fix: SearchTimeline POST + ondemand URL guard + refresh queryIds

Twitter migrated SearchTimeline from GET to POST; update fetch_search
to route through _graphql_post via a new use_post flag on _fetch_timeline,
avoiding duplicating the pagination logic (closes #39, refs #40, #42).

Also guard against get_ondemand_file_url returning None so the error
message is clear instead of crashing on NoneType.split (closes #43-adjacent).

Refresh all FALLBACK_QUERY_IDS from live JS bundles — 18 operations updated.
This commit is contained in:
jackwener
2026-03-20 18:21:29 +08:00
parent 7b8a7dd5de
commit 199a1490f9
3 changed files with 98 additions and 19 deletions

View File

@@ -341,6 +341,7 @@ class TwitterClient:
count: Max number of tweets to return.
product: Search tab — "Top", "Latest", "People", "Photos", "Videos".
"""
# Twitter migrated SearchTimeline from GET to POST — use _graphql_post.
return self._fetch_timeline(
"SearchTimeline",
count,
@@ -353,6 +354,7 @@ class TwitterClient:
"product": product,
},
override_base_variables=True,
use_post=True,
)
def fetch_tweet_detail(self, tweet_id, count=20):
@@ -730,14 +732,16 @@ class TwitterClient:
# ── Internal: timeline / user list fetchers ──────────────────────
def _fetch_timeline(self, operation_name, count, get_instructions, extra_variables=None, override_base_variables=False, field_toggles=None):
# type: (str, int, Callable[[Any], Any], Optional[Dict[str, Any]], bool, Optional[Dict[str, Any]]) -> List[Tweet]
def _fetch_timeline(self, operation_name, count, get_instructions, extra_variables=None, override_base_variables=False, field_toggles=None, use_post=False):
# type: (str, int, Callable[[Any], Any], Optional[Dict[str, Any]], bool, Optional[Dict[str, Any]], bool) -> List[Tweet]
"""Generic timeline fetcher with pagination and deduplication.
Args:
override_base_variables: If True, use only extra_variables + count/cursor
instead of the default timeline base variables. Needed for
endpoints like SearchTimeline that reject unknown variables.
use_post: If True, send request via POST instead of GET. Required for
endpoints like SearchTimeline that Twitter migrated to POST.
"""
if count <= 0:
return []
@@ -768,7 +772,10 @@ class TwitterClient:
if cursor:
variables["cursor"] = cursor
data = self._graphql_get(operation_name, variables, FEATURES, field_toggles=field_toggles)
if use_post:
data = self._graphql_post(operation_name, variables, FEATURES)
else:
data = self._graphql_get(operation_name, variables, FEATURES, field_toggles=field_toggles)
new_tweets, next_cursor = parse_timeline_response(data, get_instructions)
for tweet in new_tweets:
@@ -1075,6 +1082,8 @@ class TwitterClient:
)
home_page_response = bs4.BeautifulSoup(home_page.content, "html.parser")
ondemand_url = get_ondemand_file_url(response=home_page_response)
if not ondemand_url:
raise ValueError("Failed to extract ondemand file URL from homepage")
ondemand_file = cffi_session.get(
ondemand_url, headers=ct_headers, timeout=10,
)

View File

@@ -27,26 +27,26 @@ TWITTER_OPENAPI_URL = (
# ── Fallback (hardcoded) queryIds ────────────────────────────────────────
FALLBACK_QUERY_IDS = {
"HomeTimeline": "HCosKfLNW1AcOo3la3mMgg",
"HomeLatestTimeline": "U0cdisy7QFIoTfu3-Okw0A",
"UserByScreenName": "qRednkZG-rn1P6b48NINmQ",
"UserTweets": "E3opETHurmVJflFsUBVuUQ",
"TweetDetail": "nBS-WpgA6ZG0CyNHD517JQ",
"Likes": "dv5-II7_Bup_PHish7p6fw",
"SearchTimeline": "MJpyQGqgklrVl_0X9gNy3A",
"HomeTimeline": "L8Lb9oomccM012S7fQ-QKA",
"HomeLatestTimeline": "tzmrSIWxyV4IRRh9nij6TQ",
"UserByScreenName": "IGgvgiOx4QZndDHuD3x9TQ",
"UserTweets": "O0epvwaQPUx-bT9YlqlL6w",
"TweetDetail": "xIYgDwjboktoFeXe_fgacw",
"Likes": "RozQdCp4CilQzrcuU0NY5w",
"SearchTimeline": "rkp6b4vtR9u7v3naGoOzUQ",
"Bookmarks": "uzboyXSHSJrR-mGJqep0TQ",
"ListLatestTweetsTimeline": "ZBbXrl0FVnTqp7K6EAADog",
"Followers": "IOh4aS6UdGWGJUYTqliQ7Q",
"Following": "zx6e-TLzRkeDO_a7p4b3JQ",
"CreateTweet": "bDE2rBtZb3uyrczSZ_pI9g",
"DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
"ListLatestTweetsTimeline": "fb_6wmHD2dk9D-xYXOQlgw",
"Followers": "Enf9DNUZYiT037aersI5gg",
"Following": "ntIPnH1WMBKW--4Tn1q71A",
"CreateTweet": "zkcFc6F-RKRgWN8HUkJfZg",
"DeleteTweet": "nxpZCY2K-I6QoFHAHeojFQ",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
"CreateRetweet": "ojPdsZsimiJrUGLR1sjVsA",
"DeleteRetweet": "iQtK4dl5hBmXewYZuEOKVw",
"CreateRetweet": "mbRO74GrOvSfRcJnlMapnQ",
"DeleteRetweet": "ZyZigVsNiFO6v1dEks1eWg",
"CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q",
"DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg",
"TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg",
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"TweetResultByRestId": "zy39CwTyYhU-_0LP7dljjg",
"BookmarkFoldersSlice": "i78YDd0Tza-dV4SYs58kRg",
"BookmarkFolderTimeline": "hNY7X2xE2N7HVF6Qb_mu6w",
}