16
16
17
17
18
18
class Headers :
19
+ AUTHORIZATION = "Authorization"
19
20
GUEST_TOKEN = "x-guest-token"
20
21
CSRF_TOKEN = "x-csrf-token"
21
22
AUTH_TYPE = "x-twitter-auth-type"
22
23
RATE_LIMIT_LIMIT = "x-rate-limit-limit"
23
24
RATE_LIMIT_RESET = "x-rate-limit-reset"
24
25
25
26
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
+
26
46
class WebAPI (BaseAPI ):
27
47
auth_token = AUTH_TOKEN
28
48
headers = {
29
- "Authorization" : "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH"
30
- "5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" ,
49
+ "Authorization" : AuthorizationToken .GUEST ,
31
50
"Origin" : "https://twitter.com" ,
32
51
"Referer" : "https://twitter.com" ,
33
52
Headers .GUEST_TOKEN : "" ,
@@ -48,8 +67,10 @@ class WebAPI(BaseAPI):
48
67
}
49
68
features = {
50
69
"blue_business_profile_image_shape_enabled" : False ,
70
+ "rweb_lists_timeline_redesign_enabled" : True ,
51
71
"responsive_web_graphql_exclude_directive_enabled" : True ,
52
72
"verified_phone_label_enabled" : False ,
73
+ "creator_subscriptions_tweet_preview_api_enabled" : True ,
53
74
"responsive_web_graphql_timeline_navigation_enabled" : True ,
54
75
"responsive_web_graphql_skip_user_profile_image_extensions_enabled" : False ,
55
76
"tweetypie_unmention_optimization_enabled" : True ,
@@ -58,15 +79,17 @@ class WebAPI(BaseAPI):
58
79
"graphql_is_translatable_rweb_tweet_is_translatable_enabled" : False ,
59
80
"view_counts_everywhere_api_enabled" : True ,
60
81
"longform_notetweets_consumption_enabled" : True ,
82
+ "responsive_web_twitter_article_tweet_consumption_enabled" : False ,
61
83
"tweet_awards_web_tipping_enabled" : False ,
62
- "freedom_of_speech_not_reach_fetch_enabled" : False ,
84
+ "freedom_of_speech_not_reach_fetch_enabled" : True ,
63
85
"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 ,
67
87
"interactive_text_enabled" : True ,
68
88
"responsive_web_text_conversations_enabled" : False ,
69
89
"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 ,
70
93
"responsive_web_enhance_cards_enabled" : False ,
71
94
}
72
95
@@ -81,7 +104,12 @@ def __init__(self):
81
104
82
105
async def fetch (self , status_id : int ) -> Illust :
83
106
"""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
+
85
113
if "extended_entities" not in tweet :
86
114
raise NazurinError ("No photo found." )
87
115
media = tweet ["extended_entities" ]["media" ]
@@ -112,7 +140,16 @@ async def fetch(self, status_id: int) -> Illust:
112
140
113
141
async def tweet_detail (self , tweet_id : str ):
114
142
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
+ )
116
153
variables = WebAPI .variables
117
154
variables .update ({"focalTweetId" : tweet_id })
118
155
params = {
@@ -129,6 +166,31 @@ async def tweet_detail(self, tweet_id: str):
129
166
logger .info ("{}" , response )
130
167
raise NazurinError (f"{ msg } { error } " ) from error
131
168
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
+
132
194
async def _require_auth (self ):
133
195
if not WebAPI .headers .get (Headers .AUTH_TYPE ):
134
196
await self ._get_guest_token ()
@@ -215,7 +277,15 @@ def _process_response(self, response: dict, tweet_id: str):
215
277
tweet = None
216
278
for entry in entries :
217
279
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" ]
219
289
break
220
290
221
291
if not tweet :
@@ -240,7 +310,6 @@ def _process_response(self, response: dict, tweet_id: str):
240
310
# the result is not a direct tweet type, the real tweet is nested.
241
311
if typename == "TweetWithVisibilityResults" or tweet .get ("tweet" ):
242
312
tweet = tweet ["tweet" ]
243
-
244
313
tweet = WebAPI .normalize_tweet (tweet )
245
314
# Return original tweet if it's a retweet
246
315
retweet_original = tweet .get ("retweeted_status_result" )
@@ -255,6 +324,9 @@ def normalize_tweet(data: dict):
255
324
Transform tweet object from API to a normalized schema.
256
325
"""
257
326
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 } ." )
258
330
tweet = data ["legacy" ]
259
331
tweet .update (
260
332
{
@@ -281,3 +353,14 @@ def normalize_user(data: dict):
281
353
}
282
354
)
283
355
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