birdapi 0.0.1__tar.gz → 0.0.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: birdapi
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: CLI and library for X/Twitter GraphQL API (cookie auth, no API key required)
5
5
  Project-URL: Homepage, https://github.com/dvermaas/birdapi
6
6
  Project-URL: Repository, https://github.com/dvermaas/birdapi
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "birdapi"
7
- version = "0.0.1"
7
+ version = "0.0.2"
8
8
  description = "CLI and library for X/Twitter GraphQL API (cookie auth, no API key required)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from datetime import datetime
6
7
  from typing import Any, Optional
7
8
 
8
9
  from ._models import (
@@ -20,6 +21,19 @@ from ._models import (
20
21
 
21
22
  _HANDLE_RE = re.compile(r"^[A-Za-z0-9_]{1,15}$")
22
23
 
24
+ # X timestamp format, e.g. "Sun Jun 07 23:11:05 +0000 2026"
25
+ _TWEET_TIME_FMT = "%a %b %d %H:%M:%S %z %Y"
26
+
27
+
28
+ def parse_tweet_datetime(created_at: Optional[str]) -> Optional[datetime]:
29
+ """Parse a tweet ``created_at`` string into an aware datetime, or None."""
30
+ if not created_at:
31
+ return None
32
+ try:
33
+ return datetime.strptime(created_at, _TWEET_TIME_FMT)
34
+ except (ValueError, TypeError):
35
+ return None
36
+
23
37
 
24
38
  def normalize_handle(raw: Optional[str]) -> Optional[str]:
25
39
  if not raw:
@@ -351,6 +365,10 @@ def map_tweet_result(
351
365
  quote_depth: int = 1,
352
366
  include_raw: bool = False,
353
367
  ) -> Optional[Tweet]:
368
+ if not result:
369
+ return None
370
+ # Unwrap TweetWithVisibilityResults for callers that pass the raw result directly.
371
+ result = _unwrap_tweet_result(result)
354
372
  if not result:
355
373
  return None
356
374
  user_result = (result.get("core") or {}).get("user_results", {}).get("result") or {}
@@ -401,6 +419,9 @@ def _collect_tweet_results_from_entry(entry: dict) -> list[dict]:
401
419
  content = entry.get("content") or {}
402
420
 
403
421
  def push(r: Optional[dict]) -> None:
422
+ # Visibility-gated tweets arrive wrapped as TweetWithVisibilityResults,
423
+ # with rest_id nested under .tweet — unwrap before the rest_id check.
424
+ r = _unwrap_tweet_result(r)
404
425
  if r and r.get("rest_id"):
405
426
  results.append(r)
406
427
 
@@ -451,6 +472,7 @@ def find_tweet_in_instructions(
451
472
  result = (entry.get("content") or {}).get("itemContent", {}).get(
452
473
  "tweet_results", {}
453
474
  ).get("result")
475
+ result = _unwrap_tweet_result(result)
454
476
  if result and result.get("rest_id") == tweet_id:
455
477
  return result
456
478
  return None
@@ -6,6 +6,7 @@ import io
6
6
  import json
7
7
  import os
8
8
  import sys
9
+ from datetime import datetime, timezone
9
10
  from typing import Optional
10
11
 
11
12
  import click
@@ -27,6 +28,7 @@ def _make_client(
27
28
  auth_token: Optional[str],
28
29
  ct0: Optional[str],
29
30
  timeout: Optional[float],
31
+ min_request_interval: float = 0.0,
30
32
  ) -> TwitterClient:
31
33
  tok, csrf = resolve_credentials(auth_token, ct0)
32
34
  if not tok or not csrf:
@@ -36,34 +38,78 @@ def _make_client(
36
38
  err=True,
37
39
  )
38
40
  sys.exit(1)
39
- return TwitterClient(tok, csrf, timeout=timeout)
41
+ return TwitterClient(tok, csrf, timeout=timeout, min_request_interval=min_request_interval)
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Shorthand group: bird <tweet-id-or-url> → bird read <tweet-id-or-url>
46
+ # ---------------------------------------------------------------------------
47
+
48
+ class BirdGroup(click.Group):
49
+ def resolve_command(self, ctx, args):
50
+ cmd_name = args[0] if args else None
51
+ if cmd_name and cmd_name not in self.commands and not cmd_name.startswith("-"):
52
+ if cmd_name.isdigit() or "/status/" in cmd_name or "x.com" in cmd_name or "twitter.com" in cmd_name:
53
+ args.insert(0, "read")
54
+ return super().resolve_command(ctx, args)
40
55
 
41
56
 
42
57
  # ---------------------------------------------------------------------------
43
58
  # Global options
44
59
  # ---------------------------------------------------------------------------
45
60
 
46
- @click.group()
61
+ @click.group(cls=BirdGroup)
47
62
  @click.pass_context
48
63
  @click.option("--auth-token", envvar=["AUTH_TOKEN", "TWITTER_AUTH_TOKEN"], hidden=True)
49
64
  @click.option("--ct0", envvar=["CT0", "TWITTER_CT0"], hidden=True)
50
65
  @click.option("--timeout", type=float, default=None, envvar="BIRD_TIMEOUT_MS",
51
66
  help="Request timeout in milliseconds.")
67
+ @click.option("--rate-limit", "rate_limit", type=float, default=0.0, envvar="BIRD_RATE_LIMIT",
68
+ help="Minimum seconds between calls to x.com (0 = off). e.g. --rate-limit 5")
52
69
  @click.option("--json", "as_json", is_flag=True)
53
70
  @click.option("--quote-depth", type=int, default=1, envvar="BIRD_QUOTE_DEPTH")
54
- def main(ctx: click.Context, auth_token, ct0, timeout, as_json, quote_depth):
71
+ @click.option("--plain", is_flag=True, default=False,
72
+ help="Plain output: no emoji, no color (stable for scripting).")
73
+ @click.option("--no-emoji", "no_emoji", is_flag=True, default=False,
74
+ help="Disable emoji in output.")
75
+ @click.option("--no-color", "no_color", is_flag=True, default=False,
76
+ help="Disable ANSI colors (or set NO_COLOR env var).")
77
+ def main(ctx: click.Context, auth_token, ct0, timeout, rate_limit, as_json, quote_depth, plain, no_emoji, no_color):
55
78
  """bird — fast X/Twitter CLI (cookie auth, no browser extraction)."""
56
79
  ctx.ensure_object(dict)
57
80
  ctx.obj["auth_token"] = auth_token
58
81
  ctx.obj["ct0"] = ct0
59
82
  ctx.obj["timeout"] = timeout / 1000 if timeout else None
83
+ ctx.obj["rate_limit"] = max(0.0, rate_limit or 0.0)
60
84
  ctx.obj["as_json"] = as_json
61
85
  ctx.obj["quote_depth"] = quote_depth
86
+ # plain implies both no_emoji and no_color
87
+ ctx.obj["plain"] = plain or no_emoji or no_color
88
+ # Respect NO_COLOR env var
89
+ if os.environ.get("NO_COLOR"):
90
+ ctx.obj["plain"] = True
62
91
 
63
92
 
64
93
  def _client(ctx) -> TwitterClient:
65
94
  o = ctx.obj
66
- return _make_client(o["auth_token"], o["ct0"], o["timeout"])
95
+ return _make_client(o["auth_token"], o["ct0"], o["timeout"], o.get("rate_limit", 0.0))
96
+
97
+
98
+ def _parse_since(value: str) -> datetime:
99
+ """Parse --since (YYYY-MM-DD or ISO 8601) into an aware UTC datetime."""
100
+ s = value.strip()
101
+ try:
102
+ if len(s) == 10 and s.count("-") == 2:
103
+ dt = datetime.strptime(s, "%Y-%m-%d")
104
+ else:
105
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
106
+ except ValueError as exc:
107
+ raise click.BadParameter(
108
+ f"Invalid date {value!r}. Use YYYY-MM-DD or ISO 8601."
109
+ ) from exc
110
+ if dt.tzinfo is None:
111
+ dt = dt.replace(tzinfo=timezone.utc)
112
+ return dt
67
113
 
68
114
 
69
115
  # ---------------------------------------------------------------------------
@@ -79,7 +125,7 @@ def _unescape(text: str) -> str:
79
125
  return _html.unescape(text)
80
126
 
81
127
 
82
- def _format_tweet(tweet) -> str:
128
+ def _format_tweet(tweet, plain: bool = False, show_stats: bool = False) -> str:
83
129
  lines: list[str] = []
84
130
 
85
131
  # Header: @username (Full Name):
@@ -96,41 +142,70 @@ def _format_tweet(tweet) -> str:
96
142
  lines.append(f"\u2502 {body_line}")
97
143
  if qt.media:
98
144
  for m in qt.media:
99
- icon = "\U0001f3ac" if m.type in ("video", "animated_gif") else "\U0001f5bc\ufe0f"
100
- lines.append(f"\u2502 {icon} {m.url}")
145
+ if plain:
146
+ tag = "[video]" if m.type in ("video", "animated_gif") else "[image]"
147
+ lines.append(f"\u2502 {tag} {m.url}")
148
+ else:
149
+ icon = "\U0001f3ac" if m.type in ("video", "animated_gif") else "\U0001f5bc\ufe0f"
150
+ lines.append(f"\u2502 {icon} {m.url}")
101
151
  lines.append(f"\u2514\u2500 https://x.com/{qt.author.username}/status/{qt.id}")
102
152
 
103
153
  # Media on the outer tweet
104
154
  if tweet.media:
105
155
  for m in tweet.media:
106
- icon = "\U0001f3ac" if m.type in ("video", "animated_gif") else "\U0001f5bc\ufe0f"
107
- lines.append(f"{icon} {m.url}")
156
+ if plain:
157
+ tag = "[video]" if m.type in ("video", "animated_gif") else "[image]"
158
+ lines.append(f"{tag} {m.url}")
159
+ else:
160
+ icon = "\U0001f3ac" if m.type in ("video", "animated_gif") else "\U0001f5bc\ufe0f"
161
+ lines.append(f"{icon} {m.url}")
108
162
 
109
163
  # Metadata
110
164
  if tweet.created_at:
111
- lines.append(f"\U0001f4c5 {tweet.created_at}")
112
- lines.append(f"\U0001f517 https://x.com/{tweet.author.username}/status/{tweet.id}")
113
- lines.append(_SEPARATOR)
165
+ if plain:
166
+ lines.append(f"date: {tweet.created_at}")
167
+ else:
168
+ lines.append(f"\U0001f4c5 {tweet.created_at}")
169
+ url = f"https://x.com/{tweet.author.username}/status/{tweet.id}"
170
+ if plain:
171
+ lines.append(f"url: {url}")
172
+ else:
173
+ lines.append(f"\U0001f517 {url}")
174
+
175
+ # Engagement stats (shown for single-tweet read, not list views)
176
+ if show_stats and not plain:
177
+ parts = []
178
+ if tweet.like_count is not None:
179
+ parts.append(f"\u2764\ufe0f {tweet.like_count}")
180
+ if tweet.retweet_count is not None:
181
+ parts.append(f"\U0001f501 {tweet.retweet_count}")
182
+ if tweet.reply_count is not None:
183
+ parts.append(f"\U0001f4ac {tweet.reply_count}")
184
+ if parts:
185
+ lines.append(" ".join(parts))
186
+ else:
187
+ lines.append(_SEPARATOR)
114
188
 
115
189
  return "\n".join(lines)
116
190
 
117
191
 
118
- def _dump_tweet(tweet, as_json: bool) -> None:
192
+ def _dump_tweet(tweet, as_json: bool, plain: bool = False, include_raw: bool = False,
193
+ show_stats: bool = False) -> None:
119
194
  if as_json:
120
- click.echo(json.dumps(_tweet_to_dict(tweet), ensure_ascii=False, indent=2))
195
+ click.echo(json.dumps(_tweet_to_dict(tweet, include_raw=include_raw), ensure_ascii=False, indent=2))
121
196
  else:
122
- click.echo(_format_tweet(tweet))
197
+ click.echo(_format_tweet(tweet, plain=plain, show_stats=show_stats))
123
198
 
124
199
 
125
- def _dump_tweets(tweets, as_json: bool) -> None:
200
+ def _dump_tweets(tweets, as_json: bool, plain: bool = False, include_raw: bool = False) -> None:
126
201
  if as_json:
127
- click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False))
202
+ click.echo(json.dumps([_tweet_to_dict(t, include_raw=include_raw) for t in tweets], ensure_ascii=False))
128
203
  else:
129
204
  for t in tweets:
130
- click.echo(_format_tweet(t))
205
+ click.echo(_format_tweet(t, plain=plain))
131
206
 
132
207
 
133
- def _tweet_to_dict(tweet) -> dict:
208
+ def _tweet_to_dict(tweet, include_raw: bool = False) -> dict:
134
209
  d: dict = {
135
210
  "id": tweet.id,
136
211
  "text": _unescape(tweet.text),
@@ -145,9 +220,11 @@ def _tweet_to_dict(tweet) -> dict:
145
220
  d["author"] = {"username": tweet.author.username, "name": tweet.author.name}
146
221
  d["authorId"] = tweet.author_id
147
222
  if tweet.quoted_tweet:
148
- d["quotedTweet"] = _tweet_to_dict(tweet.quoted_tweet)
223
+ d["quotedTweet"] = _tweet_to_dict(tweet.quoted_tweet, include_raw=include_raw)
149
224
  if tweet.media:
150
225
  d["media"] = [_media_to_dict(m) for m in tweet.media]
226
+ if include_raw and tweet._raw is not None:
227
+ d["_raw"] = tweet._raw
151
228
  return d
152
229
 
153
230
 
@@ -183,20 +260,23 @@ def _user_to_dict(user) -> dict:
183
260
  @main.command()
184
261
  @click.argument("tweet_id_or_url")
185
262
  @click.option("--json", "as_json", is_flag=True)
263
+ @click.option("--json-full", "json_full", is_flag=True,
264
+ help="Include raw API response in _raw field.")
186
265
  @click.pass_context
187
- def read(ctx, tweet_id_or_url, as_json):
266
+ def read(ctx, tweet_id_or_url, as_json, json_full):
188
267
  """Fetch and display a tweet by ID or URL."""
189
268
  tweet_id = extract_tweet_id(tweet_id_or_url)
190
269
  if not tweet_id:
191
270
  click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
192
271
  sys.exit(1)
193
- as_json = as_json or ctx.obj.get("as_json")
272
+ as_json = as_json or json_full or ctx.obj.get("as_json")
273
+ plain = ctx.obj.get("plain", False)
194
274
  with _client(ctx) as client:
195
- tweet = client.get_tweet(tweet_id)
275
+ tweet = client.get_tweet(tweet_id, include_raw=json_full)
196
276
  if not tweet:
197
277
  click.echo("Tweet not found.", err=True)
198
278
  sys.exit(1)
199
- _dump_tweet(tweet, as_json)
279
+ _dump_tweet(tweet, as_json, plain=plain, include_raw=json_full, show_stats=True)
200
280
 
201
281
 
202
282
  # ---------------------------------------------------------------------------
@@ -206,34 +286,40 @@ def read(ctx, tweet_id_or_url, as_json):
206
286
  @main.command()
207
287
  @click.argument("tweet_id_or_url")
208
288
  @click.option("--json", "as_json", is_flag=True)
289
+ @click.option("--json-full", "json_full", is_flag=True,
290
+ help="Include raw API response in _raw field.")
209
291
  @click.pass_context
210
- def thread(ctx, tweet_id_or_url, as_json):
292
+ def thread(ctx, tweet_id_or_url, as_json, json_full):
211
293
  """Show the full conversation thread for a tweet."""
212
294
  tweet_id = extract_tweet_id(tweet_id_or_url)
213
295
  if not tweet_id:
214
296
  click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
215
297
  sys.exit(1)
216
- as_json = as_json or ctx.obj.get("as_json")
298
+ as_json = as_json or json_full or ctx.obj.get("as_json")
299
+ plain = ctx.obj.get("plain", False)
217
300
  with _client(ctx) as client:
218
- tweets = client.get_thread(tweet_id)
219
- _dump_tweets(tweets, as_json)
301
+ tweets = client.get_thread(tweet_id, include_raw=json_full)
302
+ _dump_tweets(tweets, as_json, plain=plain, include_raw=json_full)
220
303
 
221
304
 
222
305
  @main.command()
223
306
  @click.argument("tweet_id_or_url")
224
307
  @click.option("-n", "--count", default=20, show_default=True)
225
308
  @click.option("--json", "as_json", is_flag=True)
309
+ @click.option("--json-full", "json_full", is_flag=True,
310
+ help="Include raw API response in _raw field.")
226
311
  @click.pass_context
227
- def replies(ctx, tweet_id_or_url, count, as_json):
312
+ def replies(ctx, tweet_id_or_url, count, as_json, json_full):
228
313
  """List replies to a tweet."""
229
314
  tweet_id = extract_tweet_id(tweet_id_or_url)
230
315
  if not tweet_id:
231
316
  click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
232
317
  sys.exit(1)
233
- as_json = as_json or ctx.obj.get("as_json")
318
+ as_json = as_json or json_full or ctx.obj.get("as_json")
319
+ plain = ctx.obj.get("plain", False)
234
320
  with _client(ctx) as client:
235
- tweets = client.get_replies(tweet_id)
236
- _dump_tweets(tweets[:count], as_json)
321
+ tweets = client.get_replies(tweet_id, include_raw=json_full)
322
+ _dump_tweets(tweets[:count], as_json, plain=plain, include_raw=json_full)
237
323
 
238
324
 
239
325
  # ---------------------------------------------------------------------------
@@ -245,13 +331,18 @@ def replies(ctx, tweet_id_or_url, count, as_json):
245
331
  @click.pass_context
246
332
  def post_tweet(ctx, text):
247
333
  """Post a new tweet."""
248
- with _client(ctx) as client:
249
- tweet_id = client.tweet(text)
250
- if tweet_id:
251
- click.echo(f"Posted: https://x.com/i/status/{tweet_id}")
252
- else:
253
- click.echo("Failed to post tweet.", err=True)
334
+ plain = ctx.obj.get("plain", False)
335
+ try:
336
+ with _client(ctx) as client:
337
+ tweet_id = client.tweet(text)
338
+ except RuntimeError as exc:
339
+ click.echo(f"Failed to post tweet: {exc}", err=True)
254
340
  sys.exit(1)
341
+ url = f"https://x.com/i/status/{tweet_id}"
342
+ if plain:
343
+ click.echo(f"Tweet posted successfully!\n{url}")
344
+ else:
345
+ click.echo(f"\u2705 Tweet posted successfully!\n\U0001f517 {url}")
255
346
 
256
347
 
257
348
  @main.command(name="reply")
@@ -260,17 +351,22 @@ def post_tweet(ctx, text):
260
351
  @click.pass_context
261
352
  def post_reply(ctx, tweet_id_or_url, text):
262
353
  """Reply to a tweet."""
354
+ plain = ctx.obj.get("plain", False)
263
355
  tweet_id = extract_tweet_id(tweet_id_or_url)
264
356
  if not tweet_id:
265
357
  click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
266
358
  sys.exit(1)
267
- with _client(ctx) as client:
268
- new_id = client.reply(text, tweet_id)
269
- if new_id:
270
- click.echo(f"Replied: https://x.com/i/status/{new_id}")
271
- else:
272
- click.echo("Failed to post reply.", err=True)
359
+ try:
360
+ with _client(ctx) as client:
361
+ new_id = client.reply(text, tweet_id)
362
+ except RuntimeError as exc:
363
+ click.echo(f"Failed to post reply: {exc}", err=True)
273
364
  sys.exit(1)
365
+ url = f"https://x.com/i/status/{new_id}"
366
+ if plain:
367
+ click.echo(f"Reply posted successfully!\n{url}")
368
+ else:
369
+ click.echo(f"\u2705 Reply posted successfully!\n\U0001f517 {url}")
274
370
 
275
371
 
276
372
  # ---------------------------------------------------------------------------
@@ -281,31 +377,39 @@ def post_reply(ctx, tweet_id_or_url, text):
281
377
  @click.argument("query")
282
378
  @click.option("-n", "--count", default=20, show_default=True)
283
379
  @click.option("--json", "as_json", is_flag=True)
380
+ @click.option("--json-full", "json_full", is_flag=True,
381
+ help="Include raw API response in _raw field.")
284
382
  @click.option("--cursor", default=None)
285
383
  @click.option("--max-pages", type=int, default=None)
286
384
  @click.pass_context
287
- def search(ctx, query, count, as_json, cursor, max_pages):
385
+ def search(ctx, query, count, as_json, json_full, cursor, max_pages):
288
386
  """Search for tweets matching a query."""
289
- as_json = as_json or ctx.obj.get("as_json")
387
+ as_json = as_json or json_full or ctx.obj.get("as_json")
388
+ plain = ctx.obj.get("plain", False)
290
389
  with _client(ctx) as client:
291
- tweets, next_cursor = client.search(query, count, cursor=cursor, max_pages=max_pages)
390
+ tweets, next_cursor = client.search(query, count, cursor=cursor, max_pages=max_pages,
391
+ include_raw=json_full)
292
392
  if as_json:
293
- click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
393
+ click.echo(json.dumps([_tweet_to_dict(t, include_raw=json_full) for t in tweets],
394
+ ensure_ascii=False, indent=2))
294
395
  else:
295
- _dump_tweets(tweets, False)
396
+ _dump_tweets(tweets, False, plain=plain)
296
397
 
297
398
 
298
399
  @main.command()
299
- @click.option("--user", default=None, help="@handle to search mentions for")
400
+ @click.option("-u", "--user", default=None, help="@handle to search mentions for")
300
401
  @click.option("-n", "--count", default=20, show_default=True)
301
402
  @click.option("--json", "as_json", is_flag=True)
403
+ @click.option("--json-full", "json_full", is_flag=True,
404
+ help="Include raw API response in _raw field.")
302
405
  @click.pass_context
303
- def mentions(ctx, user, count, as_json):
406
+ def mentions(ctx, user, count, as_json, json_full):
304
407
  """Find tweets mentioning a user (defaults to authenticated user)."""
305
- as_json = as_json or ctx.obj.get("as_json")
408
+ as_json = as_json or json_full or ctx.obj.get("as_json")
409
+ plain = ctx.obj.get("plain", False)
306
410
  with _client(ctx) as client:
307
- tweets, _ = client.get_mentions(user, count)
308
- _dump_tweets(tweets, as_json)
411
+ tweets, _ = client.get_mentions(user, count, include_raw=json_full)
412
+ _dump_tweets(tweets, as_json, plain=plain, include_raw=json_full)
309
413
 
310
414
 
311
415
  # ---------------------------------------------------------------------------
@@ -316,25 +420,42 @@ def mentions(ctx, user, count, as_json):
316
420
  @click.argument("handle")
317
421
  @click.option("-n", "--count", default=20, show_default=True)
318
422
  @click.option("--json", "as_json", is_flag=True)
423
+ @click.option("--json-full", "json_full", is_flag=True,
424
+ help="Include raw API response in _raw field.")
319
425
  @click.option("--cursor", default=None)
426
+ @click.option("--max-pages", type=int, default=None)
427
+ @click.option("--since", "since", default=None,
428
+ help="Fetch tweets back to this date (YYYY-MM-DD or ISO 8601) instead "
429
+ "of a fixed count. -n caps the result; --max-pages bounds requests.")
430
+ @click.option("--delay", "delay_ms", type=int, default=1000, show_default=True,
431
+ help="Delay in ms between page fetches when paginating.")
320
432
  @click.pass_context
321
- def user_tweets(ctx, handle, count, as_json, cursor):
433
+ def user_tweets(ctx, handle, count, as_json, json_full, cursor, max_pages, since, delay_ms):
322
434
  """Get tweets from a user's profile timeline."""
323
- as_json = as_json or ctx.obj.get("as_json")
435
+ as_json = as_json or json_full or ctx.obj.get("as_json")
436
+ plain = ctx.obj.get("plain", False)
324
437
  norm = normalize_handle(handle)
325
438
  if not norm:
326
439
  click.echo(f"Invalid handle: {handle!r}", err=True)
327
440
  sys.exit(1)
441
+ since_dt = _parse_since(since) if since else None
442
+ # In since-mode, a default (unset) -n shouldn't cap results; an explicit -n does.
443
+ if since_dt is not None and ctx.get_parameter_source("count") != click.core.ParameterSource.COMMANDLINE:
444
+ count = None
328
445
  with _client(ctx) as client:
329
446
  user = client.get_user_id_by_username(norm)
330
447
  if not user:
331
448
  click.echo(f"User @{norm} not found.", err=True)
332
449
  sys.exit(1)
333
- tweets, next_cursor = client.get_user_tweets(user.id, count, cursor=cursor)
450
+ tweets, next_cursor = client.get_user_tweets(
451
+ user.id, count, cursor=cursor, max_pages=max_pages,
452
+ include_raw=json_full, page_delay=delay_ms / 1000, since=since_dt,
453
+ )
334
454
  if as_json:
335
- click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
455
+ click.echo(json.dumps([_tweet_to_dict(t, include_raw=json_full) for t in tweets],
456
+ ensure_ascii=False, indent=2))
336
457
  else:
337
- _dump_tweets(tweets, False)
458
+ _dump_tweets(tweets, False, plain=plain)
338
459
 
339
460
 
340
461
  # ---------------------------------------------------------------------------
@@ -348,19 +469,24 @@ def user_tweets(ctx, handle, count, as_json, cursor):
348
469
  @click.option("--max-pages", type=int, default=None)
349
470
  @click.option("--cursor", default=None)
350
471
  @click.option("--json", "as_json", is_flag=True)
472
+ @click.option("--json-full", "json_full", is_flag=True,
473
+ help="Include raw API response in _raw field.")
351
474
  @click.pass_context
352
- def bookmarks(ctx, count, folder_id, fetch_all, max_pages, cursor, as_json):
475
+ def bookmarks(ctx, count, folder_id, fetch_all, max_pages, cursor, as_json, json_full):
353
476
  """List bookmarked tweets."""
354
- as_json = as_json or ctx.obj.get("as_json")
477
+ as_json = as_json or json_full or ctx.obj.get("as_json")
478
+ plain = ctx.obj.get("plain", False)
355
479
  limit = -1 if fetch_all else count
356
480
  with _client(ctx) as client:
357
481
  tweets, next_cursor = client.get_bookmarks(
358
- limit, folder_id=folder_id, cursor=cursor, max_pages=max_pages
482
+ limit, folder_id=folder_id, cursor=cursor, max_pages=max_pages,
483
+ include_raw=json_full,
359
484
  )
360
485
  if as_json:
361
- click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
486
+ click.echo(json.dumps([_tweet_to_dict(t, include_raw=json_full) for t in tweets],
487
+ ensure_ascii=False, indent=2))
362
488
  else:
363
- _dump_tweets(tweets, False)
489
+ _dump_tweets(tweets, False, plain=plain)
364
490
 
365
491
 
366
492
  @main.command()
@@ -386,17 +512,21 @@ def unbookmark(ctx, tweet_ids_or_urls):
386
512
  @main.command()
387
513
  @click.option("-n", "--count", default=20, show_default=True)
388
514
  @click.option("--json", "as_json", is_flag=True)
515
+ @click.option("--json-full", "json_full", is_flag=True,
516
+ help="Include raw API response in _raw field.")
389
517
  @click.option("--cursor", default=None)
390
518
  @click.pass_context
391
- def likes(ctx, count, as_json, cursor):
519
+ def likes(ctx, count, as_json, json_full, cursor):
392
520
  """List liked tweets."""
393
- as_json = as_json or ctx.obj.get("as_json")
521
+ as_json = as_json or json_full or ctx.obj.get("as_json")
522
+ plain = ctx.obj.get("plain", False)
394
523
  with _client(ctx) as client:
395
- tweets, next_cursor = client.get_likes(count, cursor=cursor)
524
+ tweets, next_cursor = client.get_likes(count, cursor=cursor, include_raw=json_full)
396
525
  if as_json:
397
- click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
526
+ click.echo(json.dumps([_tweet_to_dict(t, include_raw=json_full) for t in tweets],
527
+ ensure_ascii=False, indent=2))
398
528
  else:
399
- _dump_tweets(tweets, False)
529
+ _dump_tweets(tweets, False, plain=plain)
400
530
 
401
531
 
402
532
  # ---------------------------------------------------------------------------
@@ -407,16 +537,19 @@ def likes(ctx, count, as_json, cursor):
407
537
  @click.option("-n", "--count", default=20, show_default=True)
408
538
  @click.option("--following", is_flag=True, help="Show Following (chronological) feed")
409
539
  @click.option("--json", "as_json", is_flag=True)
540
+ @click.option("--json-full", "json_full", is_flag=True,
541
+ help="Include raw API response in _raw field.")
410
542
  @click.pass_context
411
- def home(ctx, count, following, as_json):
543
+ def home(ctx, count, following, as_json, json_full):
412
544
  """Fetch home timeline (For You or Following feed)."""
413
- as_json = as_json or ctx.obj.get("as_json")
545
+ as_json = as_json or json_full or ctx.obj.get("as_json")
546
+ plain = ctx.obj.get("plain", False)
414
547
  with _client(ctx) as client:
415
548
  if following:
416
549
  tweets = client.get_home_latest_timeline(count)
417
550
  else:
418
551
  tweets = client.get_home_timeline(count)
419
- _dump_tweets(tweets, as_json)
552
+ _dump_tweets(tweets, as_json, plain=plain, include_raw=json_full)
420
553
 
421
554
 
422
555
  # ---------------------------------------------------------------------------
@@ -475,6 +608,58 @@ def followers(ctx, user, count, as_json, cursor):
475
608
  click.echo(f"@{u.username} — {u.name}")
476
609
 
477
610
 
611
+ # ---------------------------------------------------------------------------
612
+ # follow / unfollow
613
+ # ---------------------------------------------------------------------------
614
+
615
+ def _resolve_user_id(client, username_or_id: str) -> Optional[str]:
616
+ """Return a numeric user ID from a bare ID or @handle / handle."""
617
+ val = username_or_id.lstrip("@").strip()
618
+ if val.isdigit():
619
+ return val
620
+ norm = normalize_handle(val)
621
+ if not norm:
622
+ return None
623
+ user = client.get_user_id_by_username(norm)
624
+ return user.id if user else None
625
+
626
+
627
+ @main.command(name="follow")
628
+ @click.argument("username_or_id")
629
+ @click.pass_context
630
+ def follow_user(ctx, username_or_id):
631
+ """Follow a user (username with or without @, or numeric user ID)."""
632
+ with _client(ctx) as client:
633
+ uid = _resolve_user_id(client, username_or_id)
634
+ if not uid:
635
+ click.echo(f"User not found: {username_or_id!r}", err=True)
636
+ sys.exit(1)
637
+ ok = client.follow(uid)
638
+ if ok:
639
+ click.echo(f"Followed: {username_or_id}")
640
+ else:
641
+ click.echo(f"Failed to follow: {username_or_id}", err=True)
642
+ sys.exit(1)
643
+
644
+
645
+ @main.command(name="unfollow")
646
+ @click.argument("username_or_id")
647
+ @click.pass_context
648
+ def unfollow_user(ctx, username_or_id):
649
+ """Unfollow a user (username with or without @, or numeric user ID)."""
650
+ with _client(ctx) as client:
651
+ uid = _resolve_user_id(client, username_or_id)
652
+ if not uid:
653
+ click.echo(f"User not found: {username_or_id!r}", err=True)
654
+ sys.exit(1)
655
+ ok = client.unfollow(uid)
656
+ if ok:
657
+ click.echo(f"Unfollowed: {username_or_id}")
658
+ else:
659
+ click.echo(f"Failed to unfollow: {username_or_id}", err=True)
660
+ sys.exit(1)
661
+
662
+
478
663
  # ---------------------------------------------------------------------------
479
664
  # lists / list-timeline
480
665
  # ---------------------------------------------------------------------------
@@ -507,22 +692,27 @@ def list_lists(ctx, member_of, count, as_json):
507
692
  @click.argument("list_id_or_url")
508
693
  @click.option("-n", "--count", default=20, show_default=True)
509
694
  @click.option("--json", "as_json", is_flag=True)
695
+ @click.option("--json-full", "json_full", is_flag=True,
696
+ help="Include raw API response in _raw field.")
510
697
  @click.option("--cursor", default=None)
511
698
  @click.option("--max-pages", type=int, default=None)
512
699
  @click.pass_context
513
- def list_timeline(ctx, list_id_or_url, count, as_json, cursor, max_pages):
700
+ def list_timeline(ctx, list_id_or_url, count, as_json, json_full, cursor, max_pages):
514
701
  """Get tweets from a list timeline."""
515
702
  list_id = extract_list_id(list_id_or_url)
516
703
  if not list_id:
517
704
  click.echo(f"Cannot parse list ID from {list_id_or_url!r}", err=True)
518
705
  sys.exit(1)
519
- as_json = as_json or ctx.obj.get("as_json")
706
+ as_json = as_json or json_full or ctx.obj.get("as_json")
707
+ plain = ctx.obj.get("plain", False)
520
708
  with _client(ctx) as client:
521
- tweets, next_cursor = client.get_list_timeline(list_id, count, cursor=cursor, max_pages=max_pages)
709
+ tweets, next_cursor = client.get_list_timeline(list_id, count, cursor=cursor,
710
+ max_pages=max_pages, include_raw=json_full)
522
711
  if as_json:
523
- click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
712
+ click.echo(json.dumps([_tweet_to_dict(t, include_raw=json_full) for t in tweets],
713
+ ensure_ascii=False, indent=2))
524
714
  else:
525
- _dump_tweets(tweets, False)
715
+ _dump_tweets(tweets, False, plain=plain)
526
716
 
527
717
 
528
718
  # ---------------------------------------------------------------------------
@@ -545,6 +735,7 @@ def news(ctx, count, ai_only, with_tweets, tweets_per_item,
545
735
  tab_for_you, tab_news, tab_sports, tab_entertainment, tab_trending, as_json):
546
736
  """Fetch news and trending topics from X's Explore tabs."""
547
737
  as_json = as_json or ctx.obj.get("as_json")
738
+ plain = ctx.obj.get("plain", False)
548
739
  tabs: list[str] = []
549
740
  if tab_for_you:
550
741
  tabs.append("forYou")
@@ -633,8 +824,8 @@ def about(ctx, handle, as_json):
633
824
  click.echo(f"Based in: {profile.account_based_in}")
634
825
  if profile.source:
635
826
  click.echo(f"Source: {profile.source}")
636
- if profile.created_country_accurate:
637
- click.echo(f"Created in: {profile.created_country_accurate}")
827
+ if profile.learn_more_url:
828
+ click.echo(f"Info: {profile.learn_more_url}")
638
829
 
639
830
 
640
831
  @main.command()
@@ -15,6 +15,7 @@ import os
15
15
  import re
16
16
  import time
17
17
  import uuid
18
+ from datetime import datetime
18
19
  from typing import Any, Optional
19
20
 
20
21
  import httpx
@@ -56,6 +57,7 @@ from ._utils import (
56
57
  find_tweet_in_instructions,
57
58
  map_tweet_result,
58
59
  normalize_handle,
60
+ parse_tweet_datetime,
59
61
  parse_tweets_from_instructions,
60
62
  parse_users_from_instructions,
61
63
  )
@@ -66,6 +68,9 @@ _DEFAULT_UA = (
66
68
  "Chrome/131.0.0.0 Safari/537.36"
67
69
  )
68
70
  _PAGE_SIZE = 20
71
+ # --since pagination safety bounds: default page budget and hard ceiling.
72
+ _SINCE_DEFAULT_PAGES = 25
73
+ _SINCE_MAX_PAGES = 50
69
74
 
70
75
  # Regex to detect query-ID mismatch errors in 400/422 responses
71
76
  _RAW_QUERY_MISSING_RE = re.compile(r"must be defined", re.IGNORECASE)
@@ -128,6 +133,7 @@ class TwitterClient:
128
133
  user_agent: str = _DEFAULT_UA,
129
134
  timeout: Optional[float] = None,
130
135
  quote_depth: int = 1,
136
+ min_request_interval: float = 0.0,
131
137
  ) -> None:
132
138
  if not auth_token or not ct0:
133
139
  raise ValueError("Both auth_token and ct0 are required")
@@ -137,6 +143,9 @@ class TwitterClient:
137
143
  self._user_agent = user_agent
138
144
  self._timeout = timeout
139
145
  self._quote_depth = max(0, int(quote_depth))
146
+ # Client-side rate limit: minimum seconds between HTTP calls (0 = off).
147
+ self._min_request_interval = max(0.0, float(min_request_interval))
148
+ self._last_request_at = 0.0
140
149
  self._client_uuid = str(uuid.uuid4())
141
150
  self._client_device_id = str(uuid.uuid4())
142
151
  self._client_user_id: Optional[str] = None
@@ -202,13 +211,25 @@ class TwitterClient:
202
211
  if result and result.id:
203
212
  self._client_user_id = result.id
204
213
 
214
+ def _throttle(self) -> None:
215
+ """Enforce the optional minimum interval between outbound HTTP calls."""
216
+ if self._min_request_interval <= 0:
217
+ return
218
+ wait = self._min_request_interval - (time.monotonic() - self._last_request_at)
219
+ if wait > 0:
220
+ time.sleep(wait)
221
+ self._last_request_at = time.monotonic()
222
+
205
223
  def _get(self, url: str) -> httpx.Response:
224
+ self._throttle()
206
225
  return self._http.get(url, headers=self._json_headers())
207
226
 
208
227
  def _post(self, url: str, body: str) -> httpx.Response:
228
+ self._throttle()
209
229
  return self._http.post(url, headers=self._json_headers(), content=body.encode())
210
230
 
211
231
  def _post_form(self, url: str, data: dict, extra_headers: Optional[dict] = None) -> httpx.Response:
232
+ self._throttle()
212
233
  headers = {**self._base_headers(), "content-type": "application/x-www-form-urlencoded"}
213
234
  if extra_headers:
214
235
  headers.update(extra_headers)
@@ -256,9 +277,16 @@ class TwitterClient:
256
277
  limit: int,
257
278
  max_pages: Optional[int] = None,
258
279
  initial_cursor: Optional[str] = None,
280
+ page_delay: float = 0.0,
281
+ since: Optional[datetime] = None,
259
282
  ) -> tuple[list[Tweet], Optional[str], Optional[str]]:
