birdapi 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bird/__init__.py +16 -0
- bird/_config.py +63 -0
- bird/_constants.py +48 -0
- bird/_features.py +256 -0
- bird/_models.py +92 -0
- bird/_query_ids.py +211 -0
- bird/_utils.py +491 -0
- bird/cli.py +769 -0
- bird/client.py +1702 -0
- birdapi-0.0.1.dist-info/METADATA +207 -0
- birdapi-0.0.1.dist-info/RECORD +14 -0
- birdapi-0.0.1.dist-info/WHEEL +4 -0
- birdapi-0.0.1.dist-info/entry_points.txt +2 -0
- birdapi-0.0.1.dist-info/licenses/LICENSE +21 -0
bird/cli.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""bird CLI — X/Twitter GraphQL client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
# Ensure stdout can handle Unicode on Windows (e.g. cp1252 terminals)
|
|
14
|
+
if sys.stdout and hasattr(sys.stdout, "buffer"):
|
|
15
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
16
|
+
|
|
17
|
+
from .client import TwitterClient
|
|
18
|
+
from ._config import load_credentials, resolve_credentials, save_credentials
|
|
19
|
+
from ._utils import extract_tweet_id, extract_list_id, normalize_handle
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Auth helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def _make_client(
|
|
27
|
+
auth_token: Optional[str],
|
|
28
|
+
ct0: Optional[str],
|
|
29
|
+
timeout: Optional[float],
|
|
30
|
+
) -> TwitterClient:
|
|
31
|
+
tok, csrf = resolve_credentials(auth_token, ct0)
|
|
32
|
+
if not tok or not csrf:
|
|
33
|
+
click.echo(
|
|
34
|
+
"Error: credentials not found.\n"
|
|
35
|
+
"Run bird configure to save your auth_token and ct0.",
|
|
36
|
+
err=True,
|
|
37
|
+
)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
return TwitterClient(tok, csrf, timeout=timeout)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Global options
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@click.group()
|
|
47
|
+
@click.pass_context
|
|
48
|
+
@click.option("--auth-token", envvar=["AUTH_TOKEN", "TWITTER_AUTH_TOKEN"], hidden=True)
|
|
49
|
+
@click.option("--ct0", envvar=["CT0", "TWITTER_CT0"], hidden=True)
|
|
50
|
+
@click.option("--timeout", type=float, default=None, envvar="BIRD_TIMEOUT_MS",
|
|
51
|
+
help="Request timeout in milliseconds.")
|
|
52
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
53
|
+
@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):
|
|
55
|
+
"""bird — fast X/Twitter CLI (cookie auth, no browser extraction)."""
|
|
56
|
+
ctx.ensure_object(dict)
|
|
57
|
+
ctx.obj["auth_token"] = auth_token
|
|
58
|
+
ctx.obj["ct0"] = ct0
|
|
59
|
+
ctx.obj["timeout"] = timeout / 1000 if timeout else None
|
|
60
|
+
ctx.obj["as_json"] = as_json
|
|
61
|
+
ctx.obj["quote_depth"] = quote_depth
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _client(ctx) -> TwitterClient:
|
|
65
|
+
o = ctx.obj
|
|
66
|
+
return _make_client(o["auth_token"], o["ct0"], o["timeout"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Pretty-print helpers
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
import html as _html
|
|
74
|
+
|
|
75
|
+
_SEPARATOR = "\u2500" * 50 # ──────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unescape(text: str) -> str:
|
|
79
|
+
return _html.unescape(text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _format_tweet(tweet) -> str:
|
|
83
|
+
lines: list[str] = []
|
|
84
|
+
|
|
85
|
+
# Header: @username (Full Name):
|
|
86
|
+
lines.append(f"@{tweet.author.username} ({tweet.author.name}):")
|
|
87
|
+
|
|
88
|
+
# Tweet text
|
|
89
|
+
lines.append(_unescape(tweet.text))
|
|
90
|
+
|
|
91
|
+
# Quoted tweet box
|
|
92
|
+
if tweet.quoted_tweet:
|
|
93
|
+
qt = tweet.quoted_tweet
|
|
94
|
+
lines.append(f"\u250c\u2500 QT @{qt.author.username}:")
|
|
95
|
+
for body_line in _unescape(qt.text).splitlines():
|
|
96
|
+
lines.append(f"\u2502 {body_line}")
|
|
97
|
+
if qt.media:
|
|
98
|
+
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}")
|
|
101
|
+
lines.append(f"\u2514\u2500 https://x.com/{qt.author.username}/status/{qt.id}")
|
|
102
|
+
|
|
103
|
+
# Media on the outer tweet
|
|
104
|
+
if tweet.media:
|
|
105
|
+
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}")
|
|
108
|
+
|
|
109
|
+
# Metadata
|
|
110
|
+
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)
|
|
114
|
+
|
|
115
|
+
return "\n".join(lines)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _dump_tweet(tweet, as_json: bool) -> None:
|
|
119
|
+
if as_json:
|
|
120
|
+
click.echo(json.dumps(_tweet_to_dict(tweet), ensure_ascii=False, indent=2))
|
|
121
|
+
else:
|
|
122
|
+
click.echo(_format_tweet(tweet))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _dump_tweets(tweets, as_json: bool) -> None:
|
|
126
|
+
if as_json:
|
|
127
|
+
click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False))
|
|
128
|
+
else:
|
|
129
|
+
for t in tweets:
|
|
130
|
+
click.echo(_format_tweet(t))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _tweet_to_dict(tweet) -> dict:
|
|
134
|
+
d: dict = {
|
|
135
|
+
"id": tweet.id,
|
|
136
|
+
"text": _unescape(tweet.text),
|
|
137
|
+
"createdAt": tweet.created_at,
|
|
138
|
+
"replyCount": tweet.reply_count,
|
|
139
|
+
"retweetCount": tweet.retweet_count,
|
|
140
|
+
"likeCount": tweet.like_count,
|
|
141
|
+
"conversationId": tweet.conversation_id,
|
|
142
|
+
}
|
|
143
|
+
if tweet.in_reply_to_status_id:
|
|
144
|
+
d["inReplyToStatusId"] = tweet.in_reply_to_status_id
|
|
145
|
+
d["author"] = {"username": tweet.author.username, "name": tweet.author.name}
|
|
146
|
+
d["authorId"] = tweet.author_id
|
|
147
|
+
if tweet.quoted_tweet:
|
|
148
|
+
d["quotedTweet"] = _tweet_to_dict(tweet.quoted_tweet)
|
|
149
|
+
if tweet.media:
|
|
150
|
+
d["media"] = [_media_to_dict(m) for m in tweet.media]
|
|
151
|
+
return d
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _media_to_dict(m) -> dict:
|
|
155
|
+
d: dict = {"type": m.type, "url": m.url}
|
|
156
|
+
if m.width is not None:
|
|
157
|
+
d["width"] = m.width
|
|
158
|
+
if m.height is not None:
|
|
159
|
+
d["height"] = m.height
|
|
160
|
+
if m.preview_url is not None:
|
|
161
|
+
d["previewUrl"] = m.preview_url
|
|
162
|
+
return d
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _user_to_dict(user) -> dict:
|
|
166
|
+
return {
|
|
167
|
+
"id": user.id,
|
|
168
|
+
"username": user.username,
|
|
169
|
+
"name": user.name,
|
|
170
|
+
"description": user.description,
|
|
171
|
+
"followersCount": user.followers_count,
|
|
172
|
+
"followingCount": user.following_count,
|
|
173
|
+
"isBlueVerified": user.is_blue_verified,
|
|
174
|
+
"profileImageUrl": user.profile_image_url,
|
|
175
|
+
"createdAt": user.created_at,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# read
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
@main.command()
|
|
184
|
+
@click.argument("tweet_id_or_url")
|
|
185
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
186
|
+
@click.pass_context
|
|
187
|
+
def read(ctx, tweet_id_or_url, as_json):
|
|
188
|
+
"""Fetch and display a tweet by ID or URL."""
|
|
189
|
+
tweet_id = extract_tweet_id(tweet_id_or_url)
|
|
190
|
+
if not tweet_id:
|
|
191
|
+
click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
194
|
+
with _client(ctx) as client:
|
|
195
|
+
tweet = client.get_tweet(tweet_id)
|
|
196
|
+
if not tweet:
|
|
197
|
+
click.echo("Tweet not found.", err=True)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
_dump_tweet(tweet, as_json)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# thread / replies
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
@main.command()
|
|
207
|
+
@click.argument("tweet_id_or_url")
|
|
208
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
209
|
+
@click.pass_context
|
|
210
|
+
def thread(ctx, tweet_id_or_url, as_json):
|
|
211
|
+
"""Show the full conversation thread for a tweet."""
|
|
212
|
+
tweet_id = extract_tweet_id(tweet_id_or_url)
|
|
213
|
+
if not tweet_id:
|
|
214
|
+
click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
217
|
+
with _client(ctx) as client:
|
|
218
|
+
tweets = client.get_thread(tweet_id)
|
|
219
|
+
_dump_tweets(tweets, as_json)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@main.command()
|
|
223
|
+
@click.argument("tweet_id_or_url")
|
|
224
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
225
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
226
|
+
@click.pass_context
|
|
227
|
+
def replies(ctx, tweet_id_or_url, count, as_json):
|
|
228
|
+
"""List replies to a tweet."""
|
|
229
|
+
tweet_id = extract_tweet_id(tweet_id_or_url)
|
|
230
|
+
if not tweet_id:
|
|
231
|
+
click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
234
|
+
with _client(ctx) as client:
|
|
235
|
+
tweets = client.get_replies(tweet_id)
|
|
236
|
+
_dump_tweets(tweets[:count], as_json)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# tweet / reply
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
@main.command(name="tweet")
|
|
244
|
+
@click.argument("text")
|
|
245
|
+
@click.pass_context
|
|
246
|
+
def post_tweet(ctx, text):
|
|
247
|
+
"""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)
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@main.command(name="reply")
|
|
258
|
+
@click.argument("tweet_id_or_url")
|
|
259
|
+
@click.argument("text")
|
|
260
|
+
@click.pass_context
|
|
261
|
+
def post_reply(ctx, tweet_id_or_url, text):
|
|
262
|
+
"""Reply to a tweet."""
|
|
263
|
+
tweet_id = extract_tweet_id(tweet_id_or_url)
|
|
264
|
+
if not tweet_id:
|
|
265
|
+
click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
|
|
266
|
+
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)
|
|
273
|
+
sys.exit(1)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# search / mentions
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
@main.command()
|
|
281
|
+
@click.argument("query")
|
|
282
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
283
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
284
|
+
@click.option("--cursor", default=None)
|
|
285
|
+
@click.option("--max-pages", type=int, default=None)
|
|
286
|
+
@click.pass_context
|
|
287
|
+
def search(ctx, query, count, as_json, cursor, max_pages):
|
|
288
|
+
"""Search for tweets matching a query."""
|
|
289
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
290
|
+
with _client(ctx) as client:
|
|
291
|
+
tweets, next_cursor = client.search(query, count, cursor=cursor, max_pages=max_pages)
|
|
292
|
+
if as_json:
|
|
293
|
+
click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
|
|
294
|
+
else:
|
|
295
|
+
_dump_tweets(tweets, False)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@main.command()
|
|
299
|
+
@click.option("--user", default=None, help="@handle to search mentions for")
|
|
300
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
301
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
302
|
+
@click.pass_context
|
|
303
|
+
def mentions(ctx, user, count, as_json):
|
|
304
|
+
"""Find tweets mentioning a user (defaults to authenticated user)."""
|
|
305
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
306
|
+
with _client(ctx) as client:
|
|
307
|
+
tweets, _ = client.get_mentions(user, count)
|
|
308
|
+
_dump_tweets(tweets, as_json)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# user-tweets
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
@main.command("user-tweets")
|
|
316
|
+
@click.argument("handle")
|
|
317
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
318
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
319
|
+
@click.option("--cursor", default=None)
|
|
320
|
+
@click.pass_context
|
|
321
|
+
def user_tweets(ctx, handle, count, as_json, cursor):
|
|
322
|
+
"""Get tweets from a user's profile timeline."""
|
|
323
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
324
|
+
norm = normalize_handle(handle)
|
|
325
|
+
if not norm:
|
|
326
|
+
click.echo(f"Invalid handle: {handle!r}", err=True)
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
with _client(ctx) as client:
|
|
329
|
+
user = client.get_user_id_by_username(norm)
|
|
330
|
+
if not user:
|
|
331
|
+
click.echo(f"User @{norm} not found.", err=True)
|
|
332
|
+
sys.exit(1)
|
|
333
|
+
tweets, next_cursor = client.get_user_tweets(user.id, count, cursor=cursor)
|
|
334
|
+
if as_json:
|
|
335
|
+
click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
|
|
336
|
+
else:
|
|
337
|
+
_dump_tweets(tweets, False)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# bookmarks / unbookmark
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
@main.command()
|
|
345
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
346
|
+
@click.option("--folder-id", default=None)
|
|
347
|
+
@click.option("--all", "fetch_all", is_flag=True)
|
|
348
|
+
@click.option("--max-pages", type=int, default=None)
|
|
349
|
+
@click.option("--cursor", default=None)
|
|
350
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
351
|
+
@click.pass_context
|
|
352
|
+
def bookmarks(ctx, count, folder_id, fetch_all, max_pages, cursor, as_json):
|
|
353
|
+
"""List bookmarked tweets."""
|
|
354
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
355
|
+
limit = -1 if fetch_all else count
|
|
356
|
+
with _client(ctx) as client:
|
|
357
|
+
tweets, next_cursor = client.get_bookmarks(
|
|
358
|
+
limit, folder_id=folder_id, cursor=cursor, max_pages=max_pages
|
|
359
|
+
)
|
|
360
|
+
if as_json:
|
|
361
|
+
click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
|
|
362
|
+
else:
|
|
363
|
+
_dump_tweets(tweets, False)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@main.command()
|
|
367
|
+
@click.argument("tweet_ids_or_urls", nargs=-1, required=True)
|
|
368
|
+
@click.pass_context
|
|
369
|
+
def unbookmark(ctx, tweet_ids_or_urls):
|
|
370
|
+
"""Remove one or more bookmarks by tweet ID or URL."""
|
|
371
|
+
with _client(ctx) as client:
|
|
372
|
+
for val in tweet_ids_or_urls:
|
|
373
|
+
tweet_id = extract_tweet_id(val)
|
|
374
|
+
if not tweet_id:
|
|
375
|
+
click.echo(f"Cannot parse ID from {val!r}", err=True)
|
|
376
|
+
continue
|
|
377
|
+
ok = client.unbookmark(tweet_id)
|
|
378
|
+
status = "Removed" if ok else "Failed to remove"
|
|
379
|
+
click.echo(f"{status}: {tweet_id}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
# likes
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
@main.command()
|
|
387
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
388
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
389
|
+
@click.option("--cursor", default=None)
|
|
390
|
+
@click.pass_context
|
|
391
|
+
def likes(ctx, count, as_json, cursor):
|
|
392
|
+
"""List liked tweets."""
|
|
393
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
394
|
+
with _client(ctx) as client:
|
|
395
|
+
tweets, next_cursor = client.get_likes(count, cursor=cursor)
|
|
396
|
+
if as_json:
|
|
397
|
+
click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
|
|
398
|
+
else:
|
|
399
|
+
_dump_tweets(tweets, False)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
# home
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
@main.command()
|
|
407
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
408
|
+
@click.option("--following", is_flag=True, help="Show Following (chronological) feed")
|
|
409
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
410
|
+
@click.pass_context
|
|
411
|
+
def home(ctx, count, following, as_json):
|
|
412
|
+
"""Fetch home timeline (For You or Following feed)."""
|
|
413
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
414
|
+
with _client(ctx) as client:
|
|
415
|
+
if following:
|
|
416
|
+
tweets = client.get_home_latest_timeline(count)
|
|
417
|
+
else:
|
|
418
|
+
tweets = client.get_home_timeline(count)
|
|
419
|
+
_dump_tweets(tweets, as_json)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# following / followers
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
@main.command()
|
|
427
|
+
@click.option("--user", default=None, help="User ID to look up (defaults to self)")
|
|
428
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
429
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
430
|
+
@click.option("--cursor", default=None)
|
|
431
|
+
@click.pass_context
|
|
432
|
+
def following(ctx, user, count, as_json, cursor):
|
|
433
|
+
"""List users the authenticated user (or --user) follows."""
|
|
434
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
435
|
+
with _client(ctx) as client:
|
|
436
|
+
if user:
|
|
437
|
+
uid = user
|
|
438
|
+
else:
|
|
439
|
+
me = client.get_current_user()
|
|
440
|
+
if not me:
|
|
441
|
+
click.echo("Could not determine current user.", err=True)
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
uid = me.id
|
|
444
|
+
users, next_cursor = client.get_following(uid, count, cursor=cursor)
|
|
445
|
+
if as_json:
|
|
446
|
+
click.echo(json.dumps({"users": [_user_to_dict(u) for u in users], "nextCursor": next_cursor}, ensure_ascii=False))
|
|
447
|
+
else:
|
|
448
|
+
for u in users:
|
|
449
|
+
click.echo(f"@{u.username} — {u.name}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@main.command()
|
|
453
|
+
@click.option("--user", default=None, help="User ID to look up (defaults to self)")
|
|
454
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
455
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
456
|
+
@click.option("--cursor", default=None)
|
|
457
|
+
@click.pass_context
|
|
458
|
+
def followers(ctx, user, count, as_json, cursor):
|
|
459
|
+
"""List users that follow the authenticated user (or --user)."""
|
|
460
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
461
|
+
with _client(ctx) as client:
|
|
462
|
+
if user:
|
|
463
|
+
uid = user
|
|
464
|
+
else:
|
|
465
|
+
me = client.get_current_user()
|
|
466
|
+
if not me:
|
|
467
|
+
click.echo("Could not determine current user.", err=True)
|
|
468
|
+
sys.exit(1)
|
|
469
|
+
uid = me.id
|
|
470
|
+
users, next_cursor = client.get_followers(uid, count, cursor=cursor)
|
|
471
|
+
if as_json:
|
|
472
|
+
click.echo(json.dumps({"users": [_user_to_dict(u) for u in users], "nextCursor": next_cursor}, ensure_ascii=False))
|
|
473
|
+
else:
|
|
474
|
+
for u in users:
|
|
475
|
+
click.echo(f"@{u.username} — {u.name}")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
# lists / list-timeline
|
|
480
|
+
# ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
@main.command("lists")
|
|
483
|
+
@click.option("--member-of", is_flag=True, help="Show lists you're a member of")
|
|
484
|
+
@click.option("-n", "--count", default=100, show_default=True)
|
|
485
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
486
|
+
@click.pass_context
|
|
487
|
+
def list_lists(ctx, member_of, count, as_json):
|
|
488
|
+
"""List your owned lists or memberships."""
|
|
489
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
490
|
+
with _client(ctx) as client:
|
|
491
|
+
if member_of:
|
|
492
|
+
lst = client.get_list_memberships(count)
|
|
493
|
+
else:
|
|
494
|
+
lst = client.get_owned_lists(count)
|
|
495
|
+
if as_json:
|
|
496
|
+
click.echo(json.dumps([
|
|
497
|
+
{"id": l.id, "name": l.name, "memberCount": l.member_count, "isPrivate": l.is_private}
|
|
498
|
+
for l in lst
|
|
499
|
+
], ensure_ascii=False))
|
|
500
|
+
else:
|
|
501
|
+
for l in lst:
|
|
502
|
+
priv = " (private)" if l.is_private else ""
|
|
503
|
+
click.echo(f"[{l.id}] {l.name}{priv} — {l.member_count or '?'} members")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@main.command("list-timeline")
|
|
507
|
+
@click.argument("list_id_or_url")
|
|
508
|
+
@click.option("-n", "--count", default=20, show_default=True)
|
|
509
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
510
|
+
@click.option("--cursor", default=None)
|
|
511
|
+
@click.option("--max-pages", type=int, default=None)
|
|
512
|
+
@click.pass_context
|
|
513
|
+
def list_timeline(ctx, list_id_or_url, count, as_json, cursor, max_pages):
|
|
514
|
+
"""Get tweets from a list timeline."""
|
|
515
|
+
list_id = extract_list_id(list_id_or_url)
|
|
516
|
+
if not list_id:
|
|
517
|
+
click.echo(f"Cannot parse list ID from {list_id_or_url!r}", err=True)
|
|
518
|
+
sys.exit(1)
|
|
519
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
520
|
+
with _client(ctx) as client:
|
|
521
|
+
tweets, next_cursor = client.get_list_timeline(list_id, count, cursor=cursor, max_pages=max_pages)
|
|
522
|
+
if as_json:
|
|
523
|
+
click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
|
|
524
|
+
else:
|
|
525
|
+
_dump_tweets(tweets, False)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
# news / trending
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
@main.command()
|
|
533
|
+
@click.option("-n", "--count", default=10, show_default=True)
|
|
534
|
+
@click.option("--ai-only", is_flag=True)
|
|
535
|
+
@click.option("--with-tweets", is_flag=True)
|
|
536
|
+
@click.option("--tweets-per-item", type=int, default=5, show_default=True)
|
|
537
|
+
@click.option("--for-you", "tab_for_you", is_flag=True)
|
|
538
|
+
@click.option("--news-only", "tab_news", is_flag=True)
|
|
539
|
+
@click.option("--sports", "tab_sports", is_flag=True)
|
|
540
|
+
@click.option("--entertainment", "tab_entertainment", is_flag=True)
|
|
541
|
+
@click.option("--trending-only", "tab_trending", is_flag=True)
|
|
542
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
543
|
+
@click.pass_context
|
|
544
|
+
def news(ctx, count, ai_only, with_tweets, tweets_per_item,
|
|
545
|
+
tab_for_you, tab_news, tab_sports, tab_entertainment, tab_trending, as_json):
|
|
546
|
+
"""Fetch news and trending topics from X's Explore tabs."""
|
|
547
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
548
|
+
tabs: list[str] = []
|
|
549
|
+
if tab_for_you:
|
|
550
|
+
tabs.append("forYou")
|
|
551
|
+
if tab_news:
|
|
552
|
+
tabs.append("news")
|
|
553
|
+
if tab_sports:
|
|
554
|
+
tabs.append("sports")
|
|
555
|
+
if tab_entertainment:
|
|
556
|
+
tabs.append("entertainment")
|
|
557
|
+
if tab_trending:
|
|
558
|
+
tabs.append("trending")
|
|
559
|
+
if not tabs:
|
|
560
|
+
tabs = ["forYou", "news", "sports", "entertainment"]
|
|
561
|
+
with _client(ctx) as client:
|
|
562
|
+
items = client.get_news(
|
|
563
|
+
count,
|
|
564
|
+
ai_only=ai_only,
|
|
565
|
+
with_tweets=with_tweets,
|
|
566
|
+
tweets_per_item=tweets_per_item,
|
|
567
|
+
tabs=tabs,
|
|
568
|
+
)
|
|
569
|
+
if as_json:
|
|
570
|
+
def _item_dict(item):
|
|
571
|
+
d = {
|
|
572
|
+
"id": item.id,
|
|
573
|
+
"headline": item.headline,
|
|
574
|
+
"category": item.category,
|
|
575
|
+
"timeAgo": item.time_ago,
|
|
576
|
+
"postCount": item.post_count,
|
|
577
|
+
"description": item.description,
|
|
578
|
+
"url": item.url,
|
|
579
|
+
}
|
|
580
|
+
if item.tweets:
|
|
581
|
+
d["tweets"] = [_tweet_to_dict(t) for t in item.tweets]
|
|
582
|
+
return d
|
|
583
|
+
click.echo(json.dumps([_item_dict(i) for i in items], ensure_ascii=False))
|
|
584
|
+
else:
|
|
585
|
+
for item in items:
|
|
586
|
+
parts = [item.headline]
|
|
587
|
+
if item.category:
|
|
588
|
+
parts.append(f"[{item.category}]")
|
|
589
|
+
if item.time_ago:
|
|
590
|
+
parts.append(item.time_ago)
|
|
591
|
+
if item.post_count:
|
|
592
|
+
parts.append(f"{item.post_count:,} posts")
|
|
593
|
+
click.echo(" ".join(parts))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@main.command()
|
|
597
|
+
@click.option("-n", "--count", default=10, show_default=True)
|
|
598
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
599
|
+
@click.pass_context
|
|
600
|
+
def trending(ctx, count, as_json):
|
|
601
|
+
"""Alias for news --trending-only."""
|
|
602
|
+
ctx.invoke(news, count=count, ai_only=False, with_tweets=False, tweets_per_item=5,
|
|
603
|
+
tab_for_you=False, tab_news=False, tab_sports=False,
|
|
604
|
+
tab_entertainment=False, tab_trending=True, as_json=as_json)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# about / whoami / check
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
@main.command()
|
|
612
|
+
@click.argument("handle")
|
|
613
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
614
|
+
@click.pass_context
|
|
615
|
+
def about(ctx, handle, as_json):
|
|
616
|
+
"""Get 'About this account' information for a user."""
|
|
617
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
618
|
+
with _client(ctx) as client:
|
|
619
|
+
profile = client.get_user_about_account(handle)
|
|
620
|
+
if not profile:
|
|
621
|
+
click.echo("No about information found.", err=True)
|
|
622
|
+
sys.exit(1)
|
|
623
|
+
if as_json:
|
|
624
|
+
click.echo(json.dumps({
|
|
625
|
+
"accountBasedIn": profile.account_based_in,
|
|
626
|
+
"source": profile.source,
|
|
627
|
+
"createdCountryAccurate": profile.created_country_accurate,
|
|
628
|
+
"locationAccurate": profile.location_accurate,
|
|
629
|
+
"learnMoreUrl": profile.learn_more_url,
|
|
630
|
+
}, ensure_ascii=False))
|
|
631
|
+
else:
|
|
632
|
+
if profile.account_based_in:
|
|
633
|
+
click.echo(f"Based in: {profile.account_based_in}")
|
|
634
|
+
if profile.source:
|
|
635
|
+
click.echo(f"Source: {profile.source}")
|
|
636
|
+
if profile.created_country_accurate:
|
|
637
|
+
click.echo(f"Created in: {profile.created_country_accurate}")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@main.command()
|
|
641
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
642
|
+
@click.pass_context
|
|
643
|
+
def whoami(ctx, as_json):
|
|
644
|
+
"""Print which X account your cookies belong to."""
|
|
645
|
+
as_json = as_json or ctx.obj.get("as_json")
|
|
646
|
+
with _client(ctx) as client:
|
|
647
|
+
user = client.get_current_user()
|
|
648
|
+
if not user:
|
|
649
|
+
click.echo("Could not determine current user.", err=True)
|
|
650
|
+
sys.exit(1)
|
|
651
|
+
if as_json:
|
|
652
|
+
click.echo(json.dumps(_user_to_dict(user), ensure_ascii=False))
|
|
653
|
+
else:
|
|
654
|
+
click.echo(f"@{user.username} ({user.name}) — id: {user.id}")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@main.command()
|
|
658
|
+
@click.pass_context
|
|
659
|
+
def check(ctx):
|
|
660
|
+
"""Show which credentials are available and where they came from."""
|
|
661
|
+
import os as _os
|
|
662
|
+
o = ctx.obj
|
|
663
|
+
saved = load_credentials()
|
|
664
|
+
|
|
665
|
+
sources: dict[str, str] = {}
|
|
666
|
+
for key, flag_val, env_keys, saved_key in [
|
|
667
|
+
("auth_token", o.get("auth_token"), ["AUTH_TOKEN", "TWITTER_AUTH_TOKEN"], "auth_token"),
|
|
668
|
+
("ct0", o.get("ct0"), ["CT0", "TWITTER_CT0"], "ct0"),
|
|
669
|
+
]:
|
|
670
|
+
if flag_val:
|
|
671
|
+
sources[key] = "flag"
|
|
672
|
+
elif any(_os.environ.get(e) for e in env_keys):
|
|
673
|
+
sources[key] = "env"
|
|
674
|
+
elif saved.get(saved_key):
|
|
675
|
+
sources[key] = "credentials file"
|
|
676
|
+
else:
|
|
677
|
+
sources[key] = "NOT SET"
|
|
678
|
+
|
|
679
|
+
for key, source in sources.items():
|
|
680
|
+
click.echo(f"{key:<12} {source}")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@main.command()
|
|
684
|
+
def configure():
|
|
685
|
+
"""Interactively save X/Twitter credentials (auth_token and ct0).
|
|
686
|
+
|
|
687
|
+
Credentials are stored in ~/.config/bird/credentials.json and loaded
|
|
688
|
+
automatically by all commands.
|
|
689
|
+
|
|
690
|
+
\b
|
|
691
|
+
Where to find these values:
|
|
692
|
+
1. Log in to x.com in your browser
|
|
693
|
+
2. Open DevTools -> Application -> Cookies -> https://x.com
|
|
694
|
+
3. Copy the values of auth_token and ct0
|
|
695
|
+
"""
|
|
696
|
+
import sys
|
|
697
|
+
|
|
698
|
+
saved = load_credentials()
|
|
699
|
+
|
|
700
|
+
print("Configure bird credentials\n", flush=True)
|
|
701
|
+
print("Where to find these: x.com DevTools -> Application -> Cookies -> https://x.com\n", flush=True)
|
|
702
|
+
|
|
703
|
+
def _read(label: str, saved_key: str) -> str:
|
|
704
|
+
current = saved.get(saved_key, "")
|
|
705
|
+
hint = f" [{current[:8]}...] (Enter to keep)" if current else ""
|
|
706
|
+
print(f"{label}{hint}: ", end="", flush=True)
|
|
707
|
+
try:
|
|
708
|
+
value = sys.stdin.readline().strip()
|
|
709
|
+
except (EOFError, KeyboardInterrupt):
|
|
710
|
+
print()
|
|
711
|
+
sys.exit(0)
|
|
712
|
+
return value or current
|
|
713
|
+
|
|
714
|
+
auth_token = _read("auth_token", "auth_token")
|
|
715
|
+
ct0 = _read("ct0", "ct0")
|
|
716
|
+
|
|
717
|
+
if not auth_token or not ct0:
|
|
718
|
+
print("Aborted — both values are required.", flush=True)
|
|
719
|
+
sys.exit(1)
|
|
720
|
+
|
|
721
|
+
print("\nValidating credentials...", flush=True)
|
|
722
|
+
try:
|
|
723
|
+
client = TwitterClient(auth_token, ct0, timeout=15)
|
|
724
|
+
user = client.get_current_user()
|
|
725
|
+
client.close()
|
|
726
|
+
except Exception as exc:
|
|
727
|
+
print(f"Error connecting to X: {exc}", flush=True)
|
|
728
|
+
sys.exit(1)
|
|
729
|
+
|
|
730
|
+
if not user:
|
|
731
|
+
print("Could not verify credentials — check your auth_token and ct0.", flush=True)
|
|
732
|
+
sys.exit(1)
|
|
733
|
+
|
|
734
|
+
path = save_credentials(auth_token, ct0)
|
|
735
|
+
print(f"Authenticated as @{user.username} ({user.name})", flush=True)
|
|
736
|
+
print(f"Credentials saved to {path}", flush=True)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# ---------------------------------------------------------------------------
|
|
740
|
+
# query-ids
|
|
741
|
+
# ---------------------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
@main.command("query-ids")
|
|
744
|
+
@click.option("--fresh", is_flag=True, help="Force-refresh the cache from x.com bundles")
|
|
745
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
746
|
+
@click.pass_context
|
|
747
|
+
def query_ids_cmd(ctx, fresh, as_json):
|
|
748
|
+
"""Inspect or refresh the cached GraphQL query IDs."""
|
|
749
|
+
from ._query_ids import query_id_store
|
|
750
|
+
if fresh:
|
|
751
|
+
from ._constants import FALLBACK_QUERY_IDS
|
|
752
|
+
query_id_store.refresh(list(FALLBACK_QUERY_IDS.keys()), force=True)
|
|
753
|
+
click.echo("Query IDs refreshed.")
|
|
754
|
+
info = query_id_store.info()
|
|
755
|
+
if as_json:
|
|
756
|
+
click.echo(json.dumps(info, ensure_ascii=False))
|
|
757
|
+
else:
|
|
758
|
+
cached = info.get("cached", False)
|
|
759
|
+
click.echo(f"Cache path: {info['cachePath']}")
|
|
760
|
+
click.echo(f"Cached: {cached}")
|
|
761
|
+
if cached:
|
|
762
|
+
click.echo(f"Age: {info.get('ageSeconds', '?')}s (TTL {info.get('ttl', '?')}s)")
|
|
763
|
+
click.echo(f"Fresh: {info.get('fresh', '?')}")
|
|
764
|
+
ids = info.get("ids") or {}
|
|
765
|
+
click.echo(f"Operations: {len(ids)} cached")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
if __name__ == "__main__":
|
|
769
|
+
main()
|