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/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()