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(&params));
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(&params));
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(&params));
300        let mut resp = request_with_json_response::<SearchResult>(req).await?;
301
302        resp.response.params = Some(params);
303        Ok(resp)
304    }
305}