260
283
  """Generic tweet pagination loop.
261
284
 
285
+ When ``since`` is set, tweets older than the cutoff are dropped and
286
+ paging stops once a page's oldest tweet predates it. The page's *last*
287
+ tweet (not any tweet) drives the stop decision so a pinned tweet — which
288
+ sits at the top regardless of date — doesn't trigger an early stop.
289
+
262
290
  Returns (tweets, next_cursor, error).
263
291
  """
264
292
  tweets: list[Tweet] = []
@@ -269,7 +297,13 @@ class TwitterClient:
269
297
  unlimited = limit == math.inf or limit < 0
270
298
 
271
299
  while unlimited or len(tweets) < limit:
272
- page_count = _PAGE_SIZE if unlimited else min(_PAGE_SIZE, limit - len(tweets))
300
+ if pages_fetched > 0 and page_delay > 0:
301
+ time.sleep(page_delay)
302
+ # Always request a full page. X returns timeline *entries*, many of
303
+ # which (cursors, who-to-follow, gated dupes) aren't tweets, so a
304
+ # short request like count=1 wastes a round-trip; we trim to `limit`
305
+ # via the inner break below.
306
+ page_count = _PAGE_SIZE
273
307
  page_tweets, page_cursor, had_404, error = fetch_page(cursor, page_count)
