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.
- {birdapi-0.0.1 → birdapi-0.0.2}/PKG-INFO +1 -1
- {birdapi-0.0.1 → birdapi-0.0.2}/pyproject.toml +1 -1
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/_utils.py +22 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/cli.py +270 -79
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/client.py +119 -29
- birdapi-0.0.2/tests/test_client.py +109 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/tests/test_utils.py +45 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/.gitattributes +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/.github/workflows/workflow.yml +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/.gitignore +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/CLAUDE.md +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/LICENSE +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/README.md +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/__init__.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/_config.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/_constants.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/_features.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/_models.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/src/bird/_query_ids.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.2}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: birdapi
|
|
3
|
-
Version: 0.0.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
click.echo("Failed to post tweet
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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],
|
|
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(
|
|
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],
|
|
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],
|
|
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],
|
|
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,
|
|
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],
|
|
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.
|
|
637
|
-
click.echo(f"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
File without changes
|