egg_mode/search.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! Structs and methods for searching for tweets.
6//!
7//! Since there are several optional parameters for searches, egg-mode handles it with a builder
8//! pattern. To begin, call `search` with your requested search term. Additional parameters can be
9//! added onto the `SearchBuilder` struct that is returned. When you're ready to load the first
10//! page of results, hand your tokens to `call`.
11//!
12//! ```rust,no_run
13//! # use egg_mode::Token;
14//! # #[tokio::main]
15//! # async fn main() {
16//! # let token: Token = unimplemented!();
17//! use egg_mode::search::{self, ResultType};
18//!
19//! let search = search::search("rustlang")
20//! .result_type(ResultType::Recent)
21//! .call(&token)
22//! .await
23//! .unwrap();
24//!
25//! for tweet in &search.statuses {
26//! println!("(@{}) {}", tweet.user.as_ref().unwrap().screen_name, tweet.text);
27//! }
28//! # }
29//! ```
30//!
31//! Once you have your `SearchResult`, you can navigate the search results by calling `older` and
32//! `newer` to get the next and previous pages, respsectively. In addition, you can see your
33//! original query in the search result struct as well, so you can categorize multiple searches by
34//! their query. While this is given as a regular field, note that modifying `query` will not
35//! change what is searched for when you call `older` or `newer`; the `SearchResult` keeps its
36//! search arguments in a separate private field.
37//!
38//! The search parameter given in the initial call to `search` has several options itself. A full
39//! reference is available in [Twitter's Search API documentation][search-doc]. This listing by
40//! itself does not include the search by Place ID, as mentioned on [a separate Tweets by Place
41//! page][search-place]. A future version of egg-mode might break these options into further
42//! methods on `SearchBuilder`.
43//!
44//! [search-doc]: https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets
45//! [search-place]: https://developer.twitter.com/en/docs/tweets/search/guides/tweets-by-place
46
47use std::fmt;
48
49use serde::{Deserialize, Deserializer};
50
51use crate::common::*;
52use crate::tweet::Tweet;
53use crate::{auth, error, links};
54
55///Begin setting up a tweet search with the given query.
56pub fn search<S: Into<CowStr>>(query: S) -> SearchBuilder {
57 SearchBuilder {
58 query: query.into(),
59 lang: None,
60 result_type: None,
61 count: None,
62 until: None,
63 geocode: None,
64 since_id: None,
65 max_id: None,
66 }
67}
68
69///Represents what kind of tweets should be included in search results.
70#[derive(Debug, Copy, Clone)]
71pub enum ResultType {
72 ///Return only the most recent tweets in the response.
73 Recent,
74 ///Return only the most popular tweets in the response.
75 Popular,
76 ///Include both popular and real-time results in the response.
77 Mixed,
78}
79
80///Display impl that turns the variants into strings that can be used as search parameters.
81impl fmt::Display for ResultType {
82 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
83 match *self {
84 ResultType::Recent => write!(f, "recent"),
85 ResultType::Popular => write!(f, "popular"),
86 ResultType::Mixed => write!(f, "mixed"),
87 }
88 }
89}
90
91///Represents a radius around a given location to return search results for.
92pub enum Distance {
93 ///A radius given in miles.
94 Miles(f32),
95 ///A radius given in kilometers.
96 Kilometers(f32),
97}
98
99///Represents a tweet search query before being sent.
100#[must_use = "SearchBuilder is lazy and won't do anything unless `call`ed"]
101pub struct SearchBuilder {
102 ///The text to search for.
103 query: CowStr,
104 lang: Option<CowStr>,
105 result_type: Option<ResultType>,
106 count: Option<u32>,
107 until: Option<(u32, u32, u32)>,
108 geocode: Option<(f32, f32, Distance)>,
109 since_id: Option<u64>,
110 max_id: Option<u64>,
111}
112
113impl SearchBuilder {
114 ///Restrict search results to those that have been machine-parsed as the given two-letter
115 ///language code.
116 pub fn lang<S: Into<CowStr>>(self, lang: S) -> Self {
117 SearchBuilder {
118 lang: Some(lang.into()),
119 ..self
120 }
121 }
122
123 ///Specify the type of search results to include. The default is `Recent`.
124 pub fn result_type(self, result_type: ResultType) -> Self {
125 SearchBuilder {
126 result_type: Some(result_type),
127 ..self
128 }
129 }
130
131 ///Set the number of tweets to return per-page, up to a maximum of 100. The default is 15.
132 pub fn count(self, count: u32) -> Self {
133 SearchBuilder {
134 count: Some(count),
135 ..self
136 }
137 }
138
139 ///Returns tweets created before the given date. Keep in mind that search is limited to the
140 ///last 7 days of results, so giving a date here that's older than a week will return no
141 ///results.
142 pub fn until(self, year: u32, month: u32, day: u32) -> Self {
143 SearchBuilder {
144 until: Some((year, month, day)),
145 ..self
146 }
147 }
148
149 ///Restricts results to users located within the given radius of the given coordinate. This is
150 ///preferably populated from location-tagged tweets, but can be filled in from the user's
151 ///profile as a fallback.
152 pub fn geocode(self, latitude: f32, longitude: f32, radius: Distance) -> Self {
153 SearchBuilder {
154 geocode: Some((latitude, longitude, radius)),
155 ..self
156 }
157 }
158
159 ///Restricts results to those with higher IDs than (i.e. that were posted after) the given
160 ///tweet ID.
161 pub fn since_tweet(self, since_id: u64) -> Self {
162 SearchBuilder {
163 since_id: Some(since_id),
164 ..self
165 }
166 }
167
168 ///Restricts results to those with IDs no higher than (i.e. were posted earlier than) the given
169 ///tweet ID. Will include the given tweet in search results.
170 pub fn max_tweet(self, max_id: u64) -> Self {
171 SearchBuilder {
172 max_id: Some(max_id),
173 ..self
174 }
175 }
176
177 ///Finalize the search terms and return the first page of responses.
178 pub async fn call(self, token: &auth::Token) -> Result<Response<SearchResult>, error::Error> {
179 let params = ParamList::new()
180 .extended_tweets()
181 .add_param("q", self.query)
182 .add_opt_param("lang", self.lang)
183 .add_opt_param("result_type", self.result_type.map_string())
184 .add_opt_param("count", self.count.map_string())
185 .add_opt_param("since_id", self.since_id.map_string())
186 .add_opt_param("max_id", self.max_id.map_string())
187 .add_opt_param(
188 "until",
189 self.until
190 .map(|(year, month, day)| format!("{}-{}-{}", year, month, day)),
191 )
192 .add_opt_param(
193 "geocode",
194 self.geocode.map(|(lat, lon, radius)| match radius {
195 Distance::Miles(r) => format!("{:.6},{:.6},{}mi", lat, lon, r),
196 Distance::Kilometers(r) => format!("{:.6},{:.6},{}km", lat, lon, r),
197 }),
198 );
199
200 let req = get(links::statuses::SEARCH, token, Some(¶ms));
201 let mut resp = request_with_json_response::<SearchResult>(req).await?;
202
203 resp.response.params = Some(params);
204 Ok(resp)
205 }
206}
207
208#[derive(Debug, Deserialize)]
209struct RawSearch {
210 search_metadata: RawSearchMetaData,
211 statuses: Vec<Tweet>,
212}
213
214#[derive(Debug, Deserialize)]
215struct RawSearchMetaData {
216 completed_in: f64,
217 max_id: u64,
218 /// absent if no more results to retrieve
219 next_results: Option<String>,
220 query: String,
221 /// absent if no results
222 refresh_url: Option<String>,
223 count: u64,
224 since_id: u64,
225}
226
227impl<'de> Deserialize<'de> for SearchResult {
228 fn deserialize<D>(deser: D) -> Result<SearchResult, D::Error>
229 where
230 D: Deserializer<'de>,
231 {
232 let raw = RawSearch::deserialize(deser)?;
233 Ok(SearchResult {
234 statuses: raw.statuses,
235 query: raw.search_metadata.query,
236 max_id: raw.search_metadata.max_id,
237 since_id: raw.search_metadata.since_id,
238 params: None,
239 })
240 }
241}
242
243///Represents a page of search results, along with metadata to request the next or previous page.
244#[derive(Debug)]
245pub struct SearchResult {
246 ///The list of statuses in this page of results.
247 pub statuses: Vec<Tweet>,
248 ///The query used to generate this page of results. Note that changing this will not affect the
249 ///`next_page` method.
250 pub query: String,
251 ///Last tweet id in this page of results. This id can be used in `SearchBuilder::since_tweet`
252 pub max_id: u64,
253 ///First tweet id in this page of results. This id can be used in `SearchBuilder::since_tweet`
254 pub since_id: u64,
255 params: Option<ParamList>,
256}
257
258impl SearchResult {
259 ///Load the next page of search results for the same query.
260 pub async fn older(&self, token: &auth::Token) -> Result<Response<SearchResult>, error::Error> {
261 let mut params = self
262 .params
263 .as_ref()
264 .cloned()
265 .unwrap_or_default()
266 .extended_tweets();
267
268 params.remove("since_id");
269
270 if let Some(min_id) = self.statuses.iter().map(|t| t.id).min() {
271 params.add_param_ref("max_id", (min_id - 1).to_string());
272 } else {
273 params.remove("max_id");
274 }
275
276 let req = get(links::statuses::SEARCH, token, Some(¶ms));
277 let mut resp = request_with_json_response::<SearchResult>(req).await?;
278
279 resp.response.params = Some(params);
280 Ok(resp)
281 }
282
283 ///Load the previous page of search results for the same query.
284 pub async fn newer(&self, token: &auth::Token) -> Result<Response<SearchResult>, error::Error> {
285 let mut params = self
286 .params
287 .as_ref()
288 .cloned()
289 .unwrap_or_default()
290 .extended_tweets();
291
292 params.remove("max_id");
293 if let Some(max_id) = self.statuses.iter().map(|t| t.id).max() {
294 params.add_param_ref("since_id", max_id.to_string());
295 } else {
296 params.remove("since_id");
297 }
298
299 let req = get(links::statuses::SEARCH, token, Some(¶ms));
300 let mut resp = request_with_json_response::<SearchResult>(req).await?;
301
302 resp.response.params = Some(params);
303 Ok(resp)
304 }
305}