274
308
 
275
309
  if error and not page_tweets:
@@ -287,12 +321,23 @@ class TwitterClient:
287
321
  for t in page_tweets:
288
322
  if t.id in seen:
289
323
  continue
324
+ if since is not None:
325
+ dt = parse_tweet_datetime(t.created_at)
326
+ if dt is not None and dt < since:
327
+ continue # older than cutoff — drop (handles pinned dupes too)
290
328
  seen.add(t.id)
291
329
  tweets.append(t)
292
330
  added += 1
293
331
  if not unlimited and len(tweets) >= limit:
294
332
  break
295
333
 
334
+ # Stop once the page's oldest (last, chronological) tweet predates the cutoff.
335
+ if since is not None and page_tweets:
336
+ last_dt = parse_tweet_datetime(page_tweets[-1].created_at)
337
+ if last_dt is not None and last_dt < since:
338
+ next_cursor = None
339
+ break
340
+
296
341
  if not page_cursor or page_cursor == cursor or not page_tweets or added == 0:
297
342
  next_cursor = None
298
343
  break
@@ -511,7 +556,7 @@ class TwitterClient:
511
556
  pass
512
557
  return None
513
558
 
514
- def get_tweet(self, tweet_id: str) -> Optional[Tweet]:
559
+ def get_tweet(self, tweet_id: str, include_raw: bool = False) -> Optional[Tweet]:
515
560
  """Fetch a single tweet by ID."""
