From 114daffef83bfa1f7e6e7e830b565a3c65f338f6 Mon Sep 17 00:00:00 2001 From: Eris Date: Fri, 17 Apr 2026 13:22:46 +0800 Subject: [PATCH 1/2] fix: adapt to Twitter API schema changes (April 2026) - User fields migrated out of legacy{}: name/screen_name/created_at now live in core{}, profile image in avatar.image_url, location in location.location. Update parse_user_result() and fetch_user() to read from the new locations with legacy fallback. - UserTweets: add missing includePromotedContent variable (was causing HTTP 422); update instructions path from timeline_v2 to timeline with v2 fallback. - Followers/Following: both endpoints now require POST. Add use_post flag to _fetch_user_list() and enable it for both callers. - Refresh all stale FALLBACK_QUERY_IDS from fa0311/twitter-openapi (15 of 20 IDs had changed). Co-Authored-By: Claude Sonnet 4.6 --- twitter_cli/client.py | 30 +++++++++++++++++++++--------- twitter_cli/graphql.py | 32 ++++++++++++++++---------------- twitter_cli/parser.py | 16 +++++++++++----- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 4fc36ce..8668a44 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -280,20 +280,23 @@ def fetch_user(self, screen_name): raise NotFoundError("User @%s not found" % screen_name) legacy = result.get("legacy", {}) + core = result.get("core", {}) + avatar = result.get("avatar", {}) + location_obj = result.get("location", {}) return UserProfile( id=result.get("rest_id", ""), - name=legacy.get("name", ""), - screen_name=legacy.get("screen_name", screen_name), + name=core.get("name") or legacy.get("name", ""), + screen_name=core.get("screen_name") or legacy.get("screen_name", screen_name), bio=legacy.get("description", ""), - location=legacy.get("location", ""), + location=location_obj.get("location") or legacy.get("location", ""), url=_deep_get(legacy, "entities", "url", "urls", 0, "expanded_url") or "", followers_count=_parse_int(legacy.get("followers_count"), 0), following_count=_parse_int(legacy.get("friends_count"), 0), tweets_count=_parse_int(legacy.get("statuses_count"), 0), likes_count=_parse_int(legacy.get("favourites_count"), 0), verified=bool(result.get("is_blue_verified") or legacy.get("verified", False)), - profile_image_url=legacy.get("profile_image_url_https", ""), - created_at=legacy.get("created_at", ""), + profile_image_url=avatar.get("image_url") or legacy.get("profile_image_url_https", ""), + created_at=core.get("created_at") or legacy.get("created_at", ""), ) def fetch_user_tweets(self, user_id, count=20): @@ -302,9 +305,13 @@ def fetch_user_tweets(self, user_id, count=20): return self._fetch_timeline( "UserTweets", count, - lambda data: _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions"), + lambda data: ( + _deep_get(data, "data", "user", "result", "timeline", "timeline", "instructions") + or _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions") + ), extra_variables={ "userId": user_id, + "includePromotedContent": True, "withQuickPromoteEligibilityTweetFields": True, "withVoice": True, "withV2Timeline": True, @@ -447,6 +454,7 @@ def fetch_followers(self, user_id, count=20): return self._fetch_user_list( "Followers", user_id, count, lambda data: _deep_get(data, "data", "user", "result", "timeline", "timeline", "instructions"), + use_post=True, ) def fetch_following(self, user_id, count=20): @@ -455,6 +463,7 @@ def fetch_following(self, user_id, count=20): return self._fetch_user_list( "Following", user_id, count, lambda data: _deep_get(data, "data", "user", "result", "timeline", "timeline", "instructions"), + use_post=True, ) # ── Write operations ───────────────────────────────────────────── @@ -813,8 +822,8 @@ def _fetch_timeline(self, operation_name, count, get_instructions, extra_variabl return tweets[:count], continuation_cursor return tweets[:count] - def _fetch_user_list(self, operation_name, user_id, count, get_instructions): - # type: (str, str, int, Callable[[Any], Any]) -> List[UserProfile] + def _fetch_user_list(self, operation_name, user_id, count, get_instructions, use_post=False): + # type: (str, str, int, Callable[[Any], Any], bool) -> List[UserProfile] """Generic user list fetcher (for followers/following) with pagination.""" if count <= 0: return [] @@ -835,7 +844,10 @@ def _fetch_user_list(self, operation_name, user_id, count, get_instructions): if cursor: variables["cursor"] = cursor - data = self._graphql_get(operation_name, variables, FEATURES) + if use_post: + data = self._graphql_post(operation_name, variables, FEATURES) + else: + data = self._graphql_get(operation_name, variables, FEATURES) instructions = get_instructions(data) if not instructions: logger.warning("No user list instructions found") diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index d600497..d34ea35 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -27,26 +27,26 @@ # ── Fallback (hardcoded) queryIds ──────────────────────────────────────── FALLBACK_QUERY_IDS = { - "HomeTimeline": "L8Lb9oomccM012S7fQ-QKA", - "HomeLatestTimeline": "tzmrSIWxyV4IRRh9nij6TQ", - "UserByScreenName": "IGgvgiOx4QZndDHuD3x9TQ", - "UserTweets": "O0epvwaQPUx-bT9YlqlL6w", - "TweetDetail": "xIYgDwjboktoFeXe_fgacw", - "Likes": "RozQdCp4CilQzrcuU0NY5w", - "SearchTimeline": "rkp6b4vtR9u7v3naGoOzUQ", - "Bookmarks": "uzboyXSHSJrR-mGJqep0TQ", - "ListLatestTweetsTimeline": "fb_6wmHD2dk9D-xYXOQlgw", - "Followers": "Enf9DNUZYiT037aersI5gg", - "Following": "ntIPnH1WMBKW--4Tn1q71A", - "CreateTweet": "zkcFc6F-RKRgWN8HUkJfZg", - "DeleteTweet": "nxpZCY2K-I6QoFHAHeojFQ", + "HomeTimeline": "c-CzHF1LboFilMpsx4ZCrQ", + "HomeLatestTimeline": "BKB7oi212Fi7kQtCBGE4zA", + "UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A", + "UserTweets": "q6xj5bs0hapm9309hexA_g", + "TweetDetail": "xd_EMdYvB9hfZsZ6Idri0w", + "Likes": "lIDpu_NWL7_VhimGGt0o6A", + "SearchTimeline": "VhUd6vHVmLBcw0uX-6jMLA", + "Bookmarks": "2neUNDqrrFzbLui8yallcQ", + "ListLatestTweetsTimeline": "RlZzktZY_9wJynoepm8ZsA", + "Followers": "IOh4aS6UdGWGJUYTqliQ7Q", + "Following": "zx6e-TLzRkeDO_a7p4b3JQ", + "CreateTweet": "IID9x6WsdMnTlXnzXGq8ng", + "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA", - "CreateRetweet": "mbRO74GrOvSfRcJnlMapnQ", - "DeleteRetweet": "ZyZigVsNiFO6v1dEks1eWg", + "CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA", + "DeleteRetweet": "iQtK4dl5hBmXewYZuEOKVw", "CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q", "DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ", - "TweetResultByRestId": "zy39CwTyYhU-_0LP7dljjg", + "TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg", "BookmarkFoldersSlice": "i78YDd0Tza-dV4SYs58kRg", "BookmarkFolderTimeline": "hNY7X2xE2N7HVF6Qb_mu6w", } diff --git a/twitter_cli/parser.py b/twitter_cli/parser.py index 6dd6f6f..705e8e6 100644 --- a/twitter_cli/parser.py +++ b/twitter_cli/parser.py @@ -378,20 +378,26 @@ def parse_user_result(user_data): legacy = user_data.get("legacy", {}) if not legacy: return None + # Twitter API migrated name/screen_name/created_at to core{}, avatar to + # avatar.image_url, and location to location.location. Fall back to legacy + # for older response shapes. + core = user_data.get("core", {}) + avatar = user_data.get("avatar", {}) + location_obj = user_data.get("location", {}) return UserProfile( id=user_data.get("rest_id", ""), - name=legacy.get("name", ""), - screen_name=legacy.get("screen_name", ""), + name=core.get("name") or legacy.get("name", ""), + screen_name=core.get("screen_name") or legacy.get("screen_name", ""), bio=legacy.get("description", ""), - location=legacy.get("location", ""), + location=location_obj.get("location") or legacy.get("location", ""), url=_deep_get(legacy, "entities", "url", "urls", 0, "expanded_url") or "", followers_count=_parse_int(legacy.get("followers_count"), 0), following_count=_parse_int(legacy.get("friends_count"), 0), tweets_count=_parse_int(legacy.get("statuses_count"), 0), likes_count=_parse_int(legacy.get("favourites_count"), 0), verified=user_data.get("is_blue_verified", False) or legacy.get("verified", False), - profile_image_url=legacy.get("profile_image_url_https", ""), - created_at=legacy.get("created_at", ""), + profile_image_url=avatar.get("image_url") or legacy.get("profile_image_url_https", ""), + created_at=core.get("created_at") or legacy.get("created_at", ""), ) From 26424b4a54e123fd9d4ea988110156f1bd212bda Mon Sep 17 00:00:00 2001 From: Eris Date: Fri, 17 Apr 2026 15:29:39 +0800 Subject: [PATCH 2/2] test: update SearchTimeline queryId regression assertion The queryId refresh in the previous commit bumped SearchTimeline to VhUd6vHVmLBcw0uX-6jMLA; align the pinned-value regression test with it. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index d0a6971..91b37d6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -208,7 +208,7 @@ def test_url_length_with_full_features(self): def test_searchtimeline_fallback_query_id_regression(self): """Keep SearchTimeline fallback aligned with the live operation after issue #39.""" - assert FALLBACK_QUERY_IDS["SearchTimeline"] == "rkp6b4vtR9u7v3naGoOzUQ" + assert FALLBACK_QUERY_IDS["SearchTimeline"] == "VhUd6vHVmLBcw0uX-6jMLA" # ── _best_chrome_target ──────────────────────────────────────────────────