Skip to content

Commit 6037c0a

Browse files
committed
fix: Twitter API 404 when auth token not set
The previous official API is not usable when not logged in, now we have two options, one is the possibly legacy API in Fritter with a different endpoint and authorization token, the other is the latest official public API for guests (`/TweetResultByRestId`). We chose the official public API since it seems to have the same ability as the Fritter one, while providing clearer error messages. Now, logged in users will still be using the previous API, while others will use the public API. Furthermore, in order to use the same feature flags for different APIs, we merged the flags and found a sweet point. close #81
1 parent 4e38e9d commit 6037c0a

File tree

1 file changed

+93
-10
lines changed
  • nazurin/sites/twitter/api

1 file changed

+93
-10
lines changed

nazurin/sites/twitter/api/web.py

+93-10
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,37 @@
1616

1717

1818
class Headers:
19+
AUTHORIZATION = "Authorization"
1920
GUEST_TOKEN = "x-guest-token"
2021
CSRF_TOKEN = "x-csrf-token"
2122
AUTH_TYPE = "x-twitter-auth-type"
2223
RATE_LIMIT_LIMIT = "x-rate-limit-limit"
2324
RATE_LIMIT_RESET = "x-rate-limit-reset"
2425

2526

27+
class AuthorizationToken:
28+
# From Fritter
29+
GUEST = (
30+
"Bearer AAAAAAAAAAAAAAAAAAAAAGHtAgAAAAAA%2Bx7ILXNILCqk"
31+
"SGIzy6faIHZ9s3Q%3DQy97w6SIrzE7lQwPJEYQBsArEE2fC25caFwRBvAGi456G09vGR"
32+
)
33+
# Official
34+
LOGGED_IN = (
35+
"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH"
36+
"5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
37+
)
38+
39+
40+
class TweetDetailAPI:
41+
# From Fritter
42+
GUEST = "3XDB26fBve-MmjHaWTUZxA/TweetDetail"
43+
LOGGED_IN = "q94uRCEn65LZThakYcPT6g/TweetDetail"
44+
45+
2646
class WebAPI(BaseAPI):
2747
auth_token = AUTH_TOKEN
2848
headers = {
29-
"Authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH"
30-
"5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
49+
"Authorization": AuthorizationToken.GUEST,
3150
"Origin": "https://twitter.com",
3251
"Referer": "https://twitter.com",
3352
Headers.GUEST_TOKEN: "",
@@ -48,8 +67,10 @@ class WebAPI(BaseAPI):
4867
}
4968
features = {
5069
"blue_business_profile_image_shape_enabled": False,
70+
"rweb_lists_timeline_redesign_enabled": True,
5171
"responsive_web_graphql_exclude_directive_enabled": True,
5272
"verified_phone_label_enabled": False,
73+
"creator_subscriptions_tweet_preview_api_enabled": True,
5374
"responsive_web_graphql_timeline_navigation_enabled": True,
5475
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
5576
"tweetypie_unmention_optimization_enabled": True,
@@ -58,15 +79,17 @@ class WebAPI(BaseAPI):
5879
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": False,
5980
"view_counts_everywhere_api_enabled": True,
6081
"longform_notetweets_consumption_enabled": True,
82+
"responsive_web_twitter_article_tweet_consumption_enabled": False,
6183
"tweet_awards_web_tipping_enabled": False,
62-
"freedom_of_speech_not_reach_fetch_enabled": False,
84+
"freedom_of_speech_not_reach_fetch_enabled": True,
6385
"standardized_nudges_misinfo": True,
64-
(
65-
"tweet_with_visibility_results_" "prefer_gql_limited_actions_policy_enabled"
66-
): False,
86+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
6787
"interactive_text_enabled": True,
6888
"responsive_web_text_conversations_enabled": False,
6989
"longform_notetweets_richtext_consumption_enabled": False,
90+
"longform_notetweets_rich_text_read_enabled": True,
91+
"longform_notetweets_inline_media_enabled": True,
92+
"responsive_web_media_download_video_enabled": False,
7093
"responsive_web_enhance_cards_enabled": False,
7194
}
7295

@@ -81,7 +104,12 @@ def __init__(self):
81104

82105
async def fetch(self, status_id: int) -> Illust:
83106
"""Fetch & return tweet images and information."""
84-
tweet = await self.tweet_detail(status_id)
107+
108+
if AUTH_TOKEN:
109+
tweet = await self.tweet_detail(status_id)
110+
else:
111+
tweet = await self.tweet_result_by_rest_id(status_id)
112+
85113
if "extended_entities" not in tweet:
86114
raise NazurinError("No photo found.")
87115
media = tweet["extended_entities"]["media"]
@@ -112,7 +140,16 @@ async def fetch(self, status_id: int) -> Illust:
112140