516
561
  data = self._fetch_tweet_detail(tweet_id)
517
562
  if not data:
@@ -523,7 +568,7 @@ class TwitterClient:
523
568
  tweet_id,
524
569
  )
525
570
  )
526
- mapped = map_tweet_result(result, self._quote_depth)
571
+ mapped = map_tweet_result(result, self._quote_depth, include_raw)
527
572
  if mapped and result and result.get("article"):
528
573
  title = _first_text(
529
574
  (result["article"].get("article_results") or {}).get("result", {}).get("title"),
@@ -539,7 +584,7 @@ class TwitterClient:
539
584
  mapped.text = f"{fallback['title']}\n\n{pt}" if fallback.get("title") else pt
540
585
  return mapped
541
586
 
542
- def get_replies(self, tweet_id: str) -> list[Tweet]:
587
+ def get_replies(self, tweet_id: str, include_raw: bool = False) -> list[Tweet]:
543
588
  """Fetch the first page of replies to a tweet."""
544
589
  data = self._fetch_tweet_detail(tweet_id)
545
590
  if not data:
@@ -547,10 +592,10 @@ class TwitterClient:
547
592
  instructions = (
548
593
  (data.get("threaded_conversation_with_injections_v2") or {}).get("instructions")
549
594
  )
550
- tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
595
+ tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
551
596
  return [t for t in tweets if t.in_reply_to_status_id == tweet_id]
552
597
 
553
- def get_thread(self, tweet_id: str) -> list[Tweet]:
598
+ def get_thread(self, tweet_id: str, include_raw: bool = False) -> list[Tweet]:
554
599
  """Fetch the full conversation thread for a tweet."""
555
600
  data = self._fetch_tweet_detail(tweet_id)
556
601
  if not data:
@@ -558,7 +603,7 @@ class TwitterClient:
558
603
  instructions = (
559
604
  (data.get("threaded_conversation_with_injections_v2") or {}).get("instructions")
560
605
  )
561
- tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
606
+ tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
562
607
  target = next((t for t in tweets if t.id == tweet_id), None)
563
608
  root_id = (target.conversation_id if target else None) or tweet_id
564
609
  thread = [t for t in tweets if t.conversation_id == root_id]
@@ -620,6 +665,7 @@ class TwitterClient:
620
665
  *,
621
666
  cursor: Optional[str] = None,
622
667
  max_pages: Optional[int] = None,
668
+ include_raw: bool = False,
623
669
  ) -> tuple[list[Tweet], Optional[str]]:
