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 +2 -0
- xr/api.py +55 -0
- xr/auth.py +77 -0
- xr/cache.py +158 -0
- xr/cli.py +258 -0
- xr/commands/__init__.py +1 -0
- xr/commands/counts.py +33 -0
- xr/commands/followers.py +38 -0
- xr/commands/mentions.py +30 -0
- xr/commands/search.py +57 -0
- xr/commands/thread.py +50 -0
- xr/commands/timeline.py +45 -0
- xr/commands/tweet.py +35 -0
- xr/commands/user.py +19 -0
- xr/config.py +86 -0
- xr/formatters/__init__.py +1 -0
- xr/formatters/json_fmt.py +6 -0
- xr/formatters/markdown.py +99 -0
- xr/models.py +136 -0
- xr_cli-0.1.0.dist-info/METADATA +209 -0
- xr_cli-0.1.0.dist-info/RECORD +24 -0
- xr_cli-0.1.0.dist-info/WHEEL +4 -0
- xr_cli-0.1.0.dist-info/entry_points.txt +2 -0
- xr_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
xr/__init__.py
ADDED
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)
|
xr/commands/__init__.py
ADDED
|
@@ -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)
|
xr/commands/followers.py
ADDED
|
@@ -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
|
xr/commands/mentions.py
ADDED
|
@@ -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
|