Add a profile Reposts tab and send X requests in the captured Chrome header order.

This commit is contained in:
4cuck committed 2026-10-06 02:55:30 +00:00
1 parent 01c8303fc2
commit 979b7b89cf
11 files changed
+220 -6

No files matched your search

+2 -4
View File
@@ -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"),
+13
View File
@@ -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<Timeline> {
let data = self
.graphql_request::<UserTimelineData>(
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<Timeline> {
let mut timeline = self.get_user_tweets_and_replies(user_id, None).await?;
timeline.keep_posts();
+14
View File
@@ -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,
+137 -1
View File
@@ -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<HeaderName> {
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<HeaderName> = 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();
+4
View File
@@ -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()))
}
+19
View File
@@ -45,6 +45,7 @@ pub fn router() -> Router<AppState> {
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::<Vec<_>>();
@@ -169,6 +173,21 @@ async fn user_replies_rss(
.await
}
async fn user_reposts_rss(
State(state): State<AppState>,
Path(username): Path<String>,
Query(query): Query<RssQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
user_rss_handler(
&state,
&username,
query.cursor.as_deref(),
UserRssKind::Reposts,
)
.await
}
async fn user_media_rss(
State(state): State<AppState>,
Path(username): Path<String>,
+1 -1
View File
@@ -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<AppState> {
+24
View File
@@ -217,6 +217,7 @@ pub fn router() -> Router<AppState> {
.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<AppState>,
jar: CookieJar,
Path(username): Path<String>,
Query(query): Query<TimelineQuery>,
) -> Result<Response> {
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<AppState>,
jar: CookieJar,
+1
View File
@@ -16,6 +16,7 @@ pub enum TimelineKind {
#[default]
Tweets,
Replies,
Reposts,
Media,
Search,
}
+1
View File
@@ -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),
};
+4
View File
@@ -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,