624
670
  """Search for tweets. Returns ``(tweets, next_cursor)``."""
625
671
  features = search_features()
@@ -659,7 +705,7 @@ class TwitterClient:
659
705
  .get("timeline", {})
660
706
  .get("instructions")
661
707
  )
662
- page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
708
+ page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
663
709
  next_cur = extract_cursor_from_instructions(instructions)
664
710
  return page_tweets, next_cur, False, None
665
711
  except Exception as exc:
@@ -675,6 +721,7 @@ class TwitterClient:
675
721
  self,
676
722
  username: Optional[str] = None,
677
723
  count: int = 20,
724
+ include_raw: bool = False,
678
725
  ) -> tuple[list[Tweet], Optional[str]]:
679
726
  """Search for mentions of *username* (defaults to authenticated user)."""
680
727
  if username:
@@ -687,7 +734,7 @@ class TwitterClient:
687
734
  if not user:
688
735
  return [], None
689
736
  q = f"@{user.username}"
690
- return self.search(q, count)
737
+ return self.search(q, count, include_raw=include_raw)
691
738
 
692
739
  # ------------------------------------------------------------------
693
740
  # Posting
@@ -739,23 +786,35 @@ class TwitterClient:
739
786
  TWITTER_API_BASE, headers=headers, content=build_body(qid).encode()
