twitter-cli 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """twitter-cli: A CLI for Twitter/X."""
2
+
3
+ __version__ = "0.1.0"
twitter_cli/auth.py ADDED
@@ -0,0 +1,193 @@
1
+ """Cookie authentication for Twitter/X.
2
+
3
+ Supports:
4
+ 1. Environment variables: TWITTER_AUTH_TOKEN + TWITTER_CT0
5
+ 2. Auto-extract from browser via browser-cookie3 (subprocess)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import ssl
14
+ import subprocess
15
+ import sys
16
+ import urllib.error
17
+ import urllib.request
18
+ from typing import Dict, Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Public bearer token (same as in client.py)
23
+ _BEARER_TOKEN = (
24
+ "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs"
25
+ "%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
26
+ )
27
+
28
+
29
+ def load_from_env() -> Optional[Dict[str, str]]:
30
+ """Load cookies from environment variables."""
31
+ auth_token = os.environ.get("TWITTER_AUTH_TOKEN", "")
32
+ ct0 = os.environ.get("TWITTER_CT0", "")
33
+ if auth_token and ct0:
34
+ return {"auth_token": auth_token, "ct0": ct0}
35
+ return None
36
+
37
+
38
+ def verify_cookies(auth_token, ct0):
39
+ # type: (str, str) -> Dict[str, Any]
40
+ """Verify cookies by calling a Twitter API endpoint.
41
+
42
+ Tries multiple endpoints. Only raises on clear auth failures (401/403).
43
+ For other errors (404, network), returns empty dict (proceed without verification).
44
+ """
45
+ # Endpoints to try, in order of preference
46
+ urls = [
47
+ "https://api.x.com/1.1/account/verify_credentials.json",
48
+ "https://x.com/i/api/1.1/account/settings.json",
49
+ ]
50
+
51
+ headers = {
52
+ "Authorization": "Bearer %s" % _BEARER_TOKEN,
53
+ "Cookie": "auth_token=%s; ct0=%s" % (auth_token, ct0),
54
+ "X-Csrf-Token": ct0,
55
+ "X-Twitter-Active-User": "yes",
56
+ "X-Twitter-Auth-Type": "OAuth2Session",
57
+ "User-Agent": (
58
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
59
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
60
+ "Chrome/131.0.0.0 Safari/537.36"
61
+ ),
62
+ }
63
+
64
+ for url in urls:
65
+ req = urllib.request.Request(url)
66
+ for k, v in headers.items():
67
+ req.add_header(k, v)
68
+
69
+ ctx = ssl.create_default_context()
70
+ try:
71
+ with urllib.request.urlopen(req, context=ctx, timeout=3) as resp:
72
+ data = json.loads(resp.read().decode("utf-8"))
73
+ return {"screen_name": data.get("screen_name", "")}
74
+ except urllib.error.HTTPError as e:
75
+ if e.code in (401, 403):
76
+ raise RuntimeError(
77
+ "Cookie expired or invalid (HTTP %d). Please re-login to x.com in your browser." % e.code
78
+ )
79
+ # 404 or other — try next endpoint
80
+ logger.debug("Verification endpoint %s returned HTTP %d, trying next...", url, e.code)
81
+ continue
82
+ except Exception as e:
83
+ logger.debug("Verification endpoint %s failed: %s", url, e)
84
+ continue
85
+
86
+ # All endpoints failed with non-auth errors — proceed without verification
87
+ logger.info("Cookie verification skipped (no working endpoint), will verify on first API call")
88
+ return {}
89
+
90
+
91
+ def extract_from_browser() -> Optional[Dict[str, str]]:
92
+ """Auto-extract cookies from local browser using browser-cookie3.
93
+
94
+ Tries browsers in order: Chrome -> Edge -> Firefox -> Brave.
95
+ Runs in a subprocess to avoid SQLite database lock issues when the
96
+ browser is running.
97
+ """
98
+ extract_script = '''
99
+ import json, sys
100
+ try:
101
+ import browser_cookie3
102
+ except ImportError:
103
+ print(json.dumps({"error": "browser-cookie3 not installed"}))
104
+ sys.exit(1)
105
+
106
+ browsers = [
107
+ ("chrome", browser_cookie3.chrome),
108
+ ("edge", browser_cookie3.edge),
109
+ ("firefox", browser_cookie3.firefox),
110
+ ("brave", browser_cookie3.brave),
111
+ ]
112
+
113
+ for name, fn in browsers:
114
+ try:
115
+ jar = fn()
116
+ except Exception:
117
+ continue
118
+ result = {}
119
+ for cookie in jar:
120
+ domain = cookie.domain or ""
121
+ if domain.endswith(".x.com") or domain.endswith(".twitter.com") or domain in ("x.com", "twitter.com", ".x.com", ".twitter.com"):
122
+ if cookie.name == "auth_token":
123
+ result["auth_token"] = cookie.value
124
+ elif cookie.name == "ct0":
125
+ result["ct0"] = cookie.value
126
+ if "auth_token" in result and "ct0" in result:
127
+ result["browser"] = name
128
+ print(json.dumps(result))
129
+ sys.exit(0)
130
+
131
+ print(json.dumps({"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com."}))
132
+ sys.exit(1)
133
+ '''
134
+
135
+ try:
136
+ result = subprocess.run(
137
+ [sys.executable, "-c", extract_script],
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=15,
141
+ )
142
+ output = result.stdout.strip()
143
+ if not output:
144
+ stderr = result.stderr.strip()
145
+ if stderr:
146
+ logger.debug("Cookie extraction stderr from current env: %s", stderr[:300])
147
+ # Maybe browser-cookie3 not installed, try with uv.
148
+ result2 = subprocess.run(
149
+ ["uv", "run", "--with", "browser-cookie3", "python3", "-c", extract_script],
150
+ capture_output=True,
151
+ text=True,
152
+ timeout=30,
153
+ )
154
+ output = result2.stdout.strip()
155
+ if not output:
156
+ logger.debug("Cookie extraction stderr from uv fallback: %s", result2.stderr.strip()[:300])
157
+ return None
158
+
159
+ data = json.loads(output)
160
+ if "error" in data:
161
+ return None
162
+ logger.info("Found cookies in %s", data.get("browser", "unknown"))
163
+ return {"auth_token": data["auth_token"], "ct0": data["ct0"]}
164
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError):
165
+ return None
166
+
167
+
168
+ def get_cookies() -> Dict[str, str]:
169
+ """Get Twitter cookies. Priority: env vars -> browser extraction (Chrome/Edge/Firefox/Brave).
170
+
171
+ Raises RuntimeError if no cookies found.
172
+ """
173
+ cookies = None # type: Optional[Dict[str, str]]
174
+
175
+ # 1. Try environment variables
176
+ cookies = load_from_env()
177
+ if cookies:
178
+ logger.info("Loaded cookies from environment variables")
179
+
180
+ # 2. Try browser extraction (auto-detect)
181
+ if not cookies:
182
+ cookies = extract_from_browser()
183
+
184
+ if not cookies:
185
+ raise RuntimeError(
186
+ "No Twitter cookies found.\n"
187
+ "Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n"
188
+ "Option 2: Make sure you are logged into x.com in your browser (Chrome/Edge/Firefox/Brave)"
189
+ )
190
+
191
+ # Verify only for explicit auth failures; transient endpoint issues are tolerated.
192
+ verify_cookies(cookies["auth_token"], cookies["ct0"])
193
+ return cookies
twitter_cli/cli.py ADDED
@@ -0,0 +1,245 @@
1
+ """CLI entry point for twitter-cli.
2
+
3
+ Usage:
4
+ twitter feed # fetch home timeline (For You)
5
+ twitter feed -t following # fetch following feed
6
+ twitter feed --max 50 # custom fetch count
7
+ twitter feed --filter # enable score-based filtering
8
+ twitter feed --json # JSON output
9
+ twitter favorite # fetch bookmarks
10
+ twitter feed --input tweets.json # load existing data
11
+ twitter feed --output out.json # save filtered tweets
12
+ twitter user elonmusk # view user profile
13
+ twitter user-posts elonmusk # list user tweets
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ import click
24
+ from rich.console import Console
25
+
26
+ from . import __version__
27
+ from .auth import get_cookies
28
+ from .client import TwitterClient
29
+ from .config import load_config
30
+ from .filter import filter_tweets
31
+ from .formatter import print_filter_stats, print_tweet_table, print_user_profile
32
+ from .serialization import tweets_from_json, tweets_to_json
33
+
34
+
35
+ console = Console()
36
+ FEED_TYPES = ["for-you", "following"]
37
+
38
+
39
+ def _setup_logging(verbose):
40
+ # type: (bool) -> None
41
+ level = logging.DEBUG if verbose else logging.WARNING
42
+ logging.basicConfig(
43
+ level=level,
44
+ format="%(levelname)s %(name)s: %(message)s",
45
+ stream=sys.stderr,
46
+ )
47
+
48
+
49
+ def _load_tweets_from_json(path):
50
+ # type: (str) -> List[Tweet]
51
+ """Load tweets from a JSON file (previously exported)."""
52
+ file_path = Path(path)
53
+ if not file_path.exists():
54
+ raise RuntimeError("Input file not found: %s" % path)
55
+
56
+ try:
57
+ raw = file_path.read_text(encoding="utf-8")
58
+ return tweets_from_json(raw)
59
+ except (ValueError, OSError) as exc:
60
+ raise RuntimeError("Invalid tweet JSON file %s: %s" % (path, exc))
61
+
62
+
63
+ def _get_client():
64
+ # type: () -> TwitterClient
65
+ """Create an authenticated API client."""
66
+ console.print("\n🔐 Getting Twitter cookies...")
67
+ try:
68
+ cookies = get_cookies()
69
+ except RuntimeError as exc:
70
+ raise RuntimeError(str(exc))
71
+ return TwitterClient(cookies["auth_token"], cookies["ct0"])
72
+
73
+
74
+ def _resolve_fetch_count(max_count, configured):
75
+ # type: (Optional[int], int) -> int
76
+ """Resolve fetch count with bounds checks."""
77
+ if max_count is not None:
78
+ if max_count <= 0:
79
+ raise RuntimeError("--max must be greater than 0")
80
+ return max_count
81
+ return max(configured, 1)
82
+
83
+
84
+ def _apply_filter(tweets, do_filter, config):
85
+ # type: (List[Tweet], bool, dict) -> List[Tweet]
86
+ """Optionally apply tweet filtering."""
87
+ if not do_filter:
88
+ return tweets
89
+ filter_config = config.get("filter", {})
90
+ original_count = len(tweets)
91
+ filtered = filter_tweets(tweets, filter_config)
92
+ print_filter_stats(original_count, filtered, console)
93
+ console.print()
94
+ return filtered
95
+
96
+
97
+ @click.group()
98
+ @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
99
+ @click.version_option(version=__version__)
100
+ def cli(verbose):
101
+ # type: (bool) -> None
102
+ """twitter — Twitter/X CLI tool 🐦"""
103
+ _setup_logging(verbose)
104
+
105
+
106
+ @cli.command()
107
+ @click.option(
108
+ "--type",
109
+ "-t",
110
+ "feed_type",
111
+ type=click.Choice(FEED_TYPES),
112
+ default="for-you",
113
+ help="Feed type: for-you (algorithmic) or following (chronological).",
114
+ )
115
+ @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
116
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
117
+ @click.option("--input", "-i", "input_file", type=str, default=None, help="Load tweets from JSON file.")
118
+ @click.option("--output", "-o", "output_file", type=str, default=None, help="Save filtered tweets to JSON file.")
119
+ @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
120
+ def feed(feed_type, max_count, as_json, input_file, output_file, do_filter):
121
+ # type: (str, Optional[int], bool, Optional[str], Optional[str], bool) -> None
122
+ """Fetch home timeline with optional filtering."""
123
+ config = load_config()
124
+ try:
125
+ if input_file:
126
+ console.print("📂 Loading tweets from %s..." % input_file)
127
+ tweets = _load_tweets_from_json(input_file)
128
+ console.print(" Loaded %d tweets" % len(tweets))
129
+ else:
130
+ fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50))
131
+ client = _get_client()
132
+ label = "following feed" if feed_type == "following" else "home timeline"
133
+ console.print("📡 Fetching %s (%d tweets)...\n" % (label, fetch_count))
134
+ start = time.time()
135
+ if feed_type == "following":
136
+ tweets = client.fetch_following_feed(fetch_count)
137
+ else:
138
+ tweets = client.fetch_home_timeline(fetch_count)
139
+ elapsed = time.time() - start
140
+ console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
141
+ except RuntimeError as exc:
142
+ console.print("[red]❌ %s[/red]" % exc)
143
+ sys.exit(1)
144
+
145
+ filtered = _apply_filter(tweets, do_filter, config)
146
+
147
+ if output_file:
148
+ Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8")
149
+ console.print("💾 Saved filtered tweets to %s\n" % output_file)
150
+
151
+ if as_json:
152
+ click.echo(tweets_to_json(filtered))
153
+ return
154
+
155
+ title = "👥 Following" if feed_type == "following" else "📱 Twitter"
156
+ title += " — %d tweets" % len(filtered)
157
+ print_tweet_table(filtered, console, title=title)
158
+ console.print()
159
+
160
+
161
+ @cli.command()
162
+ @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
163
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
164
+ @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
165
+ @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
166
+ def favorite(max_count, as_json, output_file, do_filter):
167
+ # type: (Optional[int], bool, Optional[str], bool) -> None
168
+ """Fetch bookmarked (favorite) tweets."""
169
+ config = load_config()
170
+ try:
171
+ fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50))
172
+ client = _get_client()
173
+ console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count)
174
+ start = time.time()
175
+ tweets = client.fetch_bookmarks(fetch_count)
176
+ elapsed = time.time() - start
177
+ console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed))
178
+ except RuntimeError as exc:
179
+ console.print("[red]❌ %s[/red]" % exc)
180
+ sys.exit(1)
181
+
182
+ filtered = _apply_filter(tweets, do_filter, config)
183
+
184
+ if output_file:
185
+ Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8")
186
+ console.print("💾 Saved to %s\n" % output_file)
187
+
188
+ if as_json:
189
+ click.echo(tweets_to_json(filtered))
190
+ return
191
+
192
+ print_tweet_table(filtered, console, title="🔖 Favorites — %d tweets" % len(filtered))
193
+ console.print()
194
+
195
+
196
+ @cli.command()
197
+ @click.argument("screen_name")
198
+ def user(screen_name):
199
+ # type: (str,) -> None
200
+ """View a user's profile. SCREEN_NAME is the @handle (without @)."""
201
+ screen_name = screen_name.lstrip("@")
202
+ try:
203
+ client = _get_client()
204
+ console.print("👤 Fetching user @%s..." % screen_name)
205
+ profile = client.fetch_user(screen_name)
206
+ except RuntimeError as exc:
207
+ console.print("[red]❌ %s[/red]" % exc)
208
+ sys.exit(1)
209
+
210
+ console.print()
211
+ print_user_profile(profile, console)
212
+
213
+
214
+ @cli.command("user-posts")
215
+ @click.argument("screen_name")
216
+ @click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.")
217
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
218
+ def user_posts(screen_name, max_count, as_json):
219
+ # type: (str, int, bool) -> None
220
+ """List a user's tweets. SCREEN_NAME is the @handle (without @)."""
221
+ screen_name = screen_name.lstrip("@")
222
+ try:
223
+ fetch_count = _resolve_fetch_count(max_count, 20)
224
+ client = _get_client()
225
+ console.print("👤 Fetching @%s's profile..." % screen_name)
226
+ profile = client.fetch_user(screen_name)
227
+ console.print("📝 Fetching tweets (%d)...\n" % fetch_count)
228
+ start = time.time()
229
+ tweets = client.fetch_user_tweets(profile.id, fetch_count)
230
+ elapsed = time.time() - start
231
+ console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
232
+ except RuntimeError as exc:
233
+ console.print("[red]❌ %s[/red]" % exc)
234
+ sys.exit(1)
235
+
236
+ if as_json:
237
+ click.echo(tweets_to_json(tweets))
238
+ return
239
+
240
+ print_tweet_table(tweets, console, title="📝 @%s — %d tweets" % (screen_name, len(tweets)))
241
+ console.print()
242
+
243
+
244
+ if __name__ == "__main__":
245
+ cli()