use super::media::extract_media_urls;
use super::shared::{
default_user_agent, ensure_csrf_cookie, parse_cookie_header_into, parse_response_json,
tweet_from_result_response, tweet_id_from_url, tweet_result_params, twitter_headers,
update_cookies_from_headers, TwitterError, API_ROOT, BEARER_TOKEN, GUEST_API_ROOT, WEB_ROOT,
};
use reqwest::blocking::{Client, RequestBuilder, Response};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use reqwest::Method;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;
pub fn media_urls_from_tweet_url(tweet_url: &str) -> Result<Vec<String>, TwitterError> {
TwitterClient::builder()
.build()?
.media_urls_from_tweet_url(tweet_url)
}
#[derive(Debug, Clone)]
pub struct TwitterClientBuilder {
cookies: BTreeMap<String, String>,
user_agent: String,
timeout: Duration,
client_transaction_id: Option<String>,
client: Option<Client>,
}
impl Default for TwitterClientBuilder {
fn default() -> Self {
Self {
cookies: BTreeMap::new(),
user_agent: default_user_agent(),
timeout: Duration::from_secs(30),
client_transaction_id: None,
client: None,
}
}
}
impl TwitterClientBuilder {
pub fn cookie(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.cookies.insert(name.into(), value.into());
self
}
pub fn cookie_header(mut self, cookie_header: &str) -> Self {
parse_cookie_header_into(&mut self.cookies, cookie_header);
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
pub fn client_transaction_id(mut self, value: impl Into<String>) -> Self {
self.client_transaction_id = Some(value.into());
self
}
pub fn build(mut self) -> Result<TwitterClient, TwitterError> {
ensure_csrf_cookie(&mut self.cookies);
let client = match self.client {
Some(client) => client,
None => Client::builder()
.timeout(self.timeout)
.redirect(reqwest::redirect::Policy::limited(10))
.build()?,
};
Ok(TwitterClient {
client,
cookies: self.cookies,
user_agent: self.user_agent,
guest_token: None,
client_transaction_id: self.client_transaction_id,
})
}
}
#[derive(Debug, Clone)]
pub struct TwitterClient {
client: Client,
cookies: BTreeMap<String, String>,
user_agent: String,
guest_token: Option<String>,
client_transaction_id: Option<String>,
}
impl TwitterClient {
pub fn builder() -> TwitterClientBuilder {
TwitterClientBuilder::default()
}
pub fn media_urls_from_tweet_url(
mut self,
tweet_url: &str,
) -> Result<Vec<String>, TwitterError> {
self.media_urls_from_tweet_url_mut(tweet_url)
}
pub fn media_urls_from_tweet_url_mut(
&mut self,
tweet_url: &str,
) -> Result<Vec<String>, TwitterError> {
let tweet_id = tweet_id_from_url(tweet_url)?;
self.media_urls_from_tweet_id(&tweet_id)
}
pub fn media_urls_from_tweet_id(
&mut self,
tweet_id: &str,
) -> Result<Vec<String>, TwitterError> {
let tweet = self.tweet_result_by_rest_id(tweet_id)?;
Ok(extract_media_urls(&tweet))
}
fn logged_in(&self) -> bool {
self.cookies.contains_key("auth_token")
}
fn csrf_token(&self) -> &str {
self.cookies.get("ct0").map(String::as_str).unwrap_or("")
}
fn tweet_result_by_rest_id(&mut self, tweet_id: &str) -> Result<Value, TwitterError> {
let endpoint = "/graphql/qxWQxcMLiTPcavz9Qy5hwQ/TweetResultByRestId";
let url = format!("{API_ROOT}{endpoint}");
let params = tweet_result_params(tweet_id);
let data = self.call_json(Method::GET, &url, Some(¶ms), true)?;
tweet_from_result_response(data)
}
fn call_json(
&mut self,
method: Method,
url: &str,
params: Option<&[(&str, String)]>,
auth: bool,
) -> Result<Value, TwitterError> {
if auth && !self.logged_in() && self.guest_token.is_none() {
self.authenticate_guest()?;
}
let mut request = self.client.request(method, url);
request = self.apply_headers(request, auth)?;
if let Some(params) = params {
request = request.query(params);
}
let response = request.send()?;
self.handle_response(response)
}
fn authenticate_guest(&mut self) -> Result<(), TwitterError> {
let url = format!("{GUEST_API_ROOT}/1.1/guest/activate.json");
let response = self
.client
.post(&url)
.header(AUTHORIZATION, HeaderValue::from_static(BEARER_TOKEN))
.header(USER_AGENT, HeaderValue::from_str(&self.user_agent)?)
.header(ACCEPT, HeaderValue::from_static("*/*"))
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.send()?;
let data = self.handle_response(response)?;
let token = data
.get("guest_token")
.and_then(Value::as_str)
.ok_or_else(|| TwitterError::UnexpectedResponse("missing guest_token".into()))?
.to_string();
self.cookies.insert("gt".to_string(), token.clone());
self.guest_token = Some(token);
Ok(())
}
fn apply_headers(
&self,
request: RequestBuilder,
auth: bool,
) -> Result<RequestBuilder, TwitterError> {
let headers = twitter_headers(
&self.user_agent,
self.csrf_token(),
auth,
self.logged_in(),
self.guest_token.as_deref(),
self.client_transaction_id.as_deref(),
&self.cookies,
)?;
Ok(request
.headers(headers)
.header("Origin", WEB_ROOT)
.header("X-Requested-With", "XMLHttpRequest"))
}
fn handle_response(&mut self, response: Response) -> Result<Value, TwitterError> {
self.update_cookies(response.headers());
let status = response.status();
let text = response.text()?;
let data: Value = serde_json::from_str(&text).unwrap_or_else(|_| {
json!({
"errors": [{ "message": text }]
})
});
parse_response_json(status, data, self.logged_in())
}
fn update_cookies(&mut self, headers: &HeaderMap) {
update_cookies_from_headers(&mut self.cookies, headers);
}
}