740
787
  )
741
788
  if not r.is_success:
742
- return None
789
+ raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}")
743
790
  data = r.json()
744
791
  errors = data.get("errors") or []
745
792
  if errors:
793
+ msgs = ", ".join(
794
+ (e or {}).get("message") or f"Error {(e or {}).get('code', '?')}"
795
+ for e in errors
796
+ )
746
797
  # Fallback to legacy REST on error code 226 (bot detection)
747
798
  if any((e or {}).get("code") == 226 for e in errors):
748
- return self._post_status_update(variables)
749
- return None
750
- return (
799
+ fallback = self._post_status_update(variables)
800
+ if fallback:
801
+ return fallback
802
+ raise RuntimeError(msgs)
803
+ raise RuntimeError(msgs)
804
+ tweet_id = (
751
805
  (data.get("data") or {})
752
806
  .get("create_tweet", {})
753
807
  .get("tweet_results", {})
754
808
  .get("result", {})
755
809
  .get("rest_id")
756
810
  )
757
- except Exception:
758
- return None
811
+ if not tweet_id:
812
+ raise RuntimeError("Tweet created but no ID returned")
813
+ return tweet_id
814
+ except RuntimeError:
815
+ raise
816
+ except Exception as exc:
817
+ raise RuntimeError(str(exc)) from exc
759
818
 
760
819
  def _post_status_update(self, variables: dict) -> Optional[str]:
761
820
  """Legacy statuses/update.json fallback for tweet creation."""
@@ -930,17 +989,19 @@ class TwitterClient:
930
989
  folder_id: Optional[str] = None,
931
990
  cursor: Optional[str] = None,
932
991
  max_pages: Optional[int] = None,
992
+ include_raw: bool = False,
933
993
  ) -> tuple[list[Tweet], Optional[str]]:
