birdapi 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bird/__init__.py +16 -0
- bird/_config.py +63 -0
- bird/_constants.py +48 -0
- bird/_features.py +256 -0
- bird/_models.py +92 -0
- bird/_query_ids.py +211 -0
- bird/_utils.py +491 -0
- bird/cli.py +769 -0
- bird/client.py +1702 -0
- birdapi-0.0.1.dist-info/METADATA +207 -0
- birdapi-0.0.1.dist-info/RECORD +14 -0
- birdapi-0.0.1.dist-info/WHEEL +4 -0
- birdapi-0.0.1.dist-info/entry_points.txt +2 -0
- birdapi-0.0.1.dist-info/licenses/LICENSE +21 -0
bird/client.py
ADDED
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
"""TwitterClient — synchronous X/Twitter GraphQL client.
|
|
2
|
+
|
|
3
|
+
Authentication requires two cookies from an active X/Twitter web session:
|
|
4
|
+
- auth_token (the session token)
|
|
5
|
+
- ct0 (the CSRF token)
|
|
6
|
+
|
|
7
|
+
These can be copied from browser DevTools → Application → Cookies → x.com.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._constants import (
|
|
23
|
+
BEARER_TOKEN,
|
|
24
|
+
FALLBACK_QUERY_IDS,
|
|
25
|
+
SETTINGS_NAME_RE,
|
|
26
|
+
SETTINGS_SCREEN_NAME_RE,
|
|
27
|
+
SETTINGS_USER_ID_RE,
|
|
28
|
+
TWITTER_API_BASE,
|
|
29
|
+
TWITTER_STATUS_UPDATE_URL,
|
|
30
|
+
)
|
|
31
|
+
from ._features import (
|
|
32
|
+
article_field_toggles,
|
|
33
|
+
bookmarks_features,
|
|
34
|
+
explore_features,
|
|
35
|
+
following_features,
|
|
36
|
+
home_timeline_features,
|
|
37
|
+
likes_features,
|
|
38
|
+
lists_features,
|
|
39
|
+
search_features,
|
|
40
|
+
tweet_create_features,
|
|
41
|
+
tweet_detail_features,
|
|
42
|
+
user_tweets_features,
|
|
43
|
+
)
|
|
44
|
+
from ._models import (
|
|
45
|
+
AboutProfile,
|
|
46
|
+
NewsItem,
|
|
47
|
+
Tweet,
|
|
48
|
+
TwitterList,
|
|
49
|
+
User,
|
|
50
|
+
)
|
|
51
|
+
from ._query_ids import query_id_store
|
|
52
|
+
from ._utils import (
|
|
53
|
+
_extract_article_text, # noqa: PLC2701 — internal helper
|
|
54
|
+
_first_text, # noqa: PLC2701
|
|
55
|
+
extract_cursor_from_instructions,
|
|
56
|
+
find_tweet_in_instructions,
|
|
57
|
+
map_tweet_result,
|
|
58
|
+
normalize_handle,
|
|
59
|
+
parse_tweets_from_instructions,
|
|
60
|
+
parse_users_from_instructions,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_DEFAULT_UA = (
|
|
64
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
65
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
66
|
+
"Chrome/131.0.0.0 Safari/537.36"
|
|
67
|
+
)
|
|
68
|
+
_PAGE_SIZE = 20
|
|
69
|
+
|
|
70
|
+
# Regex to detect query-ID mismatch errors in 400/422 responses
|
|
71
|
+
_RAW_QUERY_MISSING_RE = re.compile(r"must be defined", re.IGNORECASE)
|
|
72
|
+
_QUERY_UNSPECIFIED_RE = re.compile(r"query:\s*unspecified", re.IGNORECASE)
|
|
73
|
+
|
|
74
|
+
# Explore tab timeline IDs (base64-encoded internal identifiers)
|
|
75
|
+
_TIMELINE_IDS = {
|
|
76
|
+
"forYou": "VGltZWxpbmU6DAC2CwABAAAAB2Zvcl95b3UAAA==",
|
|
77
|
+
"trending": "VGltZWxpbmU6DAC2CwABAAAACHRyZW5kaW5nAAA=",
|
|
78
|
+
"news": "VGltZWxpbmU6DAC2CwABAAAABG5ld3MAAA==",
|
|
79
|
+
"sports": "VGltZWxpbmU6DAC2CwABAAAABnNwb3J0cwAA",
|
|
80
|
+
"entertainment": "VGltZWxpbmU6DAC2CwABAAAADWVudGVydGFpbm1lbnQAAA==",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_POST_COUNT_RE = re.compile(r"[\d.]+[KMB]?\s*posts?", re.IGNORECASE)
|
|
84
|
+
_POST_COUNT_MATCH_RE = re.compile(r"([\d.]+)([KMB]?)\s*posts?", re.IGNORECASE)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_post_count(text: str) -> Optional[int]:
|
|
88
|
+
m = _POST_COUNT_MATCH_RE.search(text)
|
|
89
|
+
if not m:
|
|
90
|
+
return None
|
|
91
|
+
num = float(m.group(1))
|
|
92
|
+
suffix = (m.group(2) or "").upper()
|
|
93
|
+
if suffix == "K":
|
|
94
|
+
num *= 1_000
|
|
95
|
+
elif suffix == "M":
|
|
96
|
+
num *= 1_000_000
|
|
97
|
+
elif suffix == "B":
|
|
98
|
+
num *= 1_000_000_000
|
|
99
|
+
return round(num)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TwitterClient:
|
|
103
|
+
"""Synchronous client for X/Twitter's internal GraphQL API.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
auth_token:
|
|
108
|
+
Value of the ``auth_token`` cookie from an active X session.
|
|
109
|
+
ct0:
|
|
110
|
+
Value of the ``ct0`` cookie (CSRF token).
|
|
111
|
+
cookie_header:
|
|
112
|
+
Full ``Cookie`` header string. When omitted it is constructed from
|
|
113
|
+
*auth_token* and *ct0*.
|
|
114
|
+
user_agent:
|
|
115
|
+
Override the default browser user-agent string.
|
|
116
|
+
timeout:
|
|
117
|
+
HTTP request timeout in seconds (default: no timeout).
|
|
118
|
+
quote_depth:
|
|
119
|
+
How many levels of quoted tweets to include in responses (default 1).
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
auth_token: str,
|
|
125
|
+
ct0: str,
|
|
126
|
+
*,
|
|
127
|
+
cookie_header: Optional[str] = None,
|
|
128
|
+
user_agent: str = _DEFAULT_UA,
|
|
129
|
+
timeout: Optional[float] = None,
|
|
130
|
+
quote_depth: int = 1,
|
|
131
|
+
) -> None:
|
|
132
|
+
if not auth_token or not ct0:
|
|
133
|
+
raise ValueError("Both auth_token and ct0 are required")
|
|
134
|
+
self._auth_token = auth_token
|
|
135
|
+
self._ct0 = ct0
|
|
136
|
+
self._cookie = cookie_header or f"auth_token={auth_token}; ct0={ct0}"
|
|
137
|
+
self._user_agent = user_agent
|
|
138
|
+
self._timeout = timeout
|
|
139
|
+
self._quote_depth = max(0, int(quote_depth))
|
|
140
|
+
self._client_uuid = str(uuid.uuid4())
|
|
141
|
+
self._client_device_id = str(uuid.uuid4())
|
|
142
|
+
self._client_user_id: Optional[str] = None
|
|
143
|
+
self._http = httpx.Client(timeout=self._timeout)
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
self._http.close()
|
|
147
|
+
|
|
148
|
+
def __enter__(self) -> "TwitterClient":
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def __exit__(self, *_: Any) -> None:
|
|
152
|
+
self.close()
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Internal helpers
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _base_headers(self) -> dict[str, str]:
|
|
159
|
+
headers: dict[str, str] = {
|
|
160
|
+
"accept": "*/*",
|
|
161
|
+
"accept-language": "en-US,en;q=0.9",
|
|
162
|
+
"authorization": f"Bearer {BEARER_TOKEN}",
|
|
163
|
+
"x-csrf-token": self._ct0,
|
|
164
|
+
"x-twitter-auth-type": "OAuth2Session",
|
|
165
|
+
"x-twitter-active-user": "yes",
|
|
166
|
+
"x-twitter-client-language": "en",
|
|
167
|
+
"x-client-uuid": self._client_uuid,
|
|
168
|
+
"x-twitter-client-deviceid": self._client_device_id,
|
|
169
|
+
"x-client-transaction-id": os.urandom(16).hex(),
|
|
170
|
+
"cookie": self._cookie,
|
|
171
|
+
"user-agent": self._user_agent,
|
|
172
|
+
"origin": "https://x.com",
|
|
173
|
+
"referer": "https://x.com/",
|
|
174
|
+
}
|
|
175
|
+
if self._client_user_id:
|
|
176
|
+
headers["x-twitter-client-user-id"] = self._client_user_id
|
|
177
|
+
return headers
|
|
178
|
+
|
|
179
|
+
def _json_headers(self) -> dict[str, str]:
|
|
180
|
+
return {**self._base_headers(), "content-type": "application/json"}
|
|
181
|
+
|
|
182
|
+
def _get_query_id(self, operation: str) -> str:
|
|
183
|
+
return query_id_store.get(operation) or FALLBACK_QUERY_IDS.get(operation, "")
|
|
184
|
+
|
|
185
|
+
def _refresh_query_ids(self) -> None:
|
|
186
|
+
if os.environ.get("BIRD_SKIP_QUERY_ID_REFRESH"):
|
|
187
|
+
return
|
|
188
|
+
query_id_store.refresh(list(FALLBACK_QUERY_IDS.keys()), force=True)
|
|
189
|
+
|
|
190
|
+
def _tweet_detail_query_ids(self) -> list[str]:
|
|
191
|
+
primary = self._get_query_id("TweetDetail")
|
|
192
|
+
return list(dict.fromkeys([primary, "97JF30KziU00483E_8elBA", "aFvUsJm2c-oDkJV75blV6g"]))
|
|
193
|
+
|
|
194
|
+
def _search_query_ids(self) -> list[str]:
|
|
195
|
+
primary = self._get_query_id("SearchTimeline")
|
|
196
|
+
return list(dict.fromkeys([primary, "M1jEez78PEfVfbQLvlWMvQ", "5h0kNbk3ii97rmfY6CdgAA"]))
|
|
197
|
+
|
|
198
|
+
def _ensure_client_user_id(self) -> None:
|
|
199
|
+
if self._client_user_id:
|
|
200
|
+
return
|
|
201
|
+
result = self.get_current_user()
|
|
202
|
+
if result and result.id:
|
|
203
|
+
self._client_user_id = result.id
|
|
204
|
+
|
|
205
|
+
def _get(self, url: str) -> httpx.Response:
|
|
206
|
+
return self._http.get(url, headers=self._json_headers())
|
|
207
|
+
|
|
208
|
+
def _post(self, url: str, body: str) -> httpx.Response:
|
|
209
|
+
return self._http.post(url, headers=self._json_headers(), content=body.encode())
|
|
210
|
+
|
|
211
|
+
def _post_form(self, url: str, data: dict, extra_headers: Optional[dict] = None) -> httpx.Response:
|
|
212
|
+
headers = {**self._base_headers(), "content-type": "application/x-www-form-urlencoded"}
|
|
213
|
+
if extra_headers:
|
|
214
|
+
headers.update(extra_headers)
|
|
215
|
+
return self._http.post(url, headers=headers, data=data)
|
|
216
|
+
|
|
217
|
+
# Retry logic for transient errors (used by bookmarks)
|
|
218
|
+
def _get_with_retry(self, url: str, max_retries: int = 2) -> httpx.Response:
|
|
219
|
+
retryable = {429, 500, 502, 503, 504}
|
|
220
|
+
base_delay = 0.5
|
|
221
|
+
for attempt in range(max_retries + 1):
|
|
222
|
+
resp = self._get(url)
|
|
223
|
+
if resp.status_code not in retryable or attempt == max_retries:
|
|
224
|
+
return resp
|
|
225
|
+
retry_after = resp.headers.get("retry-after")
|
|
226
|
+
if retry_after and retry_after.isdigit():
|
|
227
|
+
delay = int(retry_after)
|
|
228
|
+
else:
|
|
229
|
+
delay = base_delay * (2 ** attempt) + (time.monotonic() % base_delay)
|
|
230
|
+
time.sleep(delay)
|
|
231
|
+
return self._get(url) # unreachable but satisfies type checker
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _is_query_id_mismatch(payload: str) -> bool:
|
|
235
|
+
try:
|
|
236
|
+
data = json.loads(payload)
|
|
237
|
+
errors = data.get("errors") or []
|
|
238
|
+
return any(
|
|
239
|
+
(e or {}).get("extensions", {}).get("code") == "GRAPHQL_VALIDATION_FAILED"
|
|
240
|
+
or (
|
|
241
|
+
"rawQuery" in ((e or {}).get("path") or [])
|
|
242
|
+
and _RAW_QUERY_MISSING_RE.search((e or {}).get("message", ""))
|
|
243
|
+
)
|
|
244
|
+
for e in errors
|
|
245
|
+
)
|
|
246
|
+
except Exception:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Pagination helper
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def _paginate(
|
|
254
|
+
self,
|
|
255
|
+
fetch_page, # callable(cursor) -> (tweets, next_cursor, had_404, error)
|
|
256
|
+
limit: int,
|
|
257
|
+
max_pages: Optional[int] = None,
|
|
258
|
+
initial_cursor: Optional[str] = None,
|
|
259
|
+
) -> tuple[list[Tweet], Optional[str], Optional[str]]:
|
|
260
|
+
"""Generic tweet pagination loop.
|
|
261
|
+
|
|
262
|
+
Returns (tweets, next_cursor, error).
|
|
263
|
+
"""
|
|
264
|
+
tweets: list[Tweet] = []
|
|
265
|
+
seen: set[str] = set()
|
|
266
|
+
cursor = initial_cursor
|
|
267
|
+
next_cursor: Optional[str] = None
|
|
268
|
+
pages_fetched = 0
|
|
269
|
+
unlimited = limit == math.inf or limit < 0
|
|
270
|
+
|
|
271
|
+
while unlimited or len(tweets) < limit:
|
|
272
|
+
page_count = _PAGE_SIZE if unlimited else min(_PAGE_SIZE, limit - len(tweets))
|
|
273
|
+
page_tweets, page_cursor, had_404, error = fetch_page(cursor, page_count)
|
|
274
|
+
|
|
275
|
+
if error and not page_tweets:
|
|
276
|
+
# Attempt query ID refresh on 404 / mismatch
|
|
277
|
+
if had_404:
|
|
278
|
+
self._refresh_query_ids()
|
|
279
|
+
page_tweets, page_cursor, _, error = fetch_page(cursor, page_count)
|
|
280
|
+
if error and not page_tweets:
|
|
281
|
+
return tweets, None, error
|
|
282
|
+
else:
|
|
283
|
+
return tweets, None, error
|
|
284
|
+
|
|
285
|
+
pages_fetched += 1
|
|
286
|
+
added = 0
|
|
287
|
+
for t in page_tweets:
|
|
288
|
+
if t.id in seen:
|
|
289
|
+
continue
|
|
290
|
+
seen.add(t.id)
|
|
291
|
+
tweets.append(t)
|
|
292
|
+
added += 1
|
|
293
|
+
if not unlimited and len(tweets) >= limit:
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
if not page_cursor or page_cursor == cursor or not page_tweets or added == 0:
|
|
297
|
+
next_cursor = None
|
|
298
|
+
break
|
|
299
|
+
if max_pages and pages_fetched >= max_pages:
|
|
300
|
+
next_cursor = page_cursor
|
|
301
|
+
break
|
|
302
|
+
cursor = page_cursor
|
|
303
|
+
next_cursor = page_cursor
|
|
304
|
+
|
|
305
|
+
return tweets, next_cursor, None
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
# Current user
|
|
309
|
+
# ------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def get_current_user(self) -> Optional[User]:
|
|
312
|
+
"""Return the user associated with the current cookies."""
|
|
313
|
+
candidate_urls = [
|
|
314
|
+
"https://x.com/i/api/account/settings.json",
|
|
315
|
+
"https://api.twitter.com/1.1/account/settings.json",
|
|
316
|
+
"https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false",
|
|
317
|
+
]
|
|
318
|
+
for url in candidate_urls:
|
|
319
|
+
try:
|
|
320
|
+
r = self._get(url)
|
|
321
|
+
if not r.is_success:
|
|
322
|
+
continue
|
|
323
|
+
data = r.json()
|
|
324
|
+
username = (
|
|
325
|
+
data.get("screen_name")
|
|
326
|
+
or (data.get("user") or {}).get("screen_name")
|
|
327
|
+
)
|
|
328
|
+
name = (
|
|
329
|
+
data.get("name")
|
|
330
|
+
or (data.get("user") or {}).get("name")
|
|
331
|
+
or username or ""
|
|
332
|
+
)
|
|
333
|
+
user_id = (
|
|
334
|
+
data.get("user_id")
|
|
335
|
+
or data.get("user_id_str")
|
|
336
|
+
or (data.get("user") or {}).get("id_str")
|
|
337
|
+
or (data.get("user") or {}).get("id")
|
|
338
|
+
)
|
|
339
|
+
if username and user_id:
|
|
340
|
+
self._client_user_id = str(user_id)
|
|
341
|
+
return User(id=str(user_id), username=username, name=name or username)
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
# Fallback: scrape settings HTML
|
|
346
|
+
for page in ["https://x.com/settings/account", "https://twitter.com/settings/account"]:
|
|
347
|
+
try:
|
|
348
|
+
r = self._http.get(
|
|
349
|
+
page,
|
|
350
|
+
headers={"cookie": self._cookie, "user-agent": self._user_agent},
|
|
351
|
+
)
|
|
352
|
+
if not r.is_success:
|
|
353
|
+
continue
|
|
354
|
+
html = r.text
|
|
355
|
+
um = SETTINGS_SCREEN_NAME_RE.search(html)
|
|
356
|
+
im = SETTINGS_USER_ID_RE.search(html)
|
|
357
|
+
nm = SETTINGS_NAME_RE.search(html)
|
|
358
|
+
if um and im:
|
|
359
|
+
name = nm.group(1).replace('\\"', '"') if nm else um.group(1)
|
|
360
|
+
return User(id=im.group(1), username=um.group(1), name=name)
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
# User lookup
|
|
367
|
+
# ------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
def get_user_id_by_username(self, username: str) -> Optional[User]:
|
|
370
|
+
"""Resolve a username/handle to a User (id, username, name)."""
|
|
371
|
+
handle = normalize_handle(username)
|
|
372
|
+
if not handle:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
# Try GraphQL UserByScreenName
|
|
376
|
+
query_ids = [
|
|
377
|
+
"xc8f1g7BYqr6VTzTbvNlGw",
|
|
378
|
+
"qW5u-DAuXpMEG0zA1F7UGQ",
|
|
379
|
+
"sLVLhk0bGj3MVFEKTdax1w",
|
|
380
|
+
]
|
|
381
|
+
variables = {"screen_name": handle, "withSafetyModeUserFields": True}
|
|
382
|
+
features = {
|
|
383
|
+
"hidden_profile_subscriptions_enabled": True,
|
|
384
|
+
"hidden_profile_likes_enabled": True,
|
|
385
|
+
"rweb_tipjar_consumption_enabled": True,
|
|
386
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
|
387
|
+
"verified_phone_label_enabled": False,
|
|
388
|
+
"subscriptions_verification_info_is_identity_verified_enabled": True,
|
|
389
|
+
"subscriptions_verification_info_verified_since_enabled": True,
|
|
390
|
+
"highlights_tweets_tab_ui_enabled": True,
|
|
391
|
+
"responsive_web_twitter_article_notes_tab_enabled": True,
|
|
392
|
+
"subscriptions_feature_can_gift_premium": True,
|
|
393
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
|
394
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
|
395
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
|
396
|
+
"blue_business_profile_image_shape_enabled": True,
|
|
397
|
+
}
|
|
398
|
+
params = httpx.QueryParams(
|
|
399
|
+
variables=json.dumps(variables),
|
|
400
|
+
features=json.dumps(features),
|
|
401
|
+
fieldToggles=json.dumps({"withAuxiliaryUserLabels": False}),
|
|
402
|
+
)
|
|
403
|
+
for qid in query_ids:
|
|
404
|
+
try:
|
|
405
|
+
r = self._get(f"{TWITTER_API_BASE}/{qid}/UserByScreenName?{params}")
|
|
406
|
+
if not r.is_success:
|
|
407
|
+
continue
|
|
408
|
+
data = r.json()
|
|
409
|
+
result = (data.get("data") or {}).get("user", {}).get("result") or {}
|
|
410
|
+
if result.get("__typename") == "UserUnavailable":
|
|
411
|
+
return None
|
|
412
|
+
user_id = result.get("rest_id")
|
|
413
|
+
uname = (result.get("legacy") or result.get("core") or {}).get("screen_name")
|
|
414
|
+
uname_name = (result.get("legacy") or result.get("core") or {}).get("name")
|
|
415
|
+
if user_id and uname:
|
|
416
|
+
return User(id=user_id, username=uname, name=uname_name or uname)
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
# Fallback: REST show.json
|
|
421
|
+
for url in [
|
|
422
|
+
f"https://x.com/i/api/1.1/users/show.json?screen_name={handle}",
|
|
423
|
+
f"https://api.twitter.com/1.1/users/show.json?screen_name={handle}",
|
|
424
|
+
]:
|
|
425
|
+
try:
|
|
426
|
+
r = self._get(url)
|
|
427
|
+
if not r.is_success:
|
|
428
|
+
continue
|
|
429
|
+
data = r.json()
|
|
430
|
+
user_id = data.get("id_str") or (str(data["id"]) if data.get("id") else None)
|
|
431
|
+
if user_id:
|
|
432
|
+
return User(
|
|
433
|
+
id=user_id,
|
|
434
|
+
username=data.get("screen_name", handle),
|
|
435
|
+
name=data.get("name", handle),
|
|
436
|
+
)
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
# ------------------------------------------------------------------
|
|
442
|
+
# Tweet detail / thread / replies
|
|
443
|
+
# ------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def _fetch_tweet_detail(self, tweet_id: str, cursor: Optional[str] = None) -> Optional[dict]:
|
|
446
|
+
variables: dict[str, Any] = {
|
|
447
|
+
"focalTweetId": tweet_id,
|
|
448
|
+
"with_rux_injections": False,
|
|
449
|
+
"rankingMode": "Relevance",
|
|
450
|
+
"includePromotedContent": True,
|
|
451
|
+
"withCommunity": True,
|
|
452
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
|
453
|
+
"withBirdwatchNotes": True,
|
|
454
|
+
"withVoice": True,
|
|
455
|
+
}
|
|
456
|
+
if cursor:
|
|
457
|
+
variables["cursor"] = cursor
|
|
458
|
+
features = {
|
|
459
|
+
**tweet_detail_features(),
|
|
460
|
+
"articles_preview_enabled": True,
|
|
461
|
+
"articles_rest_api_enabled": True,
|
|
462
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
|
463
|
+
"rweb_video_timestamps_enabled": True,
|
|
464
|
+
}
|
|
465
|
+
field_toggles = {**article_field_toggles(), "withArticleRichContentState": True}
|
|
466
|
+
params = httpx.QueryParams(
|
|
467
|
+
variables=json.dumps(variables),
|
|
468
|
+
features=json.dumps(features),
|
|
469
|
+
fieldToggles=json.dumps(field_toggles),
|
|
470
|
+
)
|
|
471
|
+
query_ids = self._tweet_detail_query_ids()
|
|
472
|
+
had_404 = False
|
|
473
|
+
for qid in query_ids:
|
|
474
|
+
url = f"{TWITTER_API_BASE}/{qid}/TweetDetail?{params}"
|
|
475
|
+
try:
|
|
476
|
+
r = self._get(url)
|
|
477
|
+
if r.status_code == 404:
|
|
478
|
+
had_404 = True
|
|
479
|
+
# Try POST fallback
|
|
480
|
+
body = json.dumps({"variables": variables, "features": features, "queryId": qid})
|
|
481
|
+
rp = self._post(f"{TWITTER_API_BASE}/{qid}/TweetDetail", body)
|
|
482
|
+
if rp.status_code == 404:
|
|
483
|
+
continue
|
|
484
|
+
r = rp
|
|
485
|
+
if not r.is_success:
|
|
486
|
+
continue
|
|
487
|
+
data = r.json()
|
|
488
|
+
errors = data.get("errors") or []
|
|
489
|
+
has_data = bool(
|
|
490
|
+
(data.get("data") or {}).get("tweetResult")
|
|
491
|
+
or ((data.get("data") or {}).get("threaded_conversation_with_injections_v2") or {}).get("instructions")
|
|
492
|
+
)
|
|
493
|
+
if errors and not has_data:
|
|
494
|
+
continue
|
|
495
|
+
return data.get("data") or {}
|
|
496
|
+
except Exception:
|
|
497
|
+
pass
|
|
498
|
+
if had_404:
|
|
499
|
+
self._refresh_query_ids()
|
|
500
|
+
for qid in self._tweet_detail_query_ids():
|
|
501
|
+
try:
|
|
502
|
+
params2 = httpx.QueryParams(
|
|
503
|
+
variables=json.dumps(variables),
|
|
504
|
+
features=json.dumps(features),
|
|
505
|
+
fieldToggles=json.dumps(field_toggles),
|
|
506
|
+
)
|
|
507
|
+
r = self._get(f"{TWITTER_API_BASE}/{qid}/TweetDetail?{params2}")
|
|
508
|
+
if r.is_success:
|
|
509
|
+
return r.json().get("data") or {}
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
def get_tweet(self, tweet_id: str) -> Optional[Tweet]:
|
|
515
|
+
"""Fetch a single tweet by ID."""
|
|
516
|
+
data = self._fetch_tweet_detail(tweet_id)
|
|
517
|
+
if not data:
|
|
518
|
+
return None
|
|
519
|
+
result = (
|
|
520
|
+
(data.get("tweetResult") or {}).get("result")
|
|
521
|
+
or find_tweet_in_instructions(
|
|
522
|
+
(data.get("threaded_conversation_with_injections_v2") or {}).get("instructions"),
|
|
523
|
+
tweet_id,
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
mapped = map_tweet_result(result, self._quote_depth)
|
|
527
|
+
if mapped and result and result.get("article"):
|
|
528
|
+
title = _first_text(
|
|
529
|
+
(result["article"].get("article_results") or {}).get("result", {}).get("title"),
|
|
530
|
+
result["article"].get("title"),
|
|
531
|
+
)
|
|
532
|
+
article_text = _extract_article_text(result)
|
|
533
|
+
if title and (not article_text or article_text.strip() == title.strip()):
|
|
534
|
+
user_id = (result.get("core") or {}).get("user_results", {}).get("result", {}).get("rest_id")
|
|
535
|
+
if user_id:
|
|
536
|
+
fallback = self._fetch_user_article_plain_text(user_id, tweet_id)
|
|
537
|
+
if fallback.get("plainText"):
|
|
538
|
+
pt = fallback["plainText"]
|
|
539
|
+
mapped.text = f"{fallback['title']}\n\n{pt}" if fallback.get("title") else pt
|
|
540
|
+
return mapped
|
|
541
|
+
|
|
542
|
+
def get_replies(self, tweet_id: str) -> list[Tweet]:
|
|
543
|
+
"""Fetch the first page of replies to a tweet."""
|
|
544
|
+
data = self._fetch_tweet_detail(tweet_id)
|
|
545
|
+
if not data:
|
|
546
|
+
return []
|
|
547
|
+
instructions = (
|
|
548
|
+
(data.get("threaded_conversation_with_injections_v2") or {}).get("instructions")
|
|
549
|
+
)
|
|
550
|
+
tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
551
|
+
return [t for t in tweets if t.in_reply_to_status_id == tweet_id]
|
|
552
|
+
|
|
553
|
+
def get_thread(self, tweet_id: str) -> list[Tweet]:
|
|
554
|
+
"""Fetch the full conversation thread for a tweet."""
|
|
555
|
+
data = self._fetch_tweet_detail(tweet_id)
|
|
556
|
+
if not data:
|
|
557
|
+
return []
|
|
558
|
+
instructions = (
|
|
559
|
+
(data.get("threaded_conversation_with_injections_v2") or {}).get("instructions")
|
|
560
|
+
)
|
|
561
|
+
tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
562
|
+
target = next((t for t in tweets if t.id == tweet_id), None)
|
|
563
|
+
root_id = (target.conversation_id if target else None) or tweet_id
|
|
564
|
+
thread = [t for t in tweets if t.conversation_id == root_id]
|
|
565
|
+
thread.sort(key=lambda t: t.created_at or "")
|
|
566
|
+
return thread
|
|
567
|
+
|
|
568
|
+
def _fetch_user_article_plain_text(self, user_id: str, tweet_id: str) -> dict:
|
|
569
|
+
from ._features import _article_features
|
|
570
|
+
variables = {
|
|
571
|
+
"userId": user_id,
|
|
572
|
+
"count": 20,
|
|
573
|
+
"includePromotedContent": True,
|
|
574
|
+
"withVoice": True,
|
|
575
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
|
576
|
+
"withBirdwatchNotes": True,
|
|
577
|
+
"withCommunity": True,
|
|
578
|
+
"withSafetyModeUserFields": True,
|
|
579
|
+
}
|
|
580
|
+
params = httpx.QueryParams(
|
|
581
|
+
variables=json.dumps(variables),
|
|
582
|
+
features=json.dumps(_article_features()),
|
|
583
|
+
fieldToggles=json.dumps(article_field_toggles()),
|
|
584
|
+
)
|
|
585
|
+
qid = self._get_query_id("UserArticlesTweets")
|
|
586
|
+
try:
|
|
587
|
+
r = self._get(f"{TWITTER_API_BASE}/{qid}/UserArticlesTweets?{params}")
|
|
588
|
+
if not r.is_success:
|
|
589
|
+
return {}
|
|
590
|
+
data = r.json()
|
|
591
|
+
instructions = (
|
|
592
|
+
((data.get("data") or {}).get("user") or {})
|
|
593
|
+
.get("result", {})
|
|
594
|
+
.get("timeline", {})
|
|
595
|
+
.get("timeline", {})
|
|
596
|
+
.get("instructions")
|
|
597
|
+
)
|
|
598
|
+
for instruction in instructions or []:
|
|
599
|
+
for entry in instruction.get("entries") or []:
|
|
600
|
+
result = (entry.get("content") or {}).get("itemContent", {}).get("tweet_results", {}).get("result")
|
|
601
|
+
if not result or result.get("rest_id") != tweet_id:
|
|
602
|
+
continue
|
|
603
|
+
article_result = (result.get("article") or {}).get("article_results", {}).get("result") or {}
|
|
604
|
+
return {
|
|
605
|
+
"title": _first_text(article_result.get("title"), (result.get("article") or {}).get("title")),
|
|
606
|
+
"plainText": _first_text(article_result.get("plain_text"), (result.get("article") or {}).get("plain_text")),
|
|
607
|
+
}
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
610
|
+
return {}
|
|
611
|
+
|
|
612
|
+
# ------------------------------------------------------------------
|
|
613
|
+
# Search
|
|
614
|
+
# ------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
def search(
|
|
617
|
+
self,
|
|
618
|
+
query: str,
|
|
619
|
+
count: int = 20,
|
|
620
|
+
*,
|
|
621
|
+
cursor: Optional[str] = None,
|
|
622
|
+
max_pages: Optional[int] = None,
|
|
623
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
624
|
+
"""Search for tweets. Returns ``(tweets, next_cursor)``."""
|
|
625
|
+
features = search_features()
|
|
626
|
+
|
|
627
|
+
def fetch_page(page_cursor, page_count):
|
|
628
|
+
variables: dict[str, Any] = {
|
|
629
|
+
"rawQuery": query,
|
|
630
|
+
"count": page_count,
|
|
631
|
+
"querySource": "typed_query",
|
|
632
|
+
"product": "Latest",
|
|
633
|
+
}
|
|
634
|
+
if page_cursor:
|
|
635
|
+
variables["cursor"] = page_cursor
|
|
636
|
+
params = httpx.QueryParams(variables=json.dumps(variables))
|
|
637
|
+
for qid in self._search_query_ids():
|
|
638
|
+
url = f"{TWITTER_API_BASE}/{qid}/SearchTimeline?{params}"
|
|
639
|
+
try:
|
|
640
|
+
r = self._post(url, json.dumps({"features": features, "queryId": qid}))
|
|
641
|
+
if r.status_code == 404:
|
|
642
|
+
return [], None, True, f"HTTP 404"
|
|
643
|
+
if not r.is_success:
|
|
644
|
+
txt = r.text[:200]
|
|
645
|
+
mismatch = (r.status_code in (400, 422)) and self._is_query_id_mismatch(r.text)
|
|
646
|
+
return [], None, mismatch, f"HTTP {r.status_code}: {txt}"
|
|
647
|
+
data = r.json()
|
|
648
|
+
errors = data.get("errors") or []
|
|
649
|
+
if errors:
|
|
650
|
+
mismatch = any(
|
|
651
|
+
(e or {}).get("extensions", {}).get("code") == "GRAPHQL_VALIDATION_FAILED"
|
|
652
|
+
for e in errors
|
|
653
|
+
)
|
|
654
|
+
return [], None, mismatch, ", ".join(e.get("message", "") for e in errors)
|
|
655
|
+
instructions = (
|
|
656
|
+
(data.get("data") or {})
|
|
657
|
+
.get("search_by_raw_query", {})
|
|
658
|
+
.get("search_timeline", {})
|
|
659
|
+
.get("timeline", {})
|
|
660
|
+
.get("instructions")
|
|
661
|
+
)
|
|
662
|
+
page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
663
|
+
next_cur = extract_cursor_from_instructions(instructions)
|
|
664
|
+
return page_tweets, next_cur, False, None
|
|
665
|
+
except Exception as exc:
|
|
666
|
+
return [], None, False, str(exc)
|
|
667
|
+
return [], None, False, "No query IDs available"
|
|
668
|
+
|
|
669
|
+
tweets, next_cursor, error = self._paginate(
|
|
670
|
+
fetch_page, count, max_pages=max_pages, initial_cursor=cursor
|
|
671
|
+
)
|
|
672
|
+
return tweets, next_cursor
|
|
673
|
+
|
|
674
|
+
def get_mentions(
|
|
675
|
+
self,
|
|
676
|
+
username: Optional[str] = None,
|
|
677
|
+
count: int = 20,
|
|
678
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
679
|
+
"""Search for mentions of *username* (defaults to authenticated user)."""
|
|
680
|
+
if username:
|
|
681
|
+
handle = normalize_handle(username)
|
|
682
|
+
if not handle:
|
|
683
|
+
return [], None
|
|
684
|
+
q = f"@{handle}"
|
|
685
|
+
else:
|
|
686
|
+
user = self.get_current_user()
|
|
687
|
+
if not user:
|
|
688
|
+
return [], None
|
|
689
|
+
q = f"@{user.username}"
|
|
690
|
+
return self.search(q, count)
|
|
691
|
+
|
|
692
|
+
# ------------------------------------------------------------------
|
|
693
|
+
# Posting
|
|
694
|
+
# ------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
def tweet(self, text: str) -> Optional[str]:
|
|
697
|
+
"""Post a new tweet. Returns the new tweet ID on success."""
|
|
698
|
+
variables: dict[str, Any] = {
|
|
699
|
+
"tweet_text": text,
|
|
700
|
+
"dark_request": False,
|
|
701
|
+
"media": {"media_entities": [], "possibly_sensitive": False},
|
|
702
|
+
"semantic_annotation_ids": [],
|
|
703
|
+
}
|
|
704
|
+
return self._create_tweet(variables)
|
|
705
|
+
|
|
706
|
+
def reply(self, text: str, reply_to_tweet_id: str) -> Optional[str]:
|
|
707
|
+
"""Reply to an existing tweet. Returns the new tweet ID."""
|
|
708
|
+
variables: dict[str, Any] = {
|
|
709
|
+
"tweet_text": text,
|
|
710
|
+
"reply": {
|
|
711
|
+
"in_reply_to_tweet_id": reply_to_tweet_id,
|
|
712
|
+
"exclude_reply_user_ids": [],
|
|
713
|
+
},
|
|
714
|
+
"dark_request": False,
|
|
715
|
+
"media": {"media_entities": [], "possibly_sensitive": False},
|
|
716
|
+
"semantic_annotation_ids": [],
|
|
717
|
+
}
|
|
718
|
+
return self._create_tweet(variables)
|
|
719
|
+
|
|
720
|
+
def _create_tweet(self, variables: dict) -> Optional[str]:
|
|
721
|
+
self._ensure_client_user_id()
|
|
722
|
+
features = tweet_create_features()
|
|
723
|
+
qid = self._get_query_id("CreateTweet")
|
|
724
|
+
headers = {**self._json_headers(), "referer": "https://x.com/compose/post"}
|
|
725
|
+
|
|
726
|
+
def build_body(query_id: str) -> str:
|
|
727
|
+
return json.dumps({"variables": variables, "features": features, "queryId": query_id})
|
|
728
|
+
|
|
729
|
+
url = f"{TWITTER_API_BASE}/{qid}/CreateTweet"
|
|
730
|
+
try:
|
|
731
|
+
r = self._http.post(url, headers=headers, content=build_body(qid).encode())
|
|
732
|
+
if r.status_code == 404:
|
|
733
|
+
self._refresh_query_ids()
|
|
734
|
+
qid = self._get_query_id("CreateTweet")
|
|
735
|
+
url = f"{TWITTER_API_BASE}/{qid}/CreateTweet"
|
|
736
|
+
r = self._http.post(url, headers=headers, content=build_body(qid).encode())
|
|
737
|
+
if r.status_code == 404:
|
|
738
|
+
r = self._http.post(
|
|
739
|
+
TWITTER_API_BASE, headers=headers, content=build_body(qid).encode()
|
|
740
|
+
)
|
|
741
|
+
if not r.is_success:
|
|
742
|
+
return None
|
|
743
|
+
data = r.json()
|
|
744
|
+
errors = data.get("errors") or []
|
|
745
|
+
if errors:
|
|
746
|
+
# Fallback to legacy REST on error code 226 (bot detection)
|
|
747
|
+
if any((e or {}).get("code") == 226 for e in errors):
|
|
748
|
+
return self._post_status_update(variables)
|
|
749
|
+
return None
|
|
750
|
+
return (
|
|
751
|
+
(data.get("data") or {})
|
|
752
|
+
.get("create_tweet", {})
|
|
753
|
+
.get("tweet_results", {})
|
|
754
|
+
.get("result", {})
|
|
755
|
+
.get("rest_id")
|
|
756
|
+
)
|
|
757
|
+
except Exception:
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
def _post_status_update(self, variables: dict) -> Optional[str]:
|
|
761
|
+
"""Legacy statuses/update.json fallback for tweet creation."""
|
|
762
|
+
text = variables.get("tweet_text")
|
|
763
|
+
if not isinstance(text, str):
|
|
764
|
+
return None
|
|
765
|
+
data: dict[str, str] = {"status": text}
|
|
766
|
+
reply = variables.get("reply")
|
|
767
|
+
if isinstance(reply, dict) and reply.get("in_reply_to_tweet_id"):
|
|
768
|
+
data["in_reply_to_status_id"] = reply["in_reply_to_tweet_id"]
|
|
769
|
+
data["auto_populate_reply_metadata"] = "true"
|
|
770
|
+
headers = {**self._base_headers(), "referer": "https://x.com/compose/post"}
|
|
771
|
+
try:
|
|
772
|
+
r = self._http.post(TWITTER_STATUS_UPDATE_URL, headers=headers, data=data)
|
|
773
|
+
if not r.is_success:
|
|
774
|
+
return None
|
|
775
|
+
resp_data = r.json()
|
|
776
|
+
return resp_data.get("id_str") or (str(resp_data["id"]) if resp_data.get("id") else None)
|
|
777
|
+
except Exception:
|
|
778
|
+
return None
|
|
779
|
+
|
|
780
|
+
# ------------------------------------------------------------------
|
|
781
|
+
# Engagement mutations (like, retweet, bookmark)
|
|
782
|
+
# ------------------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
def _engagement_mutation(self, operation: str, tweet_id: str) -> bool:
|
|
785
|
+
self._ensure_client_user_id()
|
|
786
|
+
variables = (
|
|
787
|
+
{"tweet_id": tweet_id, "source_tweet_id": tweet_id}
|
|
788
|
+
if operation == "DeleteRetweet"
|
|
789
|
+
else {"tweet_id": tweet_id}
|
|
790
|
+
)
|
|
791
|
+
qid = self._get_query_id(operation)
|
|
792
|
+
headers = {**self._json_headers(), "referer": f"https://x.com/i/status/{tweet_id}"}
|
|
793
|
+
body = json.dumps({"variables": variables, "queryId": qid})
|
|
794
|
+
url = f"{TWITTER_API_BASE}/{qid}/{operation}"
|
|
795
|
+
try:
|
|
796
|
+
r = self._http.post(url, headers=headers, content=body.encode())
|
|
797
|
+
if r.status_code == 404:
|
|
798
|
+
self._refresh_query_ids()
|
|
799
|
+
qid = self._get_query_id(operation)
|
|
800
|
+
body = json.dumps({"variables": variables, "queryId": qid})
|
|
801
|
+
url = f"{TWITTER_API_BASE}/{qid}/{operation}"
|
|
802
|
+
r = self._http.post(url, headers=headers, content=body.encode())
|
|
803
|
+
if r.status_code == 404:
|
|
804
|
+
r = self._http.post(TWITTER_API_BASE, headers=headers, content=body.encode())
|
|
805
|
+
if not r.is_success:
|
|
806
|
+
return False
|
|
807
|
+
data = r.json()
|
|
808
|
+
return not bool(data.get("errors"))
|
|
809
|
+
except Exception:
|
|
810
|
+
return False
|
|
811
|
+
|
|
812
|
+
def like(self, tweet_id: str) -> bool:
|
|
813
|
+
return self._engagement_mutation("FavoriteTweet", tweet_id)
|
|
814
|
+
|
|
815
|
+
def unlike(self, tweet_id: str) -> bool:
|
|
816
|
+
return self._engagement_mutation("UnfavoriteTweet", tweet_id)
|
|
817
|
+
|
|
818
|
+
def retweet(self, tweet_id: str) -> bool:
|
|
819
|
+
return self._engagement_mutation("CreateRetweet", tweet_id)
|
|
820
|
+
|
|
821
|
+
def unretweet(self, tweet_id: str) -> bool:
|
|
822
|
+
return self._engagement_mutation("DeleteRetweet", tweet_id)
|
|
823
|
+
|
|
824
|
+
def bookmark(self, tweet_id: str) -> bool:
|
|
825
|
+
return self._engagement_mutation("CreateBookmark", tweet_id)
|
|
826
|
+
|
|
827
|
+
def unbookmark(self, tweet_id: str) -> bool:
|
|
828
|
+
qid = self._get_query_id("DeleteBookmark")
|
|
829
|
+
variables = {"tweet_id": tweet_id}
|
|
830
|
+
headers = {**self._json_headers(), "referer": f"https://x.com/i/status/{tweet_id}"}
|
|
831
|
+
body = json.dumps({"variables": variables, "queryId": qid})
|
|
832
|
+
url = f"{TWITTER_API_BASE}/{qid}/DeleteBookmark"
|
|
833
|
+
try:
|
|
834
|
+
r = self._http.post(url, headers=headers, content=body.encode())
|
|
835
|
+
if r.status_code == 404:
|
|
836
|
+
self._refresh_query_ids()
|
|
837
|
+
qid = self._get_query_id("DeleteBookmark")
|
|
838
|
+
body = json.dumps({"variables": variables, "queryId": qid})
|
|
839
|
+
url = f"{TWITTER_API_BASE}/{qid}/DeleteBookmark"
|
|
840
|
+
r = self._http.post(url, headers=headers, content=body.encode())
|
|
841
|
+
if r.status_code == 404:
|
|
842
|
+
r = self._http.post(TWITTER_API_BASE, headers=headers, content=body.encode())
|
|
843
|
+
if not r.is_success:
|
|
844
|
+
return False
|
|
845
|
+
return not bool(r.json().get("errors"))
|
|
846
|
+
except Exception:
|
|
847
|
+
return False
|
|
848
|
+
|
|
849
|
+
# ------------------------------------------------------------------
|
|
850
|
+
# Follow / unfollow
|
|
851
|
+
# ------------------------------------------------------------------
|
|
852
|
+
|
|
853
|
+
def follow(self, user_id: str) -> bool:
|
|
854
|
+
self._ensure_client_user_id()
|
|
855
|
+
return self._friendship_rest(user_id, "create") or self._friendship_graphql(user_id, follow=True)
|
|
856
|
+
|
|
857
|
+
def unfollow(self, user_id: str) -> bool:
|
|
858
|
+
self._ensure_client_user_id()
|
|
859
|
+
return self._friendship_rest(user_id, "destroy") or self._friendship_graphql(user_id, follow=False)
|
|
860
|
+
|
|
861
|
+
def _friendship_rest(self, user_id: str, action: str) -> bool:
|
|
862
|
+
for url in [
|
|
863
|
+
f"https://x.com/i/api/1.1/friendships/{action}.json",
|
|
864
|
+
f"https://api.twitter.com/1.1/friendships/{action}.json",
|
|
865
|
+
]:
|
|
866
|
+
try:
|
|
867
|
+
r = self._post_form(url, {"user_id": user_id, "skip_status": "true"})
|
|
868
|
+
if r.is_success:
|
|
869
|
+
return True
|
|
870
|
+
# Code 160 = already following/unfollowing — treat as success
|
|
871
|
+
errors = (r.json().get("errors") or [])
|
|
872
|
+
if any((e or {}).get("code") == 160 for e in errors):
|
|
873
|
+
return True
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
return False
|
|
877
|
+
|
|
878
|
+
def _friendship_graphql(self, user_id: str, follow: bool) -> bool:
|
|
879
|
+
operation = "CreateFriendship" if follow else "DestroyFriendship"
|
|
880
|
+
fallbacks = (
|
|
881
|
+
["8h9JVdV8dlSyqyRDJEPCsA", "OPwKc1HXnBT_bWXfAlo-9g"]
|
|
882
|
+
if follow
|
|
883
|
+
else ["ppXWuagMNXgvzx6WoXBW0Q", "8h9JVdV8dlSyqyRDJEPCsA"]
|
|
884
|
+
)
|
|
885
|
+
qids = list(dict.fromkeys([self._get_query_id(operation)] + fallbacks))
|
|
886
|
+
variables = {"user_id": user_id}
|
|
887
|
+
had_404 = False
|
|
888
|
+
for qid in qids:
|
|
889
|
+
try:
|
|
890
|
+
body = json.dumps({"variables": variables, "queryId": qid})
|
|
891
|
+
r = self._http.post(
|
|
892
|
+
f"{TWITTER_API_BASE}/{qid}/{operation}",
|
|
893
|
+
headers=self._json_headers(),
|
|
894
|
+
content=body.encode(),
|
|
895
|
+
)
|
|
896
|
+
if r.status_code == 404:
|
|
897
|
+
had_404 = True
|
|
898
|
+
continue
|
|
899
|
+
if not r.is_success:
|
|
900
|
+
continue
|
|
901
|
+
data = r.json()
|
|
902
|
+
if data.get("errors"):
|
|
903
|
+
continue
|
|
904
|
+
return True
|
|
905
|
+
except Exception:
|
|
906
|
+
pass
|
|
907
|
+
if had_404:
|
|
908
|
+
self._refresh_query_ids()
|
|
909
|
+
qid2 = self._get_query_id(operation)
|
|
910
|
+
try:
|
|
911
|
+
body = json.dumps({"variables": variables, "queryId": qid2})
|
|
912
|
+
r = self._http.post(
|
|
913
|
+
f"{TWITTER_API_BASE}/{qid2}/{operation}",
|
|
914
|
+
headers=self._json_headers(),
|
|
915
|
+
content=body.encode(),
|
|
916
|
+
)
|
|
917
|
+
return r.is_success and not r.json().get("errors")
|
|
918
|
+
except Exception:
|
|
919
|
+
pass
|
|
920
|
+
return False
|
|
921
|
+
|
|
922
|
+
# ------------------------------------------------------------------
|
|
923
|
+
# Bookmarks
|
|
924
|
+
# ------------------------------------------------------------------
|
|
925
|
+
|
|
926
|
+
def get_bookmarks(
|
|
927
|
+
self,
|
|
928
|
+
count: int = 20,
|
|
929
|
+
*,
|
|
930
|
+
folder_id: Optional[str] = None,
|
|
931
|
+
cursor: Optional[str] = None,
|
|
932
|
+
max_pages: Optional[int] = None,
|
|
933
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
934
|
+
"""Fetch bookmarked tweets. Returns ``(tweets, next_cursor)``."""
|
|
935
|
+
if folder_id:
|
|
936
|
+
return self._bookmarks_folder(folder_id, count, cursor, max_pages)
|
|
937
|
+
return self._bookmarks_main(count, cursor, max_pages)
|
|
938
|
+
|
|
939
|
+
def _bookmarks_main(
|
|
940
|
+
self,
|
|
941
|
+
limit: int,
|
|
942
|
+
initial_cursor: Optional[str],
|
|
943
|
+
max_pages: Optional[int],
|
|
944
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
945
|
+
features = bookmarks_features()
|
|
946
|
+
qids = list(dict.fromkeys([
|
|
947
|
+
self._get_query_id("Bookmarks"),
|
|
948
|
+
"RV1g3b8n_SGOHwkqKYSCFw",
|
|
949
|
+
"tmd4ifV8RHltzn8ymGg1aw",
|
|
950
|
+
]))
|
|
951
|
+
|
|
952
|
+
def fetch_page(page_cursor, page_count):
|
|
953
|
+
variables: dict[str, Any] = {
|
|
954
|
+
"count": page_count,
|
|
955
|
+
"includePromotedContent": False,
|
|
956
|
+
"withDownvotePerspective": False,
|
|
957
|
+
"withReactionsMetadata": False,
|
|
958
|
+
"withReactionsPerspective": False,
|
|
959
|
+
}
|
|
960
|
+
if page_cursor:
|
|
961
|
+
variables["cursor"] = page_cursor
|
|
962
|
+
params = httpx.QueryParams(
|
|
963
|
+
variables=json.dumps(variables),
|
|
964
|
+
features=json.dumps(features),
|
|
965
|
+
)
|
|
966
|
+
for qid in qids:
|
|
967
|
+
url = f"{TWITTER_API_BASE}/{qid}/Bookmarks?{params}"
|
|
968
|
+
try:
|
|
969
|
+
r = self._get_with_retry(url)
|
|
970
|
+
if r.status_code == 404:
|
|
971
|
+
return [], None, True, "HTTP 404"
|
|
972
|
+
if not r.is_success:
|
|
973
|
+
return [], None, False, f"HTTP {r.status_code}: {r.text[:200]}"
|
|
974
|
+
data = r.json()
|
|
975
|
+
instructions = (
|
|
976
|
+
(data.get("data") or {})
|
|
977
|
+
.get("bookmark_timeline_v2", {})
|
|
978
|
+
.get("timeline", {})
|
|
979
|
+
.get("instructions")
|
|
980
|
+
)
|
|
981
|
+
page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
982
|
+
next_cur = extract_cursor_from_instructions(instructions)
|
|
983
|
+
return page_tweets, next_cur, False, None
|
|
984
|
+
except Exception as exc:
|
|
985
|
+
return [], None, False, str(exc)
|
|
986
|
+
return [], None, False, "No query IDs available"
|
|
987
|
+
|
|
988
|
+
tweets, next_cursor, error = self._paginate(fetch_page, limit, max_pages, initial_cursor)
|
|
989
|
+
return tweets, next_cursor
|
|
990
|
+
|
|
991
|
+
def _bookmarks_folder(
|
|
992
|
+
self,
|
|
993
|
+
folder_id: str,
|
|
994
|
+
limit: int,
|
|
995
|
+
initial_cursor: Optional[str],
|
|
996
|
+
max_pages: Optional[int],
|
|
997
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
998
|
+
features = bookmarks_features()
|
|
999
|
+
qids = list(dict.fromkeys([
|
|
1000
|
+
self._get_query_id("BookmarkFolderTimeline"),
|
|
1001
|
+
"KJIQpsvxrTfRIlbaRIySHQ",
|
|
1002
|
+
]))
|
|
1003
|
+
|
|
1004
|
+
def fetch_page(page_cursor, page_count):
|
|
1005
|
+
variables: dict[str, Any] = {
|
|
1006
|
+
"bookmark_collection_id": folder_id,
|
|
1007
|
+
"includePromotedContent": True,
|
|
1008
|
+
"count": page_count,
|
|
1009
|
+
}
|
|
1010
|
+
if page_cursor:
|
|
1011
|
+
variables["cursor"] = page_cursor
|
|
1012
|
+
params = httpx.QueryParams(
|
|
1013
|
+
variables=json.dumps(variables),
|
|
1014
|
+
features=json.dumps(features),
|
|
1015
|
+
)
|
|
1016
|
+
for qid in qids:
|
|
1017
|
+
url = f"{TWITTER_API_BASE}/{qid}/BookmarkFolderTimeline?{params}"
|
|
1018
|
+
try:
|
|
1019
|
+
r = self._get_with_retry(url)
|
|
1020
|
+
if r.status_code == 404:
|
|
1021
|
+
return [], None, True, "HTTP 404"
|
|
1022
|
+
if not r.is_success:
|
|
1023
|
+
return [], None, False, f"HTTP {r.status_code}: {r.text[:200]}"
|
|
1024
|
+
data = r.json()
|
|
1025
|
+
instructions = (
|
|
1026
|
+
(data.get("data") or {})
|
|
1027
|
+
.get("bookmark_collection_timeline", {})
|
|
1028
|
+
.get("timeline", {})
|
|
1029
|
+
.get("instructions")
|
|
1030
|
+
)
|
|
1031
|
+
page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
1032
|
+
next_cur = extract_cursor_from_instructions(instructions)
|
|
1033
|
+
return page_tweets, next_cur, False, None
|
|
1034
|
+
except Exception as exc:
|
|
1035
|
+
return [], None, False, str(exc)
|
|
1036
|
+
return [], None, False, "No query IDs available"
|
|
1037
|
+
|
|
1038
|
+
tweets, next_cursor, error = self._paginate(fetch_page, limit, max_pages, initial_cursor)
|
|
1039
|
+
return tweets, next_cursor
|
|
1040
|
+
|
|
1041
|
+
# ------------------------------------------------------------------
|
|
1042
|
+
# Likes
|
|
1043
|
+
# ------------------------------------------------------------------
|
|
1044
|
+
|
|
1045
|
+
def get_likes(
|
|
1046
|
+
self,
|
|
1047
|
+
count: int = 20,
|
|
1048
|
+
*,
|
|
1049
|
+
cursor: Optional[str] = None,
|
|
1050
|
+
max_pages: Optional[int] = None,
|
|
1051
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
1052
|
+
"""Fetch liked tweets for the current user. Returns ``(tweets, next_cursor)``."""
|
|
1053
|
+
user = self.get_current_user()
|
|
1054
|
+
if not user:
|
|
1055
|
+
return [], None
|
|
1056
|
+
features = likes_features()
|
|
1057
|
+
qids = list(dict.fromkeys([self._get_query_id("Likes"), "JR2gceKucIKcVNB_9JkhsA"]))
|
|
1058
|
+
|
|
1059
|
+
def fetch_page(page_cursor, page_count):
|
|
1060
|
+
variables: dict[str, Any] = {
|
|
1061
|
+
"userId": user.id,
|
|
1062
|
+
"count": page_count,
|
|
1063
|
+
"includePromotedContent": False,
|
|
1064
|
+
"withClientEventToken": False,
|
|
1065
|
+
"withBirdwatchNotes": False,
|
|
1066
|
+
"withVoice": True,
|
|
1067
|
+
}
|
|
1068
|
+
if page_cursor:
|
|
1069
|
+
variables["cursor"] = page_cursor
|
|
1070
|
+
params = httpx.QueryParams(
|
|
1071
|
+
variables=json.dumps(variables),
|
|
1072
|
+
features=json.dumps(features),
|
|
1073
|
+
)
|
|
1074
|
+
for qid in qids:
|
|
1075
|
+
url = f"{TWITTER_API_BASE}/{qid}/Likes?{params}"
|
|
1076
|
+
try:
|
|
1077
|
+
r = self._get(url)
|
|
1078
|
+
if r.status_code == 404:
|
|
1079
|
+
return [], None, True, "HTTP 404"
|
|
1080
|
+
if not r.is_success:
|
|
1081
|
+
return [], None, False, f"HTTP {r.status_code}: {r.text[:200]}"
|
|
1082
|
+
data = r.json()
|
|
1083
|
+
instructions = (
|
|
1084
|
+
(data.get("data") or {})
|
|
1085
|
+
.get("user", {})
|
|
1086
|
+
.get("result", {})
|
|
1087
|
+
.get("timeline", {})
|
|
1088
|
+
.get("timeline", {})
|
|
1089
|
+
.get("instructions")
|
|
1090
|
+
)
|
|
1091
|
+
page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
1092
|
+
next_cur = extract_cursor_from_instructions(instructions)
|
|
1093
|
+
return page_tweets, next_cur, False, None
|
|
1094
|
+
except Exception as exc:
|
|
1095
|
+
return [], None, False, str(exc)
|
|
1096
|
+
return [], None, False, "No query IDs available"
|
|
1097
|
+
|
|
1098
|
+
tweets, next_cursor, _ = self._paginate(fetch_page, count, max_pages, cursor)
|
|
1099
|
+
return tweets, next_cursor
|
|
1100
|
+
|
|
1101
|
+
# ------------------------------------------------------------------
|
|
1102
|
+
# User tweets
|
|
1103
|
+
# ------------------------------------------------------------------
|
|
1104
|
+
|
|
1105
|
+
def get_user_tweets(
|
|
1106
|
+
self,
|
|
1107
|
+
user_id: str,
|
|
1108
|
+
count: int = 20,
|
|
1109
|
+
*,
|
|
1110
|
+
cursor: Optional[str] = None,
|
|
1111
|
+
max_pages: Optional[int] = None,
|
|
1112
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
1113
|
+
"""Fetch tweets from a user's profile timeline. Returns ``(tweets, next_cursor)``."""
|
|
1114
|
+
features = user_tweets_features()
|
|
1115
|
+
qids = list(dict.fromkeys([self._get_query_id("UserTweets"), "Wms1GvIiHXAPBaCr9KblaA"]))
|
|
1116
|
+
hard_max = 10
|
|
1117
|
+
computed_max = max(1, math.ceil(count / _PAGE_SIZE))
|
|
1118
|
+
effective_max = min(hard_max, max_pages or computed_max)
|
|
1119
|
+
|
|
1120
|
+
def fetch_page(page_cursor, page_count):
|
|
1121
|
+
variables: dict[str, Any] = {
|
|
1122
|
+
"userId": user_id,
|
|
1123
|
+
"count": page_count,
|
|
1124
|
+
"includePromotedContent": False,
|
|
1125
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
|
1126
|
+
"withVoice": True,
|
|
1127
|
+
}
|
|
1128
|
+
if page_cursor:
|
|
1129
|
+
variables["cursor"] = page_cursor
|
|
1130
|
+
params = httpx.QueryParams(
|
|
1131
|
+
variables=json.dumps(variables),
|
|
1132
|
+
features=json.dumps(features),
|
|
1133
|
+
fieldToggles=json.dumps({"withArticlePlainText": False}),
|
|
1134
|
+
)
|
|
1135
|
+
for qid in qids:
|
|
1136
|
+
url = f"{TWITTER_API_BASE}/{qid}/UserTweets?{params}"
|
|
1137
|
+
try:
|
|
1138
|
+
r = self._get(url)
|
|
1139
|
+
if r.status_code == 404:
|
|
1140
|
+
return [], None, True, "HTTP 404"
|
|
1141
|
+
if not r.is_success:
|
|
1142
|
+
return [], None, False, f"HTTP {r.status_code}: {r.text[:200]}"
|
|
1143
|
+
data = r.json()
|
|
1144
|
+
errors = data.get("errors") or []
|
|
1145
|
+
instructions = (
|
|
1146
|
+
(data.get("data") or {})
|
|
1147
|
+
.get("user", {})
|
|
1148
|
+
.get("result", {})
|
|
1149
|
+
.get("timeline", {})
|
|
1150
|
+
.get("timeline", {})
|
|
1151
|
+
.get("instructions")
|
|
1152
|
+
)
|
|
1153
|
+
if errors:
|
|
1154
|
+
msgs = ", ".join(e.get("message", "") for e in errors)
|
|
1155
|
+
if not instructions:
|
|
1156
|
+
return [], None, False, msgs
|
|
1157
|
+
page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
1158
|
+
next_cur = extract_cursor_from_instructions(instructions)
|
|
1159
|
+
return page_tweets, next_cur, False, None
|
|
1160
|
+
except Exception as exc:
|
|
1161
|
+
return [], None, False, str(exc)
|
|
1162
|
+
return [], None, False, "No query IDs available"
|
|
1163
|
+
|
|
1164
|
+
tweets, next_cursor, _ = self._paginate(fetch_page, count, effective_max, cursor)
|
|
1165
|
+
return tweets, next_cursor
|
|
1166
|
+
|
|
1167
|
+
# ------------------------------------------------------------------
|
|
1168
|
+
# Home timeline
|
|
1169
|
+
# ------------------------------------------------------------------
|
|
1170
|
+
|
|
1171
|
+
def get_home_timeline(self, count: int = 20) -> list[Tweet]:
|
|
1172
|
+
"""Fetch the authenticated user's 'For You' home timeline."""
|
|
1173
|
+
return self._home_timeline("HomeTimeline", count)
|
|
1174
|
+
|
|
1175
|
+
def get_home_latest_timeline(self, count: int = 20) -> list[Tweet]:
|
|
1176
|
+
"""Fetch the authenticated user's 'Following' (chronological) timeline."""
|
|
1177
|
+
return self._home_timeline("HomeLatestTimeline", count)
|
|
1178
|
+
|
|
1179
|
+
def _home_timeline(self, operation: str, count: int) -> list[Tweet]:
|
|
1180
|
+
features = home_timeline_features()
|
|
1181
|
+
if operation == "HomeTimeline":
|
|
1182
|
+
qids = list(dict.fromkeys([self._get_query_id("HomeTimeline"), "edseUwk9sP5Phz__9TIRnA"]))
|
|
1183
|
+
else:
|
|
1184
|
+
qids = list(dict.fromkeys([self._get_query_id("HomeLatestTimeline"), "iOEZpOdfekFsxSlPQCQtPg"]))
|
|
1185
|
+
|
|
1186
|
+
seen: set[str] = set()
|
|
1187
|
+
tweets: list[Tweet] = []
|
|
1188
|
+
cursor: Optional[str] = None
|
|
1189
|
+
|
|
1190
|
+
while len(tweets) < count:
|
|
1191
|
+
page_count = min(_PAGE_SIZE, count - len(tweets))
|
|
1192
|
+
had_404 = False
|
|
1193
|
+
success = False
|
|
1194
|
+
for qid in qids:
|
|
1195
|
+
variables: dict[str, Any] = {
|
|
1196
|
+
"count": page_count,
|
|
1197
|
+
"includePromotedContent": True,
|
|
1198
|
+
"latestControlAvailable": True,
|
|
1199
|
+
"requestContext": "launch",
|
|
1200
|
+
"withCommunity": True,
|
|
1201
|
+
}
|
|
1202
|
+
if cursor:
|
|
1203
|
+
variables["cursor"] = cursor
|
|
1204
|
+
params = httpx.QueryParams(
|
|
1205
|
+
variables=json.dumps(variables),
|
|
1206
|
+
features=json.dumps(features),
|
|
1207
|
+
)
|
|
1208
|
+
url = f"{TWITTER_API_BASE}/{qid}/{operation}?{params}"
|
|
1209
|
+
try:
|
|
1210
|
+
r = self._get(url)
|
|
1211
|
+
if r.status_code == 404:
|
|
1212
|
+
had_404 = True
|
|
1213
|
+
continue
|
|
1214
|
+
if not r.is_success:
|
|
1215
|
+
break
|
|
1216
|
+
data = r.json()
|
|
1217
|
+
if data.get("errors"):
|
|
1218
|
+
break
|
|
1219
|
+
instructions = (
|
|
1220
|
+
(data.get("data") or {})
|
|
1221
|
+
.get("home", {})
|
|
1222
|
+
.get("home_timeline_urt", {})
|
|
1223
|
+
.get("instructions")
|
|
1224
|
+
)
|
|
1225
|
+
page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
1226
|
+
new_cursor = extract_cursor_from_instructions(instructions)
|
|
1227
|
+
added = 0
|
|
1228
|
+
for t in page_tweets:
|
|
1229
|
+
if t.id not in seen:
|
|
1230
|
+
seen.add(t.id)
|
|
1231
|
+
tweets.append(t)
|
|
1232
|
+
added += 1
|
|
1233
|
+
if not new_cursor or new_cursor == cursor or not page_tweets or added == 0:
|
|
1234
|
+
return tweets
|
|
1235
|
+
cursor = new_cursor
|
|
1236
|
+
success = True
|
|
1237
|
+
break
|
|
1238
|
+
except Exception:
|
|
1239
|
+
pass
|
|
1240
|
+
if not success:
|
|
1241
|
+
if had_404:
|
|
1242
|
+
self._refresh_query_ids()
|
|
1243
|
+
if operation == "HomeTimeline":
|
|
1244
|
+
qids = [self._get_query_id("HomeTimeline")]
|
|
1245
|
+
else:
|
|
1246
|
+
qids = [self._get_query_id("HomeLatestTimeline")]
|
|
1247
|
+
else:
|
|
1248
|
+
break
|
|
1249
|
+
return tweets
|
|
1250
|
+
|
|
1251
|
+
# ------------------------------------------------------------------
|
|
1252
|
+
# Following / Followers
|
|
1253
|
+
# ------------------------------------------------------------------
|
|
1254
|
+
|
|
1255
|
+
def get_following(
|
|
1256
|
+
self,
|
|
1257
|
+
user_id: str,
|
|
1258
|
+
count: int = 20,
|
|
1259
|
+
*,
|
|
1260
|
+
cursor: Optional[str] = None,
|
|
1261
|
+
) -> tuple[list[User], Optional[str]]:
|
|
1262
|
+
"""Return users that *user_id* follows. Returns ``(users, next_cursor)``."""
|
|
1263
|
+
return self._follow_list("Following", user_id, count, cursor)
|
|
1264
|
+
|
|
1265
|
+
def get_followers(
|
|
1266
|
+
self,
|
|
1267
|
+
user_id: str,
|
|
1268
|
+
count: int = 20,
|
|
1269
|
+
*,
|
|
1270
|
+
cursor: Optional[str] = None,
|
|
1271
|
+
) -> tuple[list[User], Optional[str]]:
|
|
1272
|
+
"""Return users that follow *user_id*. Returns ``(users, next_cursor)``."""
|
|
1273
|
+
return self._follow_list("Followers", user_id, count, cursor)
|
|
1274
|
+
|
|
1275
|
+
def _follow_list(
|
|
1276
|
+
self,
|
|
1277
|
+
operation: str,
|
|
1278
|
+
user_id: str,
|
|
1279
|
+
count: int,
|
|
1280
|
+
cursor: Optional[str],
|
|
1281
|
+
) -> tuple[list[User], Optional[str]]:
|
|
1282
|
+
fallback_ids = {
|
|
1283
|
+
"Following": "BEkNpEt5pNETESoqMsTEGA",
|
|
1284
|
+
"Followers": "kuFUYP9eV1FPoEy4N-pi7w",
|
|
1285
|
+
}
|
|
1286
|
+
qids = list(dict.fromkeys([self._get_query_id(operation), fallback_ids[operation]]))
|
|
1287
|
+
features = following_features()
|
|
1288
|
+
variables: dict[str, Any] = {
|
|
1289
|
+
"userId": user_id,
|
|
1290
|
+
"count": count,
|
|
1291
|
+
"includePromotedContent": False,
|
|
1292
|
+
}
|
|
1293
|
+
if cursor:
|
|
1294
|
+
variables["cursor"] = cursor
|
|
1295
|
+
params = httpx.QueryParams(
|
|
1296
|
+
variables=json.dumps(variables),
|
|
1297
|
+
features=json.dumps(features),
|
|
1298
|
+
)
|
|
1299
|
+
had_404 = False
|
|
1300
|
+
for qid in qids:
|
|
1301
|
+
url = f"{TWITTER_API_BASE}/{qid}/{operation}?{params}"
|
|
1302
|
+
try:
|
|
1303
|
+
r = self._get(url)
|
|
1304
|
+
if r.status_code == 404:
|
|
1305
|
+
had_404 = True
|
|
1306
|
+
continue
|
|
1307
|
+
if not r.is_success:
|
|
1308
|
+
continue
|
|
1309
|
+
data = r.json()
|
|
1310
|
+
if data.get("errors"):
|
|
1311
|
+
continue
|
|
1312
|
+
instructions = (
|
|
1313
|
+
(data.get("data") or {})
|
|
1314
|
+
.get("user", {})
|
|
1315
|
+
.get("result", {})
|
|
1316
|
+
.get("timeline", {})
|
|
1317
|
+
.get("timeline", {})
|
|
1318
|
+
.get("instructions")
|
|
1319
|
+
)
|
|
1320
|
+
users = parse_users_from_instructions(instructions)
|
|
1321
|
+
next_cursor = extract_cursor_from_instructions(instructions)
|
|
1322
|
+
return users, next_cursor
|
|
1323
|
+
except Exception:
|
|
1324
|
+
pass
|
|
1325
|
+
|
|
1326
|
+
if had_404:
|
|
1327
|
+
self._refresh_query_ids()
|
|
1328
|
+
# REST fallback
|
|
1329
|
+
rest_op = "friends" if operation == "Following" else "followers"
|
|
1330
|
+
for url in [
|
|
1331
|
+
f"https://x.com/i/api/1.1/{rest_op}/list.json",
|
|
1332
|
+
f"https://api.twitter.com/1.1/{rest_op}/list.json",
|
|
1333
|
+
]:
|
|
1334
|
+
params_rest = {"user_id": user_id, "count": str(count), "skip_status": "true"}
|
|
1335
|
+
if cursor:
|
|
1336
|
+
params_rest["cursor"] = cursor
|
|
1337
|
+
try:
|
|
1338
|
+
r = self._http.get(url, headers=self._json_headers(), params=params_rest)
|
|
1339
|
+
if not r.is_success:
|
|
1340
|
+
continue
|
|
1341
|
+
data = r.json()
|
|
1342
|
+
raw_users = data.get("users") or []
|
|
1343
|
+
users = [
|
|
1344
|
+
User(
|
|
1345
|
+
id=u.get("id_str") or str(u["id"]),
|
|
1346
|
+
username=u["screen_name"],
|
|
1347
|
+
name=u.get("name", u["screen_name"]),
|
|
1348
|
+
description=u.get("description"),
|
|
1349
|
+
followers_count=u.get("followers_count"),
|
|
1350
|
+
following_count=u.get("friends_count"),
|
|
1351
|
+
is_blue_verified=u.get("verified"),
|
|
1352
|
+
profile_image_url=u.get("profile_image_url_https"),
|
|
1353
|
+
created_at=u.get("created_at"),
|
|
1354
|
+
)
|
|
1355
|
+
for u in raw_users
|
|
1356
|
+
if u.get("screen_name")
|
|
1357
|
+
]
|
|
1358
|
+
nc = data.get("next_cursor_str")
|
|
1359
|
+
return users, (nc if nc and nc != "0" else None)
|
|
1360
|
+
except Exception:
|
|
1361
|
+
pass
|
|
1362
|
+
return [], None
|
|
1363
|
+
|
|
1364
|
+
# ------------------------------------------------------------------
|
|
1365
|
+
# Lists
|
|
1366
|
+
# ------------------------------------------------------------------
|
|
1367
|
+
|
|
1368
|
+
def get_owned_lists(self, count: int = 100) -> list[TwitterList]:
|
|
1369
|
+
user = self.get_current_user()
|
|
1370
|
+
if not user:
|
|
1371
|
+
return []
|
|
1372
|
+
return self._fetch_lists("ListOwnerships", user.id, count)
|
|
1373
|
+
|
|
1374
|
+
def get_list_memberships(self, count: int = 100) -> list[TwitterList]:
|
|
1375
|
+
user = self.get_current_user()
|
|
1376
|
+
if not user:
|
|
1377
|
+
return []
|
|
1378
|
+
return self._fetch_lists("ListMemberships", user.id, count)
|
|
1379
|
+
|
|
1380
|
+
def _fetch_lists(self, operation: str, user_id: str, count: int) -> list[TwitterList]:
|
|
1381
|
+
fallback = {"ListOwnerships": "wQcOSjSQ8NtgxIwvYl1lMg", "ListMemberships": "BlEXXdARdSeL_0KyKHHvvg"}
|
|
1382
|
+
qids = list(dict.fromkeys([self._get_query_id(operation), fallback[operation]]))
|
|
1383
|
+
features = lists_features()
|
|
1384
|
+
variables = {
|
|
1385
|
+
"userId": user_id,
|
|
1386
|
+
"count": count,
|
|
1387
|
+
"isListMembershipShown": True,
|
|
1388
|
+
"isListMemberTargetUserId": user_id,
|
|
1389
|
+
}
|
|
1390
|
+
params = httpx.QueryParams(
|
|
1391
|
+
variables=json.dumps(variables),
|
|
1392
|
+
features=json.dumps(features),
|
|
1393
|
+
)
|
|
1394
|
+
had_404 = False
|
|
1395
|
+
for qid in qids:
|
|
1396
|
+
url = f"{TWITTER_API_BASE}/{qid}/{operation}?{params}"
|
|
1397
|
+
try:
|
|
1398
|
+
r = self._get(url)
|
|
1399
|
+
if r.status_code == 404:
|
|
1400
|
+
had_404 = True
|
|
1401
|
+
continue
|
|
1402
|
+
if not r.is_success:
|
|
1403
|
+
continue
|
|
1404
|
+
data = r.json()
|
|
1405
|
+
if data.get("errors"):
|
|
1406
|
+
continue
|
|
1407
|
+
instructions = (
|
|
1408
|
+
(data.get("data") or {})
|
|
1409
|
+
.get("user", {})
|
|
1410
|
+
.get("result", {})
|
|
1411
|
+
.get("timeline", {})
|
|
1412
|
+
.get("timeline", {})
|
|
1413
|
+
.get("instructions")
|
|
1414
|
+
)
|
|
1415
|
+
return self._parse_lists_from_instructions(instructions)
|
|
1416
|
+
except Exception:
|
|
1417
|
+
pass
|
|
1418
|
+
if had_404:
|
|
1419
|
+
self._refresh_query_ids()
|
|
1420
|
+
return []
|
|
1421
|
+
|
|
1422
|
+
@staticmethod
|
|
1423
|
+
def _parse_lists_from_instructions(instructions: Optional[list]) -> list[TwitterList]:
|
|
1424
|
+
result: list[TwitterList] = []
|
|
1425
|
+
for instruction in instructions or []:
|
|
1426
|
+
for entry in instruction.get("entries") or []:
|
|
1427
|
+
lr = (entry.get("content") or {}).get("itemContent", {}).get("list")
|
|
1428
|
+
if not lr or not lr.get("id_str") or not lr.get("name"):
|
|
1429
|
+
continue
|
|
1430
|
+
owner_result = (lr.get("user_results") or {}).get("result")
|
|
1431
|
+
owner = None
|
|
1432
|
+
if owner_result:
|
|
1433
|
+
owner_legacy = owner_result.get("legacy") or {}
|
|
1434
|
+
owner = Author(
|
|
1435
|
+
username=owner_legacy.get("screen_name", ""),
|
|
1436
|
+
name=owner_legacy.get("name", ""),
|
|
1437
|
+
)
|
|
1438
|
+
result.append(
|
|
1439
|
+
TwitterList(
|
|
1440
|
+
id=lr["id_str"],
|
|
1441
|
+
name=lr["name"],
|
|
1442
|
+
description=lr.get("description"),
|
|
1443
|
+
member_count=lr.get("member_count"),
|
|
1444
|
+
subscriber_count=lr.get("subscriber_count"),
|
|
1445
|
+
is_private=(lr.get("mode") or "").lower() == "private",
|
|
1446
|
+
created_at=lr.get("created_at"),
|
|
1447
|
+
owner=owner,
|
|
1448
|
+
)
|
|
1449
|
+
)
|
|
1450
|
+
return result
|
|
1451
|
+
|
|
1452
|
+
def get_list_timeline(
|
|
1453
|
+
self,
|
|
1454
|
+
list_id: str,
|
|
1455
|
+
count: int = 20,
|
|
1456
|
+
*,
|
|
1457
|
+
cursor: Optional[str] = None,
|
|
1458
|
+
max_pages: Optional[int] = None,
|
|
1459
|
+
) -> tuple[list[Tweet], Optional[str]]:
|
|
1460
|
+
"""Fetch tweets from a list timeline. Returns ``(tweets, next_cursor)``."""
|
|
1461
|
+
features = lists_features()
|
|
1462
|
+
qids = list(dict.fromkeys([self._get_query_id("ListLatestTweetsTimeline"), "2TemLyqrMpTeAmysdbnVqw"]))
|
|
1463
|
+
|
|
1464
|
+
def fetch_page(page_cursor, page_count):
|
|
1465
|
+
variables: dict[str, Any] = {"listId": list_id, "count": page_count}
|
|
1466
|
+
if page_cursor:
|
|
1467
|
+
variables["cursor"] = page_cursor
|
|
1468
|
+
params = httpx.QueryParams(
|
|
1469
|
+
variables=json.dumps(variables),
|
|
1470
|
+
features=json.dumps(features),
|
|
1471
|
+
)
|
|
1472
|
+
for qid in qids:
|
|
1473
|
+
url = f"{TWITTER_API_BASE}/{qid}/ListLatestTweetsTimeline?{params}"
|
|
1474
|
+
try:
|
|
1475
|
+
r = self._get(url)
|
|
1476
|
+
if r.status_code == 404:
|
|
1477
|
+
return [], None, True, "HTTP 404"
|
|
1478
|
+
if not r.is_success:
|
|
1479
|
+
return [], None, False, f"HTTP {r.status_code}"
|
|
1480
|
+
data = r.json()
|
|
1481
|
+
if data.get("errors"):
|
|
1482
|
+
continue
|
|
1483
|
+
instructions = (
|
|
1484
|
+
(data.get("data") or {})
|
|
1485
|
+
.get("list", {})
|
|
1486
|
+
.get("tweets_timeline", {})
|
|
1487
|
+
.get("timeline", {})
|
|
1488
|
+
.get("instructions")
|
|
1489
|
+
)
|
|
1490
|
+
page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
|
|
1491
|
+
next_cur = extract_cursor_from_instructions(instructions)
|
|
1492
|
+
return page_tweets, next_cur, False, None
|
|
1493
|
+
except Exception as exc:
|
|
1494
|
+
return [], None, False, str(exc)
|
|
1495
|
+
return [], None, False, "No query IDs available"
|
|
1496
|
+
|
|
1497
|
+
tweets, next_cursor, _ = self._paginate(fetch_page, count, max_pages, cursor)
|
|
1498
|
+
return tweets, next_cursor
|
|
1499
|
+
|
|
1500
|
+
# ------------------------------------------------------------------
|
|
1501
|
+
# About account
|
|
1502
|
+
# ------------------------------------------------------------------
|
|
1503
|
+
|
|
1504
|
+
def get_user_about_account(self, username: str) -> Optional[AboutProfile]:
|
|
1505
|
+
"""Fetch 'About this account' data for a user."""
|
|
1506
|
+
handle = normalize_handle(username)
|
|
1507
|
+
if not handle:
|
|
1508
|
+
return None
|
|
1509
|
+
qids = list(dict.fromkeys([self._get_query_id("AboutAccountQuery"), "zs_jFPFT78rBpXv9Z3U2YQ"]))
|
|
1510
|
+
params = httpx.QueryParams(variables=json.dumps({"screenName": handle}))
|
|
1511
|
+
had_404 = False
|
|
1512
|
+
for qid in qids:
|
|
1513
|
+
try:
|
|
1514
|
+
r = self._get(f"{TWITTER_API_BASE}/{qid}/AboutAccountQuery?{params}")
|
|
1515
|
+
if r.status_code == 404:
|
|
1516
|
+
had_404 = True
|
|
1517
|
+
continue
|
|
1518
|
+
if not r.is_success:
|
|
1519
|
+
continue
|
|
1520
|
+
data = r.json()
|
|
1521
|
+
if data.get("errors"):
|
|
1522
|
+
continue
|
|
1523
|
+
about = (
|
|
1524
|
+
(data.get("data") or {})
|
|
1525
|
+
.get("user_result_by_screen_name", {})
|
|
1526
|
+
.get("result", {})
|
|
1527
|
+
.get("about_profile")
|
|
1528
|
+
)
|
|
1529
|
+
if not about:
|
|
1530
|
+
continue
|
|
1531
|
+
return AboutProfile(
|
|
1532
|
+
account_based_in=about.get("account_based_in"),
|
|
1533
|
+
source=about.get("source"),
|
|
1534
|
+
created_country_accurate=about.get("created_country_accurate"),
|
|
1535
|
+
location_accurate=about.get("location_accurate"),
|
|
1536
|
+
learn_more_url=about.get("learn_more_url"),
|
|
1537
|
+
)
|
|
1538
|
+
except Exception:
|
|
1539
|
+
pass
|
|
1540
|
+
if had_404:
|
|
1541
|
+
self._refresh_query_ids()
|
|
1542
|
+
return None
|
|
1543
|
+
|
|
1544
|
+
# ------------------------------------------------------------------
|
|
1545
|
+
# News / trending
|
|
1546
|
+
# ------------------------------------------------------------------
|
|
1547
|
+
|
|
1548
|
+
def get_news(
|
|
1549
|
+
self,
|
|
1550
|
+
count: int = 10,
|
|
1551
|
+
*,
|
|
1552
|
+
ai_only: bool = False,
|
|
1553
|
+
with_tweets: bool = False,
|
|
1554
|
+
tweets_per_item: int = 5,
|
|
1555
|
+
tabs: Optional[list[str]] = None,
|
|
1556
|
+
) -> list[NewsItem]:
|
|
1557
|
+
"""Fetch news and trending topics from X's Explore tabs."""
|
|
1558
|
+
if tabs is None:
|
|
1559
|
+
tabs = ["forYou", "news", "sports", "entertainment"]
|
|
1560
|
+
features = explore_features()
|
|
1561
|
+
qid = self._get_query_id("GenericTimelineById")
|
|
1562
|
+
all_items: list[NewsItem] = []
|
|
1563
|
+
seen_headlines: set[str] = set()
|
|
1564
|
+
|
|
1565
|
+
for tab in tabs:
|
|
1566
|
+
timeline_id = _TIMELINE_IDS.get(tab)
|
|
1567
|
+
if not timeline_id:
|
|
1568
|
+
continue
|
|
1569
|
+
try:
|
|
1570
|
+
variables = {
|
|
1571
|
+
"timelineId": timeline_id,
|
|
1572
|
+
"count": count * 2,
|
|
1573
|
+
"includePromotedContent": False,
|
|
1574
|
+
}
|
|
1575
|
+
params = httpx.QueryParams(
|
|
1576
|
+
variables=json.dumps(variables),
|
|
1577
|
+
features=json.dumps(features),
|
|
1578
|
+
)
|
|
1579
|
+
r = self._get(f"{TWITTER_API_BASE}/{qid}/GenericTimelineById?{params}")
|
|
1580
|
+
if not r.is_success:
|
|
1581
|
+
continue
|
|
1582
|
+
data = r.json()
|
|
1583
|
+
if data.get("errors"):
|
|
1584
|
+
continue
|
|
1585
|
+
tab_items = self._parse_timeline_tab_items(data, tab, count, ai_only)
|
|
1586
|
+
for item in tab_items:
|
|
1587
|
+
if item.headline not in seen_headlines:
|
|
1588
|
+
seen_headlines.add(item.headline)
|
|
1589
|
+
all_items.append(item)
|
|
1590
|
+
if len(all_items) >= count:
|
|
1591
|
+
break
|
|
1592
|
+
except Exception:
|
|
1593
|
+
pass
|
|
1594
|
+
|
|
1595
|
+
items = all_items[:count]
|
|
1596
|
+
if with_tweets:
|
|
1597
|
+
for item in items:
|
|
1598
|
+
try:
|
|
1599
|
+
page_tweets, _ = self.search(item.headline, tweets_per_item)
|
|
1600
|
+
item.tweets = page_tweets
|
|
1601
|
+
except Exception:
|
|
1602
|
+
pass
|
|
1603
|
+
return items
|
|
1604
|
+
|
|
1605
|
+
def _parse_timeline_tab_items(
|
|
1606
|
+
self, data: dict, source: str, max_count: int, ai_only: bool
|
|
1607
|
+
) -> list[NewsItem]:
|
|
1608
|
+
items: list[NewsItem] = []
|
|
1609
|
+
seen: set[str] = set()
|
|
1610
|
+
timeline = (data.get("data") or {}).get("timeline", {}).get("timeline") or {}
|
|
1611
|
+
instructions = timeline.get("instructions") or []
|
|
1612
|
+
for instruction in instructions:
|
|
1613
|
+
entries = instruction.get("entries") or (
|
|
1614
|
+
[instruction["entry"]] if instruction.get("entry") else []
|
|
1615
|
+
)
|
|
1616
|
+
for entry in entries:
|
|
1617
|
+
if len(items) >= max_count:
|
|
1618
|
+
break
|
|
1619
|
+
content = entry.get("content") or {}
|
|
1620
|
+
if content.get("itemContent"):
|
|
1621
|
+
item = self._parse_news_item(content["itemContent"], entry.get("entryId", ""), source, seen, ai_only)
|
|
1622
|
+
if item:
|
|
1623
|
+
items.append(item)
|
|
1624
|
+
for sub in content.get("items") or []:
|
|
1625
|
+
item_content = sub.get("itemContent") or (sub.get("item") or {}).get("itemContent")
|
|
1626
|
+
if item_content:
|
|
1627
|
+
item = self._parse_news_item(item_content, entry.get("entryId", ""), source, seen, ai_only)
|
|
1628
|
+
if item:
|
|
1629
|
+
items.append(item)
|
|
1630
|
+
if len(items) >= max_count:
|
|
1631
|
+
break
|
|
1632
|
+
return items
|
|
1633
|
+
|
|
1634
|
+
@staticmethod
|
|
1635
|
+
def _parse_news_item(
|
|
1636
|
+
item_content: dict,
|
|
1637
|
+
entry_id: str,
|
|
1638
|
+
source: str,
|
|
1639
|
+
seen: set[str],
|
|
1640
|
+
ai_only: bool,
|
|
1641
|
+
) -> Optional[NewsItem]:
|
|
1642
|
+
headline = item_content.get("name") or item_content.get("title")
|
|
1643
|
+
if not headline or headline in seen:
|
|
1644
|
+
return None
|
|
1645
|
+
|
|
1646
|
+
trend_metadata = item_content.get("trend_metadata") or {}
|
|
1647
|
+
trend_url = (item_content.get("trend_url") or {}).get("url") or trend_metadata.get("url", {}).get("url")
|
|
1648
|
+
social_context_text = (item_content.get("social_context") or {}).get("text") or ""
|
|
1649
|
+
is_full_sentence = len(headline.split()) >= 5
|
|
1650
|
+
has_news_category = "News" in social_context_text or "hours ago" in social_context_text
|
|
1651
|
+
is_ai_trend = item_content.get("is_ai_trend") is True
|
|
1652
|
+
is_ai = is_ai_trend or (is_full_sentence and has_news_category)
|
|
1653
|
+
|
|
1654
|
+
if ai_only and not is_ai:
|
|
1655
|
+
return None
|
|
1656
|
+
|
|
1657
|
+
seen.add(headline)
|
|
1658
|
+
post_count: Optional[int] = None
|
|
1659
|
+
time_ago: Optional[str] = None
|
|
1660
|
+
category = "Trending"
|
|
1661
|
+
|
|
1662
|
+
if social_context_text:
|
|
1663
|
+
for part in [p.strip() for p in social_context_text.split("·")]:
|
|
1664
|
+
if "ago" in part:
|
|
1665
|
+
time_ago = part
|
|
1666
|
+
elif _POST_COUNT_RE.search(part):
|
|
1667
|
+
post_count = _parse_post_count(part)
|
|
1668
|
+
else:
|
|
1669
|
+
category = part
|
|
1670
|
+
|
|
1671
|
+
if trend_metadata.get("meta_description"):
|
|
1672
|
+
pc = _parse_post_count(trend_metadata["meta_description"])
|
|
1673
|
+
if pc:
|
|
1674
|
+
post_count = pc
|
|
1675
|
+
|
|
1676
|
+
domain_ctx = trend_metadata.get("domain_context")
|
|
1677
|
+
if domain_ctx and category in ("Trending", "News"):
|
|
1678
|
+
category = domain_ctx
|
|
1679
|
+
|
|
1680
|
+
item_id = trend_url or f"{entry_id}-{headline}" or f"{source}-{headline}"
|
|
1681
|
+
return NewsItem(
|
|
1682
|
+
id=item_id,
|
|
1683
|
+
headline=headline,
|
|
1684
|
+
category=f"AI · {category}" if is_ai else category,
|
|
1685
|
+
time_ago=time_ago,
|
|
1686
|
+
post_count=post_count,
|
|
1687
|
+
description=item_content.get("description"),
|
|
1688
|
+
url=trend_url,
|
|
1689
|
+
)
|
|
1690
|
+
|
|
1691
|
+
# ------------------------------------------------------------------
|
|
1692
|
+
# Query ID cache management
|
|
1693
|
+
# ------------------------------------------------------------------
|
|
1694
|
+
|
|
1695
|
+
def refresh_query_ids(self) -> dict:
|
|
1696
|
+
"""Force-refresh the GraphQL query ID cache and return info."""
|
|
1697
|
+
self._refresh_query_ids()
|
|
1698
|
+
return query_id_store.info()
|
|
1699
|
+
|
|
1700
|
+
def query_ids_info(self) -> dict:
|
|
1701
|
+
"""Return current query ID cache state."""
|
|
1702
|
+
return query_id_store.info()
|