From 979b7b89cfe8f24b28017ec85bd0c7a9ef106233 Mon Sep 17 00:00:00 2001 From: 4cuck <40539855+4cuck@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:55:30 +0000 Subject: [PATCH] Add a profile Reposts tab and send X requests in the captured Chrome header order. --- src/api/client.rs | 6 +- src/api/client_endpoints.rs | 13 ++++ src/api/endpoints.rs | 14 ++++ src/api/http.rs | 138 +++++++++++++++++++++++++++++++++++- src/cache/keys.rs | 4 ++ src/routes/rss.rs | 19 +++++ src/routes/seo.rs | 2 +- src/routes/timeline.rs | 24 +++++++ src/types/timeline.rs | 1 + src/views/rss.rs | 1 + src/views/timeline.rs | 4 ++ 11 files changed, 220 insertions(+), 6 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index 4430eaa..72eb16b 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -903,10 +903,8 @@ impl ApiClient { .parse() .map_err(|_| Error::Internal("invalid cookie header value".into()))?, ); - headers.insert( - header::ORIGIN, - header::HeaderValue::from_static("https://x.com"), - ); + // A same-origin GraphQL GET does not send Origin. Account settings + // and form posts do, and those builders add it themselves. headers.insert( header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"), diff --git a/src/api/client_endpoints.rs b/src/api/client_endpoints.rs index d8892b9..eb8ddd6 100644 --- a/src/api/client_endpoints.rs +++ b/src/api/client_endpoints.rs @@ -429,6 +429,19 @@ impl ApiClient { parser::parse_timeline(&data) } + /// Profile Reposts tab (`UserRepostsTimeline`). + pub async fn get_user_reposts(&self, user_id: &str, cursor: Option<&str>) -> Result { + let data = self + .graphql_request::( + endpoints::GRAPH_USER_REPOSTS, + &endpoints::user_reposts_vars(user_id, cursor), + endpoints::USER_REPOSTS_FEATURES, + Some(endpoints::USER_REPOSTS_FIELD_TOGGLES), + ) + .await?; + parser::parse_timeline(&data) + } + async fn posts_from_replies(&self, user_id: &str) -> Result { let mut timeline = self.get_user_tweets_and_replies(user_id, None).await?; timeline.keep_posts(); diff --git a/src/api/endpoints.rs b/src/api/endpoints.rs index 7ff66a0..12564b4 100644 --- a/src/api/endpoints.rs +++ b/src/api/endpoints.rs @@ -27,6 +27,8 @@ pub const GRAPH_LIST_BY_SLUG: &str = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"; pub const GRAPH_LIST_TWEETS: &str = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"; pub const GRAPH_LIST_MEMBERS: &str = "BQp2IEYkgxuSxqbTAr1e1g/ListMembers"; pub const GRAPH_USER_TWEETS_AND_REPLIES: &str = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"; +/// Web query for a profile's Reposts tab, from a logged-in Chrome capture. +pub const GRAPH_USER_REPOSTS: &str = "xqXxsWU5PFYzr90dfab6rg/UserRepostsTimeline"; pub const GRAPH_USER_TWEETS_AND_REPLIES_V2: &str = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"; pub const GRAPH_TWEET_EDIT_HISTORY: &str = "upS9teTSG45aljmP9oTuXA/TweetEditHistory"; @@ -57,6 +59,11 @@ pub const GQL_FEATURES: &str = r#"{"android_ad_formats_media_component_render_ov pub const USER_FIELD_TOGGLES: &str = r#"{"withPayments":false,"withAuxiliaryUserLabels":true}"#; pub const USER_TWEETS_FIELD_TOGGLES: &str = r#"{"withArticlePlainText":false}"#; +pub const USER_REPOSTS_FIELD_TOGGLES: &str = + r#"{"withPayments":false,"withArticlePlainText":false}"#; +/// Features sent with `UserRepostsTimeline` in the same capture. The query +/// id is paired with this set; the older shared blob is a different generation. +pub const USER_REPOSTS_FEATURES: &str = r#"{"rweb_video_screen_enabled":false,"rweb_cashtags_enabled":true,"profile_label_improvements_pcf_label_in_post_enabled":true,"responsive_web_profile_redirect_enabled":true,"rweb_tipjar_consumption_enabled":false,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"rweb_cashtags_composer_attachment_enabled":true,"responsive_web_jetfuel_frame":true,"rweb_sports_post_context_enabled":true,"responsive_web_grok_share_attachment_enabled":true,"responsive_web_grok_annotations_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"rweb_conversational_replies_downvote_enabled":false,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"content_disclosure_indicator_enabled":true,"content_disclosure_ai_generated_indicator_enabled":true,"responsive_web_grok_show_grok_translated_post":true,"responsive_web_grok_analysis_button_from_backend":true,"post_ctas_fetch_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":false,"responsive_web_nested_quote_preview_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"responsive_web_grok_community_note_auto_translation_is_enabled":true,"responsive_web_enhance_cards_enabled":false}"#; pub const TWEET_DETAIL_FIELD_TOGGLES: &str = r#"{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"#; // ── Helper ────────────────────────────────────────────────────────────── @@ -90,6 +97,13 @@ pub fn user_tweets_vars(user_id: &str, cursor: Option<&str>) -> String { })) } +pub fn user_reposts_vars(user_id: &str, cursor: Option<&str>) -> String { + vars(json!({ + "userId": user_id, "cursor": cursor, "count": 20, + "includePromotedContent": true, "withVoice": true, + })) +} + pub fn user_media_vars(user_id: &str, cursor: Option<&str>) -> String { vars(json!({ "userId": user_id, "cursor": cursor, "count": 20, diff --git a/src/api/http.rs b/src/api/http.rs index 0e47ddf..4e8649b 100644 --- a/src/api/http.rs +++ b/src/api/http.rs @@ -33,6 +33,7 @@ use std::{ use axum::http::{ HeaderMap, + HeaderName, HeaderValue, Method, StatusCode, @@ -449,6 +450,72 @@ fn default_identity() -> Identity { } } +/// Header order from a logged-in Chrome 152 session on x.com. +/// +/// Names that a given request does not send are skipped, so a GraphQL GET +/// stays in the order of those calls and an image or video fetch stays in +/// the order of the CDN calls. `x-twitter-polling` and `origin` sit where the +/// few requests that send them placed them. +fn wire_header_order(purpose: Purpose) -> Vec { + let names: &[&str] = match purpose { + Purpose::Api => { + &[ + "sec-gpc", + "sec-ch-ua-platform", + "authorization", + "x-csrf-token", + "sec-ch-ua", + "x-twitter-client-language", + "sec-ch-ua-mobile", + "x-twitter-active-user", + "x-client-transaction-id", + "x-twitter-auth-type", + "user-agent", + "dnt", + "x-twitter-polling", + "content-type", + "accept", + "origin", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "referer", + "accept-encoding", + "accept-language", + "cookie", + "priority", + ] + }, + Purpose::Media => { + &[ + "sec-gpc", + "sec-ch-ua-platform", + "user-agent", + "sec-ch-ua", + "dnt", + "sec-ch-ua-mobile", + "accept", + "origin", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "sec-fetch-storage-access", + "referer", + "accept-encoding", + "accept-language", + "range", + "if-range", + "cookie", + "priority", + ] + }, + }; + names + .iter() + .map(|name| HeaderName::from_static(name)) + .collect() +} + fn build_client( identity: Identity, proxy: Option<&ProxyConfig>, @@ -468,7 +535,11 @@ fn build_client( .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL) .http2_keep_alive_timeout(KEEP_ALIVE_TIMEOUT) .http2_keep_alive_while_idle(true) - .tcp_nodelay(true); + .tcp_nodelay(true) + // Impersonation installs a document-navigation order. x.com's own calls + // use a different order, captured from Chrome 152, and headers missing + // from that list are appended after `priority`. + .http2_headers_order(wire_header_order(purpose)); builder = match proxy { Some(proxy) => { builder.proxy( @@ -758,6 +829,71 @@ mod tests { assert_eq!(bare.url(), "http://squid.local:8080"); } + #[test] + fn api_header_order_matches_the_chrome_capture() { + let order = wire_header_order(Purpose::Api); + let mut headers = HeaderMap::new(); + // Inserted backwards on purpose: the wire order is the capture, not + // insertion order. + for name in [ + "priority", + "cookie", + "accept-language", + "accept-encoding", + "referer", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "accept", + "content-type", + "user-agent", + "x-twitter-auth-type", + "x-client-transaction-id", + "x-twitter-active-user", + "sec-ch-ua-mobile", + "x-twitter-client-language", + "sec-ch-ua", + "x-csrf-token", + "authorization", + "sec-ch-ua-platform", + ] { + headers.insert(HeaderName::from_static(name), HeaderValue::from_static("x")); + } + let positions: std::collections::HashMap<&HeaderName, usize> = order + .iter() + .enumerate() + .map(|(index, name)| (name, index)) + .collect(); + let mut names: Vec = headers.keys().cloned().collect(); + names.sort_by_key(|name| positions.get(name).copied().unwrap_or(order.len())); + let joined: Vec<&str> = names.iter().map(HeaderName::as_str).collect(); + assert_eq!( + joined, + vec![ + "sec-ch-ua-platform", + "authorization", + "x-csrf-token", + "sec-ch-ua", + "x-twitter-client-language", + "sec-ch-ua-mobile", + "x-twitter-active-user", + "x-client-transaction-id", + "x-twitter-auth-type", + "user-agent", + "content-type", + "accept", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "referer", + "accept-encoding", + "accept-language", + "cookie", + "priority", + ] + ); + } + #[test] fn api_headers_look_like_an_xhr() { let mut headers = HeaderMap::new(); diff --git a/src/cache/keys.rs b/src/cache/keys.rs index 1b040fc..d66b860 100644 --- a/src/cache/keys.rs +++ b/src/cache/keys.rs @@ -65,6 +65,10 @@ pub fn rss_media(username: &str) -> String { rss(&format!("media:{}", username.to_lowercase())) } +pub fn rss_reposts(username: &str) -> String { + rss(&format!("reposts:{}", username.to_lowercase())) +} + pub fn rss_search(query: &str) -> String { rss(&format!("search:{}", query.to_lowercase())) } diff --git a/src/routes/rss.rs b/src/routes/rss.rs index 2f8b2f1..7ab0086 100644 --- a/src/routes/rss.rs +++ b/src/routes/rss.rs @@ -45,6 +45,7 @@ pub fn router() -> Router { Router::new() .route("/{username}/rss", get(user_rss)) .route("/{username}/with_replies/rss", get(user_replies_rss)) + .route("/{username}/reposts/rss", get(user_reposts_rss)) .route("/{username}/media/rss", get(user_media_rss)) .route("/{username}/search/rss", get(user_search_rss)) .route("/{username}/status/{id}/rss", get(thread_rss)) @@ -77,6 +78,7 @@ fn check_rss_enabled(state: &AppState) -> Result<()> { enum UserRssKind { Tweets, Replies, + Reposts, Media, } @@ -90,6 +92,7 @@ async fn user_rss_handler( let (feed_kind, cache_key_fn): (&str, fn(&str) -> String) = match kind { UserRssKind::Tweets => ("tweets", cache_keys::rss_user), UserRssKind::Replies => ("replies", cache_keys::rss_replies), + UserRssKind::Reposts => ("reposts", cache_keys::rss_reposts), UserRssKind::Media => ("media", cache_keys::rss_media), }; @@ -127,6 +130,7 @@ async fn user_rss_handler( .get_user_tweets_and_replies(&user.id, cursor) .await? }, + UserRssKind::Reposts => state.api.get_user_reposts(&user.id, cursor).await?, UserRssKind::Media => state.api.get_user_media(&user.id, cursor).await?, }; let tweets = timeline.content.into_iter().flatten().collect::>(); @@ -169,6 +173,21 @@ async fn user_replies_rss( .await } +async fn user_reposts_rss( + State(state): State, + Path(username): Path, + Query(query): Query, +) -> Result { + check_rss_enabled(&state)?; + user_rss_handler( + &state, + &username, + query.cursor.as_deref(), + UserRssKind::Reposts, + ) + .await +} + async fn user_media_rss( State(state): State, Path(username): Path, diff --git a/src/routes/seo.rs b/src/routes/seo.rs index 061a936..7195189 100644 --- a/src/routes/seo.rs +++ b/src/routes/seo.rs @@ -374,7 +374,7 @@ const SEED_PROFILES: &[&str] = &[ ]; /// Profile paths worth listing for each handle. -const PROFILE_PATHS: &[&str] = &["", "/with_replies", "/media", "/search"]; +const PROFILE_PATHS: &[&str] = &["", "/with_replies", "/reposts", "/media", "/search"]; const XML_CONTENT_TYPE: &str = "text/xml; charset=utf-8"; pub fn router() -> Router { diff --git a/src/routes/timeline.rs b/src/routes/timeline.rs index 48a5aff..8e2a27b 100644 --- a/src/routes/timeline.rs +++ b/src/routes/timeline.rs @@ -217,6 +217,7 @@ pub fn router() -> Router { .route("/{username}", get(user_timeline)) .route("/{username}/", get(user_timeline)) .route("/{username}/with_replies", get(user_replies)) + .route("/{username}/reposts", get(user_reposts)) .route("/{username}/media", get(user_media)) .route("/{username}/search", get(user_search)) } @@ -347,6 +348,7 @@ async fn user_tab_handler( let (cache_kind, tab_str, title_prefix) = match tab { TimelineKind::Replies => ("replies", "with_replies", "Tweets & replies from"), + TimelineKind::Reposts => ("reposts", "reposts", "Reposts from"), TimelineKind::Media => ("media", "media", "Media from"), _ => unreachable!(), }; @@ -363,6 +365,7 @@ async fn user_tab_handler( TimelineKind::Replies => { state.api.get_user_tweets_and_replies(&user_id, None).await }, + TimelineKind::Reposts => state.api.get_user_reposts(&user_id, None).await, TimelineKind::Media => state.api.get_user_media(&user_id, None).await, _ => return, }; @@ -377,6 +380,7 @@ async fn user_tab_handler( } let result = match tab { TimelineKind::Replies => state.api.get_user_tweets_and_replies(&user.id, None).await, + TimelineKind::Reposts => state.api.get_user_reposts(&user.id, None).await, TimelineKind::Media => state.api.get_user_media(&user.id, None).await, _ => unreachable!(), }; @@ -394,6 +398,7 @@ async fn user_tab_handler( .get_user_tweets_and_replies(&user.id, cursor) .await }, + TimelineKind::Reposts => state.api.get_user_reposts(&user.id, cursor).await, TimelineKind::Media => state.api.get_user_media(&user.id, cursor).await, _ => unreachable!(), } @@ -489,6 +494,25 @@ async fn user_replies( .await } +async fn user_reposts( + State(state): State, + jar: CookieJar, + Path(username): Path, + Query(query): Query, +) -> Result { + let prefs = Prefs::from_cookies(&jar, &state.config); + user_tab_handler( + &state, + &prefs, + &username, + query.cursor.as_deref(), + TimelineKind::Reposts, + true, + "", + ) + .await +} + async fn user_media( State(state): State, jar: CookieJar, diff --git a/src/types/timeline.rs b/src/types/timeline.rs index dcf8168..11ee975 100644 --- a/src/types/timeline.rs +++ b/src/types/timeline.rs @@ -16,6 +16,7 @@ pub enum TimelineKind { #[default] Tweets, Replies, + Reposts, Media, Search, } diff --git a/src/views/rss.rs b/src/views/rss.rs index baa8bce..5a36e8e 100644 --- a/src/views/rss.rs +++ b/src/views/rss.rs @@ -73,6 +73,7 @@ pub fn render_user_rss_with_pinned( let url_prefix = config.url_prefix(); let feed_path = match feed_kind { "replies" => format!("/{}/with_replies/rss", user.username), + "reposts" => format!("/{}/reposts/rss", user.username), "media" => format!("/{}/media/rss", user.username), _ => format!("/{}/rss", user.username), }; diff --git a/src/views/timeline.rs b/src/views/timeline.rs index d5434b8..f81b974 100644 --- a/src/views/timeline.rs +++ b/src/views/timeline.rs @@ -286,6 +286,9 @@ pub fn render_timeline_tabs(active_tab: TimelineKind, username: &str) -> Markup li class=(if active_tab == TimelineKind::Replies { "tab-item active wide" } else { "tab-item wide" }) { a href=(format!("/{username}/with_replies")) { "Tweets & Replies" } } + li class=(if active_tab == TimelineKind::Reposts { "tab-item active" } else { "tab-item" }) { + a href=(format!("/{username}/reposts")) { "Reposts" } + } li class=(if active_tab == TimelineKind::Media { "tab-item active" } else { "tab-item" }) { a href=(format!("/{username}/media")) { "Media" } } @@ -319,6 +322,7 @@ pub fn render_media_view_tabs(base: &str, current: &str) -> Markup { pub fn tab_to_kind(tab: &str) -> TimelineKind { match tab { "replies" | "with_replies" => TimelineKind::Replies, + "reposts" => TimelineKind::Reposts, "media" => TimelineKind::Media, "search" => TimelineKind::Search, _ => TimelineKind::Tweets,