934
994
  """Fetch bookmarked tweets. Returns ``(tweets, next_cursor)``."""
935
995
  if folder_id:
936
- return self._bookmarks_folder(folder_id, count, cursor, max_pages)
937
- return self._bookmarks_main(count, cursor, max_pages)
996
+ return self._bookmarks_folder(folder_id, count, cursor, max_pages, include_raw)
997
+ return self._bookmarks_main(count, cursor, max_pages, include_raw)
938
998
 
939
999
  def _bookmarks_main(
940
1000
  self,
941
1001
  limit: int,
942
1002
  initial_cursor: Optional[str],
943
1003
  max_pages: Optional[int],
1004
+ include_raw: bool = False,
944
1005
  ) -> tuple[list[Tweet], Optional[str]]:
945
1006
  features = bookmarks_features()
946
1007
  qids = list(dict.fromkeys([
@@ -978,7 +1039,7 @@ class TwitterClient:
978
1039
  .get("timeline", {})
979
1040
  .get("instructions")
980
1041
  )
981
- page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
1042
+ page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
982
1043
  next_cur = extract_cursor_from_instructions(instructions)
983
1044
  return page_tweets, next_cur, False, None
984
1045
  except Exception as exc:
@@ -994,6 +1055,7 @@ class TwitterClient:
994
1055
  limit: int,
995
1056
  initial_cursor: Optional[str],
996
1057
  max_pages: Optional[int],
1058
+ include_raw: bool = False,
997
1059
  ) -> tuple[list[Tweet], Optional[str]]:
998
1060
  features = bookmarks_features()
999
1061
  qids = list(dict.fromkeys([
@@ -1028,7 +1090,7 @@ class TwitterClient:
1028
1090
  .get("timeline", {})
1029
1091
  .get("instructions")
1030
1092
  )
1031
- page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
1093
+ page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
1032
1094
  next_cur = extract_cursor_from_instructions(instructions)
1033
1095
  return page_tweets, next_cur, False, None
1034
1096
  except Exception as exc:
@@ -1048,6 +1110,7 @@ class TwitterClient:
1048
1110
  *,
1049
1111
  cursor: Optional[str] = None,
1050
1112
  max_pages: Optional[int] = None,
1113
+ include_raw: bool = False,
1051
1114
  ) -> tuple[list[Tweet], Optional[str]]:
1052
1115
  """Fetch liked tweets for the current user. Returns ``(tweets, next_cursor)``."""
1053
1116
  user = self.get_current_user()
@@ -1088,7 +1151,7 @@ class TwitterClient:
1088
1151
  .get("timeline", {})
1089
1152
  .get("instructions")
1090
1153
  )
1091
- page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
1154
+ page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
1092
1155
  next_cur = extract_cursor_from_instructions(instructions)
1093
1156
  return page_tweets, next_cur, False, None
1094
1157
  except Exception as exc:
@@ -1105,17 +1168,37 @@ class TwitterClient:
1105
1168
  def get_user_tweets(
1106
1169
  self,
1107
1170
  user_id: str,
1108
- count: int = 20,
1171
+ count: Optional[int] = 20,
1109
1172
  *,
1110
1173
  cursor: Optional[str] = None,
1111
1174
  max_pages: Optional[int] = None,
1175
+ include_raw: bool = False,
1176
+ page_delay: float = 0.0,
1177
+ since: Optional[datetime] = None,
1112
1178
  ) -> tuple[list[Tweet], Optional[str]]:
1113
- """Fetch tweets from a user's profile timeline. Returns ``(tweets, next_cursor)``."""
1179
+ """Fetch tweets from a user's profile timeline. Returns ``(tweets, next_cursor)``.
1180
+
1181
+ ``since`` (aware datetime) fetches everything back to that cutoff instead
1182
+ of a fixed ``count``: paging continues until tweets predate it, bounded
1183
+ by ``max_pages`` (default ``_SINCE_DEFAULT_PAGES``). When ``since`` is set
1184
+ ``count`` becomes an optional upper cap (``None`` = no tweet cap).
1185
+ """
1114
1186
  features = user_tweets_features()
1115
1187
  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)
1188
+ if since is not None:
1189
+ # since-mode: date is the goal; max_pages is the safety bound and
1190
+ # count is an optional upper cap on returned tweets.
1191
+ limit = count if count is not None else math.inf
1192
+ effective_max = min(_SINCE_MAX_PAGES, max_pages or _SINCE_DEFAULT_PAGES)
1193
+ else:
1194
+ limit = count if count is not None else _PAGE_SIZE
1195
+ hard_max = 10
1196
+ # +1 page of headroom: a profile page of N entries nets fewer than N
1197
+ # tweets (cursors, who-to-follow, pinned dupes, gated tweets), so allow
1198
+ # one extra page to top up to `count`. The _paginate loop stops as soon
1199
+ # as `count` is reached, so the common case is still 1 request.
1200
+ computed_max = math.ceil(limit / _PAGE_SIZE) + 1
1201
+ effective_max = min(hard_max, max_pages or computed_max)
1119
1202
 
1120
1203
  def fetch_page(page_cursor, page_count):
1121
1204
  variables: dict[str, Any] = {
@@ -1154,14 +1237,16 @@ class TwitterClient:
1154
1237
  msgs = ", ".join(e.get("message", "") for e in errors)
1155
1238
  if not instructions:
1156
1239
  return [], None, False, msgs
1157
- page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
1240
+ page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
1158
1241
  next_cur = extract_cursor_from_instructions(instructions)
1159
1242
  return page_tweets, next_cur, False, None
1160
1243
  except Exception as exc:
1161
1244
  return [], None, False, str(exc)
1162
1245
  return [], None, False, "No query IDs available"
1163
1246
 
1164
- tweets, next_cursor, _ = self._paginate(fetch_page, count, effective_max, cursor)
1247
+ tweets, next_cursor, _ = self._paginate(
1248
+ fetch_page, limit, effective_max, cursor, page_delay=page_delay, since=since,
1249
+ )
1165
1250
  return tweets, next_cursor
1166
1251
 
1167
1252
  # ------------------------------------------------------------------
@@ -1188,7 +1273,9 @@ class TwitterClient:
1188
1273
  cursor: Optional[str] = None
1189
1274
 
1190
1275
  while len(tweets) < count:
1191
- page_count = min(_PAGE_SIZE, count - len(tweets))
1276
+ # Request full pages; non-tweet entries get filtered, so a partial
1277
+ # request under-delivers. Trimmed to `count` after the dedup loop.
1278
+ page_count = _PAGE_SIZE
1192
1279
  had_404 = False
1193
1280
  success = False
1194
1281
  for qid in qids:
@@ -1230,6 +1317,8 @@ class TwitterClient:
1230
1317
  seen.add(t.id)
1231
1318
  tweets.append(t)
1232
1319
  added += 1
1320
+ if len(tweets) >= count:
1321
+ break
1233
1322
  if not new_cursor or new_cursor == cursor or not page_tweets or added == 0:
1234
1323
  return tweets
1235
1324
  cursor = new_cursor
@@ -1456,6 +1545,7 @@ class TwitterClient:
1456
1545
  *,
1457
1546
  cursor: Optional[str] = None,
1458
1547
  max_pages: Optional[int] = None,
1548
+ include_raw: bool = False,
1459
1549
  ) -> tuple[list[Tweet], Optional[str]]:
1460
1550
  """Fetch tweets from a list timeline. Returns ``(tweets, next_cursor)``."""
1461
1551
  features = lists_features()
@@ -1487,7 +1577,7 @@ class TwitterClient:
1487
1577
  .get("timeline", {})
1488
1578
  .get("instructions")
1489
1579
  )
1490
- page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth)
1580
+ page_tweets = parse_tweets_from_instructions(instructions, self._quote_depth, include_raw)
1491
1581
  next_cur = extract_cursor_from_instructions(instructions)
1492
1582
  return page_tweets, next_cur, False, None
