xr-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.
xr/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """XR — X (Twitter) Research CLI."""
2
+ __version__ = "0.1.0"
xr/api.py ADDED
@@ -0,0 +1,55 @@
1
+ """HTTP client for X API v2 with rate limit handling."""
2
+ from __future__ import annotations
3
+ import sys
4
+ import time
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ API_BASE = "https://api.x.com/2"
10
+ MAX_RETRIES = 3
11
+
12
+ class APIError(Exception):
13
+ def __init__(self, status_code: int, message: str):
14
+ self.status_code = status_code
15
+ super().__init__(f"API error {status_code}: {message}")
16
+
17
+ class RateLimitError(APIError):
18
+ def __init__(self, reset_at: int):
19
+ self.reset_at = reset_at
20
+ super().__init__(429, f"Rate limited. Resets at {reset_at}")
21
+
22
+ class XClient:
23
+ def __init__(self, bearer_token: str):
24
+ self.bearer_token = bearer_token
25
+
26
+ def _url(self, endpoint: str) -> str:
27
+ return f"{API_BASE}/{endpoint}"
28
+
29
+ def _headers(self) -> dict[str, str]:
30
+ return {
31
+ "Authorization": f"Bearer {self.bearer_token}",
32
+ "User-Agent": "xr-cli/0.1.0",
33
+ }
34
+
35
+ def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
36
+ """Make GET request with retry on rate limit."""
37
+ url = self._url(endpoint)
38
+ for attempt in range(MAX_RETRIES):
39
+ resp = requests.get(url, headers=self._headers(), params=params, timeout=15)
40
+
41
+ if resp.ok:
42
+ return resp.json()
43
+
44
+ if resp.status_code == 429:
45
+ reset_at = int(resp.headers.get("x-rate-limit-reset", 0))
46
+ if attempt < MAX_RETRIES - 1:
47
+ wait = max(reset_at - int(time.time()), 1) + 1
48
+ print(f"Rate limited. Waiting {wait}s...", file=sys.stderr)
49
+ time.sleep(wait)
50
+ continue
51
+ raise RateLimitError(reset_at)
52
+
53
+ raise APIError(resp.status_code, resp.text)
54
+
55
+ raise APIError(0, "Max retries exceeded")
xr/auth.py ADDED
@@ -0,0 +1,77 @@
1
+ """Credential loading and bearer token generation."""
2
+ from __future__ import annotations
3
+ import base64
4
+ import os
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ import requests
9
+
10
+ class CredentialError(Exception):
11
+ pass
12
+
13
+ def _credentials_path() -> Path:
14
+ xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
15
+ return Path(xdg) / "xr" / "credentials.toml"
16
+
17
+ def _legacy_path() -> Path:
18
+ return Path.home() / "charlie" / ".env.x-api"
19
+
20
+ def load_credentials(
21
+ path: Path | None = None,
22
+ legacy_path: Path | None = None,
23
+ ) -> tuple[str, str]:
24
+ """Load consumer key and secret. Priority: env vars > toml > legacy .env file."""
25
+ # 1. Environment variables
26
+ env_key = os.environ.get("XR_CONSUMER_KEY")
27
+ env_secret = os.environ.get("XR_CONSUMER_SECRET")
28
+ if env_key and env_secret:
29
+ return env_key, env_secret
30
+
31
+ # 2. TOML credentials file
32
+ cred_path = path or _credentials_path()
33
+ if cred_path.exists():
34
+ with open(cred_path, "rb") as f:
35
+ data = tomllib.load(f)
36
+ creds = data.get("credentials", {})
37
+ key = creds.get("consumer_key")
38
+ secret = creds.get("consumer_secret")
39
+ if key and secret:
40
+ return key, secret
41
+
42
+ # 3. Legacy .env.x-api
43
+ lp = legacy_path or _legacy_path()
44
+ if lp.exists():
45
+ kvs = {}
46
+ with open(lp) as f:
47
+ for line in f:
48
+ line = line.strip()
49
+ if line and not line.startswith("#") and "=" in line:
50
+ k, v = line.split("=", 1)
51
+ kvs[k.strip()] = v.strip()
52
+ key = kvs.get("X_API_CONSUMER_KEY")
53
+ secret = kvs.get("X_API_CONSUMER_SECRET")
54
+ if key and secret:
55
+ return key, secret
56
+
57
+ raise CredentialError(
58
+ "No X API credentials found. Run 'xr auth setup' or set "
59
+ "XR_CONSUMER_KEY and XR_CONSUMER_SECRET environment variables."
60
+ )
61
+
62
+ def get_bearer_token(consumer_key: str, consumer_secret: str) -> str:
63
+ """Generate OAuth 2.0 Bearer Token from consumer credentials."""
64
+ credentials = f"{consumer_key}:{consumer_secret}"
65
+ b64 = base64.b64encode(credentials.encode()).decode()
66
+ resp = requests.post(
67
+ "https://api.x.com/oauth2/token",
68
+ headers={
69
+ "Authorization": f"Basic {b64}",
70
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
71
+ },
72
+ data="grant_type=client_credentials",
73
+ timeout=10,
74
+ )
75
+ if not resp.ok:
76
+ raise CredentialError(f"Failed to get bearer token: {resp.status_code} {resp.text}")
77
+ return resp.json()["access_token"]
xr/cache.py ADDED
@@ -0,0 +1,158 @@
1
+ """SQLite cache for API responses."""
2
+ from __future__ import annotations
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ def _cache_path() -> Path:
12
+ xdg = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
13
+ return Path(xdg) / "xr" / "cache.db"
14
+
15
+ class Cache:
16
+ def __init__(self, path: Path | None = None, enabled: bool = True):
17
+ self.enabled = enabled
18
+ self.path = path or _cache_path()
19
+ if self.enabled:
20
+ self.path.parent.mkdir(parents=True, exist_ok=True)
21
+ self.conn = sqlite3.connect(str(self.path))
22
+ self._init_tables()
23
+ else:
24
+ self.conn = None
25
+
26
+ def _init_tables(self):
27
+ self.conn.executescript("""
28
+ CREATE TABLE IF NOT EXISTS tweets (
29
+ tweet_id TEXT PRIMARY KEY,
30
+ data TEXT NOT NULL,
31
+ fetched_at REAL NOT NULL
32
+ );
33
+ CREATE TABLE IF NOT EXISTS users (
34
+ user_id TEXT PRIMARY KEY,
35
+ username TEXT UNIQUE,
36
+ data TEXT NOT NULL,
37
+ fetched_at REAL NOT NULL
38
+ );
39
+ CREATE TABLE IF NOT EXISTS searches (
40
+ query_hash TEXT PRIMARY KEY,
41
+ query TEXT NOT NULL,
42
+ result_ids TEXT NOT NULL,
43
+ fetched_at REAL NOT NULL
44
+ );
45
+ CREATE TABLE IF NOT EXISTS counts (
46
+ query_hash TEXT PRIMARY KEY,
47
+ query TEXT NOT NULL,
48
+ granularity TEXT NOT NULL,
49
+ data TEXT NOT NULL,
50
+ fetched_at REAL NOT NULL
51
+ );
52
+ """)
53
+
54
+ def _is_fresh(self, fetched_at: float, ttl: int) -> bool:
55
+ return (time.time() - fetched_at) < ttl
56
+
57
+ def _query_hash(self, query: str) -> str:
58
+ return hashlib.sha256(query.strip().lower().encode()).hexdigest()
59
+
60
+ # --- Tweets ---
61
+ def get_tweet(self, tweet_id: str, ttl: int) -> dict | None:
62
+ if not self.enabled or not self.conn:
63
+ return None
64
+ row = self.conn.execute(
65
+ "SELECT data, fetched_at FROM tweets WHERE tweet_id = ?", (tweet_id,)
66
+ ).fetchone()
67
+ if row and self._is_fresh(row[1], ttl):
68
+ return json.loads(row[0])
69
+ return None
70
+
71
+ def put_tweet(self, tweet_id: str, data: dict):
72
+ if not self.enabled or not self.conn:
73
+ return
74
+ self.conn.execute(
75
+ "INSERT OR REPLACE INTO tweets (tweet_id, data, fetched_at) VALUES (?, ?, ?)",
76
+ (tweet_id, json.dumps(data), time.time()),
77
+ )
78
+ self.conn.commit()
79
+
80
+ # --- Users ---
81
+ def get_user(self, username: str, ttl: int) -> dict | None:
82
+ if not self.enabled or not self.conn:
83
+ return None
84
+ row = self.conn.execute(
85
+ "SELECT data, fetched_at FROM users WHERE username = ?", (username,)
86
+ ).fetchone()
87
+ if row and self._is_fresh(row[1], ttl):
88
+ return json.loads(row[0])
89
+ return None
90
+
91
+ def put_user(self, user_id: str, username: str, data: dict):
92
+ if not self.enabled or not self.conn:
93
+ return
94
+ self.conn.execute(
95
+ "INSERT OR REPLACE INTO users (user_id, username, data, fetched_at) VALUES (?, ?, ?, ?)",
96
+ (user_id, username, json.dumps(data), time.time()),
97
+ )
98
+ self.conn.commit()
99
+
100
+ # --- Searches ---
101
+ def get_search(self, query: str, ttl: int) -> list[str] | None:
102
+ if not self.enabled or not self.conn:
103
+ return None
104
+ qh = self._query_hash(query)
105
+ row = self.conn.execute(
106
+ "SELECT result_ids, fetched_at FROM searches WHERE query_hash = ?", (qh,)
107
+ ).fetchone()
108
+ if row and self._is_fresh(row[1], ttl):
109
+ return json.loads(row[0])
110
+ return None
111
+
112
+ def put_search(self, query: str, result_ids: list[str]):
113
+ if not self.enabled or not self.conn:
114
+ return
115
+ qh = self._query_hash(query)
116
+ self.conn.execute(
117
+ "INSERT OR REPLACE INTO searches (query_hash, query, result_ids, fetched_at) VALUES (?, ?, ?, ?)",
118
+ (qh, query, json.dumps(result_ids), time.time()),
119
+ )
120
+ self.conn.commit()
121
+
122
+ # --- Counts ---
123
+ def get_counts(self, query: str, granularity: str, ttl: int) -> dict | None:
124
+ if not self.enabled or not self.conn:
125
+ return None
126
+ qh = self._query_hash(f"{query}:{granularity}")
127
+ row = self.conn.execute(
128
+ "SELECT data, fetched_at FROM counts WHERE query_hash = ?", (qh,)
129
+ ).fetchone()
130
+ if row and self._is_fresh(row[1], ttl):
131
+ return json.loads(row[0])
132
+ return None
133
+
134
+ def put_counts(self, query: str, granularity: str, data: dict):
135
+ if not self.enabled or not self.conn:
136
+ return
137
+ qh = self._query_hash(f"{query}:{granularity}")
138
+ self.conn.execute(
139
+ "INSERT OR REPLACE INTO counts (query_hash, query, granularity, data, fetched_at) VALUES (?, ?, ?, ?, ?)",
140
+ (qh, query, granularity, json.dumps(data), time.time()),
141
+ )
142
+ self.conn.commit()
143
+
144
+ def cleanup(self, max_size_mb: int = 50):
145
+ """Remove old entries if cache exceeds max size."""
146
+ if not self.enabled or not self.conn:
147
+ return
148
+ size = self.path.stat().st_size / (1024 * 1024) if self.path.exists() else 0
149
+ if size > max_size_mb:
150
+ for table in ("tweets", "searches", "users", "counts"):
151
+ self.conn.execute(f"""
152
+ DELETE FROM {table} WHERE rowid IN (
153
+ SELECT rowid FROM {table} ORDER BY fetched_at ASC
154
+ LIMIT (SELECT COUNT(*) / 2 FROM {table})
155
+ )
156
+ """)
157
+ self.conn.commit()
158
+ self.conn.execute("VACUUM")
xr/cli.py ADDED
@@ -0,0 +1,258 @@
1
+ """CLI entry point for XR."""
2
+ from __future__ import annotations
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from xr import __version__
10
+ from xr.auth import load_credentials, get_bearer_token, CredentialError
11
+ from xr.api import XClient, APIError, RateLimitError
12
+ from xr.cache import Cache
13
+ from xr.config import Config
14
+ from xr.formatters.markdown import (
15
+ format_tweet, format_user, format_search, format_thread,
16
+ format_timeline, format_followers, format_counts,
17
+ )
18
+ from xr.formatters.json_fmt import format_json
19
+
20
+ def _get_client_and_cache(ctx) -> tuple[XClient, Cache]:
21
+ config = ctx.obj.get("config") or Config.load()
22
+ ctx.obj["config"] = config
23
+ try:
24
+ key, secret = load_credentials()
25
+ except CredentialError as e:
26
+ click.echo(str(e), err=True)
27
+ raise SystemExit(1)
28
+ token = get_bearer_token(key, secret)
29
+ client = XClient(token)
30
+ no_cache = ctx.obj.get("no_cache", False)
31
+ cache = Cache(enabled=config.cache_enabled and not no_cache)
32
+ return client, cache
33
+
34
+ def _output(ctx, content: str, filename: str | None = None):
35
+ """Print to stdout and optionally save."""
36
+ click.echo(content)
37
+ if ctx.obj.get("save") and filename:
38
+ config = ctx.obj["config"]
39
+ save_dir = Path(config.save_dir).expanduser()
40
+ save_dir.mkdir(parents=True, exist_ok=True)
41
+ path = save_dir / filename
42
+ path.write_text(content)
43
+ click.echo(f"Saved: {path}", err=True)
44
+
45
+ @click.group()
46
+ @click.version_option(__version__, prog_name="xr")
47
+ @click.option("--pretty", is_flag=True, help="Output raw JSON")
48
+ @click.option("--save", is_flag=True, help="Save to configured directory")
49
+ @click.option("--no-cache", is_flag=True, help="Bypass cache")
50
+ @click.pass_context
51
+ def main(ctx, pretty, save, no_cache):
52
+ """XR — X (Twitter) Research CLI."""
53
+ ctx.ensure_object(dict)
54
+ ctx.obj["pretty"] = pretty
55
+ ctx.obj["save"] = save
56
+ ctx.obj["no_cache"] = no_cache
57
+
58
+ @main.command()
59
+ @click.argument("input_str")
60
+ @click.pass_context
61
+ def tweet(ctx, input_str):
62
+ """Fetch a single tweet by ID or URL."""
63
+ from xr.commands.tweet import fetch_tweet, extract_tweet_id
64
+ client, cache = _get_client_and_cache(ctx)
65
+ config = ctx.obj["config"]
66
+ tweet_id = extract_tweet_id(input_str)
67
+ if ctx.obj["pretty"]:
68
+ data = client.get(f"tweets/{tweet_id}", {
69
+ "tweet.fields": "created_at,author_id,text,public_metrics,entities,referenced_tweets,note_tweet,conversation_id",
70
+ "expansions": "author_id,referenced_tweets.id",
71
+ "user.fields": "username,name,verified",
72
+ })
73
+ _output(ctx, format_json(data), f"tweet-{tweet_id}.json")
74
+ else:
75
+ t = fetch_tweet(client, cache, tweet_id, config.cache_ttl_tweets)
76
+ md = format_tweet(t)
77
+ _output(ctx, md, f"tweet-{t.username}-{t.id}.md")
78
+
79
+ @main.command()
80
+ @click.argument("input_str")
81
+ @click.option("--author-only", is_flag=True, help="Only thread author's tweets")
82
+ @click.pass_context
83
+ def thread(ctx, input_str, author_only):
84
+ """Fetch a conversation thread."""
85
+ from xr.commands.tweet import extract_tweet_id
86
+ from xr.commands.thread import fetch_thread
87
+ client, cache = _get_client_and_cache(ctx)
88
+ config = ctx.obj["config"]
89
+ tweet_id = extract_tweet_id(input_str)
90
+ tweets, conv_id = fetch_thread(client, cache, tweet_id, author_only, config.cache_ttl_tweets, config.cache_ttl_searches)
91
+ if ctx.obj["pretty"]:
92
+ _output(ctx, format_json([{"id": t.id, "text": t.text, "username": t.username} for t in tweets]))
93
+ else:
94
+ md = format_thread(tweets, conv_id)
95
+ suffix = "-author-only" if author_only else ""
96
+ _output(ctx, md, f"thread-{tweets[0].username if tweets else 'unknown'}-{conv_id}{suffix}.md")
97
+
98
+ @main.command()
99
+ @click.argument("query")
100
+ @click.option("--lang", default="", help="Filter by language (e.g., es, en)")
101
+ @click.option("--no-rt", is_flag=True, help="Exclude retweets")
102
+ @click.option("--top", is_flag=True, help="Sort by relevancy instead of recency")
103
+ @click.option("--max", "max_results", default=20, help="Max results (default: 20)")
104
+ @click.pass_context
105
+ def search(ctx, query, lang, no_rt, top, max_results):
106
+ """Search recent tweets (7-day window)."""
107
+ from xr.commands.search import fetch_search
108
+ client, cache = _get_client_and_cache(ctx)
109
+ config = ctx.obj["config"]
110
+
111
+ # Build query with operators
112
+ q = query
113
+ if lang:
114
+ q += f" lang:{lang}"
115
+ if no_rt:
116
+ q += " -is:retweet"
117
+
118
+ sort = "relevancy" if top else "recency"
119
+ result = fetch_search(client, cache, q, max_results, sort, config.cache_ttl_searches, config.cache_ttl_tweets)
120
+ if ctx.obj["pretty"]:
121
+ _output(ctx, format_json({"query": q, "total": result.total, "tweets": [{"id": t.id, "text": t.text, "username": t.username, "likes": t.likes} for t in result.tweets]}))
122
+ else:
123
+ md = format_search(result, sort)
124
+ _output(ctx, md, f"search-{query[:50].replace(' ', '-')}.md")
125
+
126
+ @main.command()
127
+ @click.argument("username")
128
+ @click.pass_context
129
+ def user(ctx, username):
130
+ """Fetch user profile."""
131
+ from xr.commands.user import fetch_user
132
+ client, cache = _get_client_and_cache(ctx)
133
+ config = ctx.obj["config"]
134
+ username = username.lstrip("@")
135
+ u = fetch_user(client, cache, username, config.cache_ttl_users)
136
+ if ctx.obj["pretty"]:
137
+ _output(ctx, format_json({"id": u.id, "username": u.username, "name": u.name, "followers": u.followers, "following": u.following, "tweets": u.tweet_count}))
138
+ else:
139
+ md = format_user(u)
140
+ _output(ctx, md, f"user-{u.username}.md")
141
+
142
+ @main.command()
143
+ @click.argument("username")
144
+ @click.option("--top", is_flag=True, help="Sort by likes")
145
+ @click.option("--no-rt", is_flag=True, help="Exclude retweets")
146
+ @click.option("--no-replies", is_flag=True, help="Exclude replies")
147
+ @click.option("--max", "max_results", default=20, help="Max results")
148
+ @click.pass_context
149
+ def timeline(ctx, username, top, no_rt, no_replies, max_results):
150
+ """Fetch user's recent tweets."""
151
+ from xr.commands.timeline import fetch_timeline
152
+ client, cache = _get_client_and_cache(ctx)
153
+ config = ctx.obj["config"]
154
+ username = username.lstrip("@")
155
+ tweets, u = fetch_timeline(client, cache, username, max_results, no_rt, no_replies, top, config.cache_ttl_users, config.cache_ttl_tweets)
156
+ if ctx.obj["pretty"]:
157
+ _output(ctx, format_json([{"id": t.id, "text": t.text, "likes": t.likes} for t in tweets]))
158
+ else:
159
+ md = format_timeline(tweets, u.username)
160
+ _output(ctx, md, f"timeline-{u.username}.md")
161
+
162
+ @main.command()
163
+ @click.argument("username")
164
+ @click.option("--max", "max_results", default=20, help="Max results")
165
+ @click.pass_context
166
+ def mentions(ctx, username, max_results):
167
+ """Fetch user's recent mentions."""
168
+ from xr.commands.mentions import fetch_mentions
169
+ client, cache = _get_client_and_cache(ctx)
170
+ config = ctx.obj["config"]
171
+ username = username.lstrip("@")
172
+ tweets, u = fetch_mentions(client, cache, username, max_results, config.cache_ttl_users, config.cache_ttl_tweets)
173
+ if ctx.obj["pretty"]:
174
+ _output(ctx, format_json([{"id": t.id, "text": t.text, "username": t.username} for t in tweets]))
175
+ else:
176
+ md = format_timeline(tweets, f"{u.username} (mentions)")
177
+ _output(ctx, md, f"mentions-{u.username}.md")
178
+
179
+ @main.command()
180
+ @click.argument("username")
181
+ @click.option("--max", "max_results", default=100, help="Max results")
182
+ @click.pass_context
183
+ def followers(ctx, username, max_results):
184
+ """Fetch user's followers."""
185
+ from xr.commands.followers import fetch_followers
186
+ client, cache = _get_client_and_cache(ctx)
187
+ config = ctx.obj["config"]
188
+ username = username.lstrip("@")
189
+ users, target = fetch_followers(client, cache, username, max_results, config.cache_ttl_users)
190
+ if ctx.obj["pretty"]:
191
+ _output(ctx, format_json([{"username": u.username, "followers": u.followers} for u in users]))
192
+ else:
193
+ md = format_followers(users, target.username, "followers")
194
+ _output(ctx, md, f"followers-{target.username}.md")
195
+
196
+ @main.command()
197
+ @click.argument("username")
198
+ @click.option("--max", "max_results", default=100, help="Max results")
199
+ @click.pass_context
200
+ def following(ctx, username, max_results):
201
+ """Fetch who a user follows."""
202
+ from xr.commands.followers import fetch_following
203
+ client, cache = _get_client_and_cache(ctx)
204
+ config = ctx.obj["config"]
205
+ username = username.lstrip("@")
206
+ users, target = fetch_following(client, cache, username, max_results, config.cache_ttl_users)
207
+ if ctx.obj["pretty"]:
208
+ _output(ctx, format_json([{"username": u.username, "followers": u.followers} for u in users]))
209
+ else:
210
+ md = format_followers(users, target.username, "following")
211
+ _output(ctx, md, f"following-{target.username}.md")
212
+
213
+ @main.command()
214
+ @click.argument("query")
215
+ @click.option("--granularity", type=click.Choice(["day", "hour"]), default="day", help="Bucket size")
216
+ @click.pass_context
217
+ def counts(ctx, query, granularity):
218
+ """Show tweet volume over time."""
219
+ from xr.commands.counts import fetch_counts
220
+ client, cache = _get_client_and_cache(ctx)
221
+ config = ctx.obj["config"]
222
+ result = fetch_counts(client, cache, query, granularity, config.cache_ttl_counts)
223
+ if ctx.obj["pretty"]:
224
+ _output(ctx, format_json({"query": query, "total": result.total, "buckets": [{"date": b.start, "count": b.count} for b in result.buckets]}))
225
+ else:
226
+ md = format_counts(result)
227
+ _output(ctx, md, f"counts-{query[:50].replace(' ', '-')}.md")
228
+
229
+ @main.group()
230
+ def auth():
231
+ """Manage API credentials."""
232
+ pass
233
+
234
+ @auth.command("setup")
235
+ def auth_setup():
236
+ """Interactive credential setup."""
237
+ click.echo("XR — X API Credential Setup")
238
+ click.echo("Get your credentials at: https://developer.x.com/en/portal/dashboard\n")
239
+ key = click.prompt("Consumer Key (API Key)")
240
+ secret = click.prompt("Consumer Secret (API Secret)", hide_input=True)
241
+
242
+ import os
243
+ from pathlib import Path
244
+ xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
245
+ config_dir = Path(xdg) / "xr"
246
+ config_dir.mkdir(parents=True, exist_ok=True)
247
+ cred_path = config_dir / "credentials.toml"
248
+ cred_path.write_text(f'[credentials]\nconsumer_key = "{key}"\nconsumer_secret = "{secret}"\n')
249
+ cred_path.chmod(0o600)
250
+ click.echo(f"\nCredentials saved to {cred_path} (mode 600)")
251
+
252
+ # Test connection
253
+ try:
254
+ from xr.auth import get_bearer_token
255
+ get_bearer_token(key, secret)
256
+ click.echo("Connection test: OK")
257
+ except Exception as e:
258
+ click.echo(f"Connection test failed: {e}", err=True)
@@ -0,0 +1 @@
1
+ """Command modules."""
xr/commands/counts.py ADDED
@@ -0,0 +1,33 @@
1
+ """Fetch tweet volume counts."""
2
+ from __future__ import annotations
3
+
4
+ from xr.api import XClient
5
+ from xr.cache import Cache
6
+ from xr.models import CountBucket, CountResult
7
+
8
+ def fetch_counts(
9
+ client: XClient, cache: Cache, query: str,
10
+ granularity: str = "day", ttl: int = 3600,
11
+ ) -> CountResult:
12
+ cached = cache.get_counts(query, granularity, ttl)
13
+ if cached:
14
+ buckets = [CountBucket(**b) for b in cached.get("buckets", [])]
15
+ return CountResult(query=query, granularity=granularity, buckets=buckets, total=cached.get("total", 0))
16
+
17
+ data = client.get("tweets/counts/recent", {
18
+ "query": query,
19
+ "granularity": granularity,
20
+ })
21
+
22
+ buckets = [
23
+ CountBucket(start=b["start"], end=b["end"], count=b["tweet_count"])
24
+ for b in data.get("data", [])
25
+ ]
26
+ total = data.get("meta", {}).get("total_tweet_count", sum(b.count for b in buckets))
27
+
28
+ cache.put_counts(query, granularity, {
29
+ "buckets": [{"start": b.start, "end": b.end, "count": b.count} for b in buckets],
30
+ "total": total,
31
+ })
32
+
33
+ return CountResult(query=query, granularity=granularity, buckets=buckets, total=total)
@@ -0,0 +1,38 @@
1
+ """Fetch followers and following lists."""
2
+ from __future__ import annotations
3
+
4
+ from xr.api import XClient
5
+ from xr.cache import Cache
6
+ from xr.models import User
7
+ from xr.commands.user import fetch_user, USER_FIELDS
8
+
9
+ def fetch_followers(
10
+ client: XClient, cache: Cache, username: str,
11
+ max_results: int = 100, ttl_user: int = 86400,
12
+ ) -> tuple[list[User], User]:
13
+ target = fetch_user(client, cache, username, ttl_user)
14
+
15
+ data = client.get(f"users/{target.id}/followers", {
16
+ "user.fields": USER_FIELDS,
17
+ "max_results": min(max_results, 1000),
18
+ })
19
+
20
+ users = [User.from_api(u) for u in data.get("data", [])]
21
+ for u in users:
22
+ cache.put_user(u.id, u.username, {"data": data})
23
+
24
+ return users, target
25
+
26
+ def fetch_following(
27
+ client: XClient, cache: Cache, username: str,
28
+ max_results: int = 100, ttl_user: int = 86400,
29
+ ) -> tuple[list[User], User]:
30
+ target = fetch_user(client, cache, username, ttl_user)
31
+
32
+ data = client.get(f"users/{target.id}/following", {
33
+ "user.fields": USER_FIELDS,
34
+ "max_results": min(max_results, 1000),
35
+ })
36
+
37
+ users = [User.from_api(u) for u in data.get("data", [])]
38
+ return users, target
@@ -0,0 +1,30 @@
1
+ """Fetch user's mentions."""
2
+ from __future__ import annotations
3
+
4
+ from xr.api import XClient
5
+ from xr.cache import Cache
6
+ from xr.models import Tweet, User
7
+ from xr.commands.user import fetch_user
8
+ from xr.commands.tweet import TWEET_FIELDS, USER_FIELDS as TWEET_USER_FIELDS
9
+
10
+ def fetch_mentions(
11
+ client: XClient, cache: Cache, username: str,
12
+ max_results: int = 20, ttl_user: int = 86400, ttl_tweet: int = 604800,
13
+ ) -> tuple[list[Tweet], User]:
14
+ user = fetch_user(client, cache, username, ttl_user)
15
+
16
+ data = client.get(f"users/{user.id}/mentions", {
17
+ "tweet.fields": TWEET_FIELDS,
18
+ "expansions": "author_id",
19
+ "user.fields": TWEET_USER_FIELDS,
20
+ "max_results": min(max_results, 100),
21
+ })
22
+ includes = data.get("includes", {})
23
+
24
+ tweets = []
25
+ for t in data.get("data", []):
26
+ tweet = Tweet.from_api(t, includes)
27
+ tweets.append(tweet)
28
+ cache.put_tweet(tweet.id, {"data": t, "includes": includes})
29
+
30
+ return tweets, user