113141
async def tweet_detail(self, tweet_id: str):
114142
logger.info("Fetching tweet {} from web API", tweet_id)
115-
api = "https://twitter.com/i/api/graphql/1oIoGPTOJN2mSjbbXlQifA/TweetDetail"
143+
api = "https://twitter.com/i/api/graphql/" + (
144+
TweetDetailAPI.LOGGED_IN if AUTH_TOKEN else TweetDetailAPI.GUEST
145+
)
146+
self.headers.update(
147+
{
148+
Headers.AUTHORIZATION: AuthorizationToken.LOGGED_IN
149+
if AUTH_TOKEN
150+
else AuthorizationToken.GUEST
151+
}
152+
)
116153
variables = WebAPI.variables
117154
variables.update({"focalTweetId": tweet_id})
118155
params = {
@@ -129,6 +166,31 @@ async def tweet_detail(self, tweet_id: str):
129166
logger.info("{}", response)
130167
raise NazurinError(f"{msg} {error}") from error
131168

169+
async def tweet_result_by_rest_id(self, tweet_id: str):
170+
logger.info("Fetching tweet {} from web API /TweetResultByRestId", tweet_id)
171+
api = "https://twitter.com/i/api/graphql/0hWvDhmW8YQ-S_ib3azIrw/TweetResultByRestId"
172+
variables = WebAPI.variables
173+
variables.update({"tweetId": tweet_id})
174+
params = {
175+
"variables": json.dumps(variables),
176+
"features": json.dumps(WebAPI.features),
177+
}
178+
# This API uses the same token as logged-in TweetDetail
179+
self.headers.update({Headers.AUTHORIZATION: AuthorizationToken.LOGGED_IN})
180+
await self._require_auth()
181+
response = await self._request("GET", api, params=params)
182+
try:
183+
tweetResult = response["data"]["tweetResult"]
184+
if "result" not in tweetResult:
185+
logger.warning("Empty tweet result: {}", response)
186+
raise NazurinError("Tweet not found.")
187+
return WebAPI.normalize_tweet(tweetResult["result"])
188+
except KeyError as error:
189+
msg = "Failed to parse response:"
190+
logger.error("{} {}", msg, error)
191+
logger.info("{}", response)
192+
raise NazurinError(f"{msg} {error}") from error
193+
132194
async def _require_auth(self):
133195
if not WebAPI.headers.get(Headers.AUTH_TYPE):
134196
await self._get_guest_token()
@@ -215,7 +277,15 @@ def _process_response(self, response: dict, tweet_id: str):
215277
tweet = None
216278
for entry in entries:
217279
if entry["entryId"] == f"tweet-{tweet_id}":
218-
tweet = entry["content"]["itemContent"]["tweet_results"]["result"]
280+
tweet = entry["content"]["itemContent"]["tweet_results"]
281+
if "result" not in tweet:
282+
logger.warning("Empty tweet result: {}", response)
283+
raise NazurinError(
284+
"Tweet result is empty, maybe it's a sensitive tweet "
285+
"or the author limited visibility, "
286+
"you may try setting an AUTH_TOKEN."
287+
)
288+
tweet = tweet["result"]
219289
break
220290

221291
if not tweet:
@@ -240,7 +310,6 @@ def _process_response(self, response: dict, tweet_id: str):
240310
# the result is not a direct tweet type, the real tweet is nested.
241311
if typename == "TweetWithVisibilityResults" or tweet.get("tweet"):
242312
tweet = tweet["tweet"]
243-
244313
tweet = WebAPI.normalize_tweet(tweet)
245314
# Return original tweet if it's a retweet
246315
retweet_original = tweet.get("retweeted_status_result")
@@ -255,6 +324,9 @@ def normalize_tweet(data: dict):
255324
Transform tweet object from API to a normalized schema.
256325
"""
257326

327+
if data.get("__typename") == "TweetUnavailable":
328+
reason = WebAPI.error_message_by_reason(data.get("reason"))
329+
raise NazurinError(f"Tweet is unavailable, reason: {reason}.")
258330
tweet = data["legacy"]
259331
tweet.update(
260332
{
@@ -281,3 +353,14 @@ def normalize_user(data: dict):
281353
}
282354
)
283355
return user
356+
357+
@staticmethod
358+
def error_message_by_reason(reason: str):
359+
MESSAGES = {
360+
"NsfwLoggedOut": "NSFW tweet, please log in",
361+
"Protected": "Protected tweet, you may try logging in if you have access",
362+
"Suspended": "This account has been suspended",
363+
}
364+
if reason in MESSAGES:
365+
return MESSAGES[reason]
366+
return reason

0 commit comments

Comments
 (0)