1493
1583
  except Exception as exc:
@@ -0,0 +1,109 @@
1
+ """Unit tests for TwitterClient pagination/throttle internals — no network."""
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from bird._models import Author, Tweet
6
+ from bird.client import TwitterClient
7
+
8
+
9
+ def _client():
10
+ return TwitterClient(auth_token="x", ct0="y")
11
+
12
+
13
+ def _tweet(tid: str, dt: datetime, pinned_label: str = "") -> Tweet:
14
+ return Tweet(
15
+ id=tid,
16
+ text=f"tweet {tid}{pinned_label}",
17
+ author=Author(username="alice", name="Alice"),
18
+ created_at=dt.strftime("%a %b %d %H:%M:%S %z %Y"),
19
+ )
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # _paginate: since-mode date filtering
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _make_fetcher(pages):
27
+ """Return a fetch_page(cursor, count) that yields successive pages.
28
+
29
+ `pages` is a list of (tweets, next_cursor).
30
+ """
31
+ state = {"i": 0}
32
+
33
+ def fetch_page(cursor, count):
34
+ i = state["i"]
35
+ if i >= len(pages):
36
+ return [], None, False, None
37
+ tweets, cur = pages[i]
38
+ state["i"] = i + 1
39
+ return tweets, cur, False, None
40
+
41
+ return fetch_page
42
+
43
+
44
+ def test_paginate_since_stops_and_filters():
45
+ now = datetime(2026, 6, 7, tzinfo=timezone.utc)
46
+ cutoff = now - timedelta(days=2)
47
+ # Page 1: all newer than cutoff. Page 2: straddles cutoff (last is older).
48
+ p1 = [_tweet("1", now), _tweet("2", now - timedelta(days=1))]
49
+ p2 = [
50
+ _tweet("3", now - timedelta(days=1, hours=12)),
51
+ _tweet("4", now - timedelta(days=3)),
52
+ ]
53
+ p3 = [_tweet("5", now - timedelta(days=5))] # should never be fetched
54
+ fetch = _make_fetcher([(p1, "c1"), (p2, "c2"), (p3, "c3")])
55
+
56
+ tweets, _, _ = _client()._paginate(fetch, limit=float("inf"), since=cutoff)
57
+ ids = [t.id for t in tweets]
58
+ assert ids == ["1", "2", "3"] # "4" dropped (older), page 3 never fetched
59
+
60
+
61
+ def test_paginate_since_pinned_old_does_not_stop_early():
62
+ now = datetime(2026, 6, 7, tzinfo=timezone.utc)
63
+ cutoff = now - timedelta(days=2)
64
+ # Pinned tweet (old) sits FIRST but the rest of the page is recent.
65
+ pinned = _tweet("pin", now - timedelta(days=400), " [pinned]")
66
+ p1 = [pinned, _tweet("1", now), _tweet("2", now - timedelta(days=1))]
67
+ p2 = [_tweet("3", now - timedelta(days=3))] # crosses cutoff -> stop after this
68
+ fetch = _make_fetcher([(p1, "c1"), (p2, "c2")])
69
+
70
+ tweets, _, _ = _client()._paginate(fetch, limit=float("inf"), since=cutoff)
71
+ ids = [t.id for t in tweets]
72
+ # Pinned dropped (older than cutoff), but page 2 WAS still fetched.
73
+ assert "pin" not in ids
74
+ assert ids == ["1", "2"]
75
+
76
+
77
+ def test_paginate_since_count_caps_result():
78
+ now = datetime(2026, 6, 7, tzinfo=timezone.utc)
79
+ cutoff = now - timedelta(days=30)
80
+ p1 = [_tweet(str(i), now - timedelta(hours=i)) for i in range(20)]
81
+ fetch = _make_fetcher([(p1, "c1"), (p1, "c2")])
82
+ tweets, _, _ = _client()._paginate(fetch, limit=5, since=cutoff)
83
+ assert len(tweets) == 5
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # rate-limit throttle
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def test_throttle_disabled_by_default(monkeypatch):
91
+ slept = []
92
+ monkeypatch.setattr("bird.client.time.sleep", lambda s: slept.append(s))
93
+ c = _client()
94
+ c._throttle()
95
+ c._throttle()
96
+ assert slept == []
97
+
98
+
99
+ def test_throttle_spaces_calls(monkeypatch):
100
+ slept = []
101
+ clock = {"t": 100.0}
102
+ monkeypatch.setattr("bird.client.time.monotonic", lambda: clock["t"])
103
+ monkeypatch.setattr("bird.client.time.sleep", lambda s: slept.append(s))
104
+ c = TwitterClient(auth_token="x", ct0="y", min_request_interval=5.0)
105
+ c._throttle() # first call: last_request_at was 0, so elapsed huge -> no sleep
106
+ assert slept == []
107
+ # Second call immediately after: must wait the full interval.
108
+ c._throttle()
109
+ assert slept and abs(slept[0] - 5.0) < 1e-6
@@ -1,5 +1,7 @@
1
1
  """Unit tests for _utils.py — no network required."""
2
2
 
3
+ from datetime import timezone
4
+
3
5
  import pytest
4
6
  from bird._utils import (
5
7
  extract_bookmark_folder_id,
@@ -8,6 +10,7 @@ from bird._utils import (
8
10
  extract_tweet_id,
9
11
  map_tweet_result,
10
12
  normalize_handle,
13
+ parse_tweet_datetime,
11
14
  parse_tweets_from_instructions,
12
15
  render_content_state,
13
16
  )
@@ -203,6 +206,48 @@ def test_parse_tweets_deduplication():
203
206
  assert len(tweets) == 1
204
207
 
205
208
 
209
+ def test_map_tweet_result_visibility_wrapper():
210
+ # Visibility-gated tweets nest the real tweet under .tweet with no top rest_id.
211
+ inner = _make_raw_tweet()
212
+ wrapped = {"__typename": "TweetWithVisibilityResults", "tweet": inner}
213
+ tweet = map_tweet_result(wrapped)
214
+ assert tweet is not None
215
+ assert tweet.id == "1"
216
+ assert tweet.text == "Hello"
217
+
218
+
219
+ def test_parse_tweet_datetime_valid():
220
+ dt = parse_tweet_datetime("Sun Jun 07 23:11:05 +0000 2026")
221
+ assert dt is not None
222
+ assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 6, 7, 23)
223
+ assert dt.tzinfo is not None
224
+ assert dt.utcoffset().total_seconds() == 0
225
+
226
+
227
+ def test_parse_tweet_datetime_invalid():
228
+ assert parse_tweet_datetime(None) is None
229
+ assert parse_tweet_datetime("") is None
230
+ assert parse_tweet_datetime("not a date") is None
231
+
232
+
233
+ def test_parse_tweet_datetime_is_comparable():
234
+ older = parse_tweet_datetime("Mon Jan 01 00:00:00 +0000 2024")
235
+ newer = parse_tweet_datetime("Sun Jun 07 23:11:05 +0000 2026")
236
+ cutoff = parse_tweet_datetime("Mon Jan 01 00:00:00 +0000 2026")
237
+ assert older < cutoff < newer
238
+ assert cutoff.tzinfo == timezone.utc or cutoff.utcoffset().total_seconds() == 0
239
+
240
+
241
+ def test_parse_tweets_from_instructions_visibility_wrapper():
242
+ # Regression: accounts under visibility gating return all tweets wrapped as
243
+ # TweetWithVisibilityResults; without unwrapping the parser yielded 0 tweets.
244
+ inner = _make_raw_tweet()
245
+ wrapped = {"__typename": "TweetWithVisibilityResults", "tweet": inner}
246
+ tweets = parse_tweets_from_instructions(_instructions_with_tweet(wrapped))
247
+ assert len(tweets) == 1
248
+ assert tweets[0].id == "1"
249
+
250
+
206
251
  # ---------------------------------------------------------------------------
207
252
  # extract_cursor_from_instructions
208
253
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes