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.
- twitter_cli/__init__.py +3 -0
- twitter_cli/auth.py +193 -0
- twitter_cli/cli.py +245 -0
- twitter_cli/client.py +586 -0
- twitter_cli/config.py +149 -0
- twitter_cli/filter.py +115 -0
- twitter_cli/formatter.py +247 -0
- twitter_cli/models.py +69 -0
- twitter_cli/serialization.py +147 -0
- twitter_cli-0.1.0.dist-info/METADATA +185 -0
- twitter_cli-0.1.0.dist-info/RECORD +13 -0
- twitter_cli-0.1.0.dist-info/WHEEL +4 -0
- twitter_cli-0.1.0.dist-info/entry_points.txt +2 -0
twitter_cli/__init__.py
ADDED
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()
|