fix: adapt to Twitter API schema changes (April 2026) (#51)

- Migrate user profile fields from `legacy{}` to `core{}` / `avatar{}` / `location{}` (with legacy fallback for older response shapes). Affects `user`, `whoami`, `status`, `followers`, `following`.
- Fix `user-posts` empty results: add missing `includePromotedContent` variable and update `instructions` path from `timeline_v2` to `timeline`.
- Switch `followers` / `following` to POST (Twitter changed the requirement).
- Refresh 16 stale `FALLBACK_QUERY_IDS` from fa0311/twitter-openapi.
- Drop `legacy{}` early-return in `parse_user_result`; key existence on `rest_id` so followers/following keep working when Twitter fully drops legacy.
- Add unit tests for the new core/avatar/location fallback chain and rest_id/typename guards.
This commit is contained in:
nemo
2026-04-29 21:20:39 +08:00
committed by GitHub
parent 7816f8d813
commit 1f3a9ee535
4 changed files with 136 additions and 32 deletions

View File

@@ -208,7 +208,7 @@ class TestBuildGraphqlUrl:
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 ──────────────────────────────────────────────────
@@ -1230,6 +1230,89 @@ class TestParseUserResult:
assert user.tweets_count == 78
assert user.likes_count == 0
def test_reads_core_avatar_location_when_legacy_absent(self):
"""New API shape: name/screen_name/created_at moved to core{},
profile_image_url to avatar.image_url, location to location.location.
legacy{} may be empty or missing entirely."""
user = parse_user_result(
{
"rest_id": "user-2",
"core": {
"name": "Bob",
"screen_name": "bob",
"created_at": "Tue Mar 21 17:25:43 +0000 2023",
},
"avatar": {"image_url": "https://example.com/bob.jpg"},
"location": {"location": "Earth"},
"is_blue_verified": True,
}
)
assert user is not None
assert user.id == "user-2"
assert user.name == "Bob"
assert user.screen_name == "bob"
assert user.created_at == "Tue Mar 21 17:25:43 +0000 2023"
assert user.profile_image_url == "https://example.com/bob.jpg"
assert user.location == "Earth"
assert user.verified is True
def test_prefers_core_over_legacy_when_both_present(self):
"""During the migration both shapes coexist — core{} should win."""
user = parse_user_result(
{
"rest_id": "user-3",
"core": {"name": "NewName", "screen_name": "new_handle"},
"avatar": {"image_url": "https://example.com/new.jpg"},
"legacy": {
"name": "OldName",
"screen_name": "old_handle",
"profile_image_url_https": "https://example.com/old.jpg",
"description": "old bio",
},
}
)
assert user is not None
assert user.name == "NewName"
assert user.screen_name == "new_handle"
assert user.profile_image_url == "https://example.com/new.jpg"
# bio still comes from legacy — it hasn't migrated
assert user.bio == "old bio"
def test_falls_back_to_legacy_when_core_missing(self):
"""Older response shape with only legacy{} — keep working."""
user = parse_user_result(
{
"rest_id": "user-4",
"legacy": {
"name": "Carol",
"screen_name": "carol",
"profile_image_url_https": "https://example.com/carol.jpg",
"location": "Mars",
"created_at": "Mon Jan 01 00:00:00 +0000 2020",
},
}
)
assert user is not None
assert user.name == "Carol"
assert user.screen_name == "carol"
assert user.profile_image_url == "https://example.com/carol.jpg"
assert user.location == "Mars"
assert user.created_at == "Mon Jan 01 00:00:00 +0000 2020"
def test_returns_none_without_rest_id(self):
"""No rest_id means no user — drop the row instead of emitting an
empty-id UserProfile."""
assert parse_user_result({"core": {"name": "Anon"}}) is None
assert parse_user_result({}) is None
def test_returns_none_for_user_unavailable(self):
assert (
parse_user_result({"__typename": "UserUnavailable", "rest_id": "x"}) is None
)
# ── upload_media ─────────────────────────────────────────────────────────