mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 14:04:35 +00:00
Add a profile Reposts tab and send X requests in the captured Chrome header order.
This commit is contained in:
1 parent
01c8303fc2
commit
979b7b89cf
11 files changed
+220
-6
No files matched your search
+2
-4
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Vendored
+4
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum TimelineKind {
|
||||
#[default]
|
||||
Tweets,
|
||||
Replies,
|
||||
Reposts,
|
||||
Media,
|
||||
Search,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user