rdt-cli 0.2.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.
rdt_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Reddit CLI."""
2
+
3
+ __version__ = "0.1.0"
rdt_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m rdt_cli`."""
2
+
3
+ from rdt_cli.cli import cli
4
+
5
+ cli()
rdt_cli/auth.py ADDED
@@ -0,0 +1,174 @@
1
+ """Authentication for Reddit CLI.
2
+
3
+ Strategy:
4
+ 1. Try loading saved credential from ~/.config/rdt-cli/credential.json
5
+ 2. Try extracting cookies from local browsers via browser-cookie3
6
+ 3. No QR login — Reddit uses OAuth/cookie-based auth only
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import shutil
14
+ import subprocess
15
+ import time
16
+ from typing import Any
17
+
18
+ from .constants import CONFIG_DIR, CREDENTIAL_FILE, REQUIRED_COOKIES
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Credential TTL: attempt refresh after 7 days
23
+ CREDENTIAL_TTL_DAYS = 7
24
+ _CREDENTIAL_TTL_SECONDS = CREDENTIAL_TTL_DAYS * 86400
25
+
26
+
27
+ # ── Credential data class ───────────────────────────────────────────
28
+
29
+
30
+ class Credential:
31
+ """Holds Reddit session cookies."""
32
+
33
+ def __init__(self, cookies: dict[str, str]):
34
+ self.cookies = cookies
35
+
36
+ @property
37
+ def is_valid(self) -> bool:
38
+ return bool(self.cookies)
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {"cookies": self.cookies, "saved_at": time.time()}
42
+
43
+ @classmethod
44
+ def from_dict(cls, data: dict[str, Any]) -> Credential:
45
+ return cls(cookies=data.get("cookies", {}))
46
+
47
+ def as_cookie_header(self) -> str:
48
+ return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
49
+
50
+
51
+ # ── Persistence ─────────────────────────────────────────────────────
52
+
53
+
54
+ def save_credential(credential: Credential) -> None:
55
+ """Save credential to disk with restricted permissions."""
56
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
57
+ CREDENTIAL_FILE.write_text(json.dumps(credential.to_dict(), indent=2, ensure_ascii=False))
58
+ CREDENTIAL_FILE.chmod(0o600)
59
+
60
+
61
+ def load_credential() -> Credential | None:
62
+ """Load saved credential with TTL-based auto-refresh."""
63
+ if not CREDENTIAL_FILE.exists():
64
+ return None
65
+ try:
66
+ data = json.loads(CREDENTIAL_FILE.read_text())
67
+ cred = Credential.from_dict(data)
68
+ if not cred.is_valid:
69
+ return None
70
+
71
+ # TTL check — auto-refresh if stale
72
+ saved_at = data.get("saved_at", 0)
73
+ if saved_at and (time.time() - saved_at) > _CREDENTIAL_TTL_SECONDS:
74
+ logger.info("Credential older than %d days, attempting browser refresh", CREDENTIAL_TTL_DAYS)
75
+ fresh = extract_browser_credential()
76
+ if fresh:
77
+ logger.info("Auto-refreshed credential from browser")
78
+ return fresh
79
+ logger.warning("Cookie refresh failed; using existing cookies")
80
+
81
+ return cred
82
+ except (json.JSONDecodeError, KeyError):
83
+ return None
84
+
85
+
86
+ def clear_credential() -> None:
87
+ """Remove saved credential file."""
88
+ if CREDENTIAL_FILE.exists():
89
+ CREDENTIAL_FILE.unlink()
90
+
91
+
92
+ # ── Browser cookie extraction ───────────────────────────────────────
93
+
94
+
95
+ def extract_browser_credential() -> Credential | None:
96
+ """Extract Reddit cookies from installed browsers.
97
+
98
+ Uses subprocess to avoid SQLite lock when browser is running.
99
+ """
100
+ if shutil.which("uv"):
101
+ cred = _extract_subprocess()
102
+ if cred:
103
+ return cred
104
+ return _extract_direct()
105
+
106
+
107
+ def _extract_subprocess() -> Credential | None:
108
+ """Extract via uv subprocess — avoids SQLite lock."""
109
+ script = '''
110
+ import browser_cookie3, json
111
+ cookies = {}
112
+ for browser_fn in [browser_cookie3.chrome, browser_cookie3.firefox, browser_cookie3.edge, browser_cookie3.brave]:
113
+ try:
114
+ jar = browser_fn(domain_name=".reddit.com")
115
+ for c in jar:
116
+ cookies[c.name] = c.value
117
+ if cookies:
118
+ break
119
+ except Exception:
120
+ continue
121
+ if cookies:
122
+ print(json.dumps(cookies))
123
+ '''
124
+ try:
125
+ result = subprocess.run(
126
+ ["uv", "run", "--with", "browser-cookie3", "python3", "-c", script],
127
+ capture_output=True,
128
+ text=True,
129
+ timeout=30,
130
+ )
131
+ if result.returncode == 0 and result.stdout.strip():
132
+ cookies = json.loads(result.stdout.strip())
133
+ if any(k in cookies for k in REQUIRED_COOKIES):
134
+ cred = Credential(cookies=cookies)
135
+ save_credential(cred)
136
+ return cred
137
+ except Exception as e:
138
+ logger.debug("Subprocess extraction failed: %s", e)
139
+ return None
140
+
141
+
142
+ def _extract_direct() -> Credential | None:
143
+ """Fallback direct extraction (may fail if browser is open)."""
144
+ try:
145
+ import browser_cookie3
146
+ except ImportError:
147
+ logger.warning("browser-cookie3 not available for direct extraction")
148
+ return None
149
+
150
+ for fn in [browser_cookie3.chrome, browser_cookie3.firefox, browser_cookie3.edge, browser_cookie3.brave]:
151
+ try:
152
+ jar = fn(domain_name=".reddit.com")
153
+ cookies = {c.name: c.value for c in jar}
154
+ if any(k in cookies for k in REQUIRED_COOKIES):
155
+ cred = Credential(cookies=cookies)
156
+ save_credential(cred)
157
+ return cred
158
+ except Exception:
159
+ continue
160
+ return None
161
+
162
+
163
+ # ── Credential chain ────────────────────────────────────────────────
164
+
165
+
166
+ def get_credential() -> Credential | None:
167
+ """Try saved → browser → return None."""
168
+ cred = load_credential()
169
+ if cred:
170
+ return cred
171
+ cred = extract_browser_credential()
172
+ if cred:
173
+ return cred
174
+ return None
rdt_cli/cli.py ADDED
@@ -0,0 +1,72 @@
1
+ """CLI entry point for rdt-cli.
2
+
3
+ Usage:
4
+ rdt login / status / logout
5
+ rdt feed / popular / all / sub <subreddit>
6
+ rdt read <post_id> / show <index> / open <id_or_index>
7
+ rdt search <query> / export <query>
8
+ rdt user <username> / user-posts <username>
9
+ rdt upvote / save / subscribe / comment
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ import click
17
+
18
+ from . import __version__
19
+ from .commands import auth, browse, post, search, social
20
+
21
+
22
+ @click.group()
23
+ @click.version_option(version=__version__, prog_name="rdt")
24
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging (show request URLs, timing)")
25
+ @click.pass_context
26
+ def cli(ctx: click.Context, verbose: bool) -> None:
27
+ """rdt — Reddit in your terminal 📖"""
28
+ ctx.ensure_object(dict)
29
+ logging.basicConfig(
30
+ level=logging.INFO if verbose else logging.WARNING,
31
+ format="%(name)s %(message)s",
32
+ )
33
+
34
+
35
+ # ─── Auth commands ───────────────────────────────────────────────────
36
+
37
+ cli.add_command(auth.login)
38
+ cli.add_command(auth.logout)
39
+ cli.add_command(auth.status)
40
+ cli.add_command(auth.whoami)
41
+
42
+ # ─── Browse commands ─────────────────────────────────────────────────
43
+
44
+ cli.add_command(browse.feed)
45
+ cli.add_command(browse.popular)
46
+ cli.add_command(browse.all_cmd)
47
+ cli.add_command(browse.sub)
48
+ cli.add_command(browse.sub_info)
49
+ cli.add_command(browse.user)
50
+ cli.add_command(browse.user_posts)
51
+ cli.add_command(browse.open_post)
52
+
53
+ # ─── Post commands ───────────────────────────────────────────────────
54
+
55
+ cli.add_command(post.read)
56
+ cli.add_command(post.show)
57
+
58
+ # ─── Search & Export ─────────────────────────────────────────────────
59
+
60
+ cli.add_command(search.search)
61
+ cli.add_command(search.export)
62
+
63
+ # ─── Social commands ────────────────────────────────────────────────
64
+
65
+ cli.add_command(social.upvote)
66
+ cli.add_command(social.save)
67
+ cli.add_command(social.subscribe)
68
+ cli.add_command(social.comment)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ cli()
rdt_cli/client.py ADDED
@@ -0,0 +1,356 @@
1
+ """API client for Reddit with rate limiting, retry, and anti-detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import random
7
+ import time
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from .constants import (
13
+ ALL_URL,
14
+ BASE_URL,
15
+ COMMENT_URL,
16
+ DEFAULT_LIMIT,
17
+ HEADERS,
18
+ HOME_URL,
19
+ POPULAR_URL,
20
+ POST_COMMENTS_SHORT_URL,
21
+ POST_COMMENTS_URL,
22
+ SAVE_URL,
23
+ SEARCH_URL,
24
+ SUBREDDIT_ABOUT_URL,
25
+ SUBREDDIT_SEARCH_URL,
26
+ SUBSCRIBE_URL,
27
+ UNSAVE_URL,
28
+ USER_ABOUT_URL,
29
+ USER_COMMENTS_URL,
30
+ USER_POSTS_URL,
31
+ VOTE_URL,
32
+ )
33
+ from .exceptions import (
34
+ ForbiddenError,
35
+ NotFoundError,
36
+ RedditApiError,
37
+ SessionExpiredError,
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class RedditClient:
44
+ """Reddit API client with Gaussian jitter, exponential backoff, and session-stable identity.
45
+
46
+ Anti-detection strategy:
47
+ - Gaussian jitter delay between requests
48
+ - 5% chance of random long pause (2-5s) to mimic reading
49
+ - Exponential backoff on HTTP 429/5xx (up to 3 retries)
50
+ - Response cookies merged back into session jar
51
+ - Per-request logging with counter
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ credential: object | None = None,
57
+ timeout: float = 30.0,
58
+ request_delay: float = 1.0,
59
+ max_retries: int = 3,
60
+ ):
61
+ self.credential = credential
62
+ self._timeout = timeout
63
+ self._request_delay = request_delay
64
+ self._max_retries = max_retries
65
+ self._last_request_time = 0.0
66
+ self._request_count = 0
67
+ self._http: httpx.Client | None = None
68
+
69
+ def _build_client(self) -> httpx.Client:
70
+ cookies = {}
71
+ if self.credential:
72
+ cookies = self.credential.cookies
73
+ return httpx.Client(
74
+ base_url=BASE_URL,
75
+ headers=dict(HEADERS),
76
+ cookies=cookies,
77
+ follow_redirects=True,
78
+ timeout=httpx.Timeout(self._timeout),
79
+ )
80
+
81
+ @property
82
+ def client(self) -> httpx.Client:
83
+ if not self._http:
84
+ raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
85
+ return self._http
86
+
87
+ def __enter__(self) -> RedditClient:
88
+ self._http = self._build_client()
89
+ return self
90
+
91
+ def __exit__(self, *args: Any) -> None:
92
+ if self._http:
93
+ self._http.close()
94
+ self._http = None
95
+
96
+ @property
97
+ def request_stats(self) -> dict[str, int]:
98
+ return {"request_count": self._request_count}
99
+
100
+ # ── Rate limiting ───────────────────────────────────────────────
101
+
102
+ def _rate_limit_delay(self) -> None:
103
+ """Enforce minimum delay with Gaussian jitter to mimic human browsing."""
104
+ if self._request_delay <= 0:
105
+ return
106
+ elapsed = time.time() - self._last_request_time
107
+ if elapsed < self._request_delay:
108
+ jitter = max(0, random.gauss(0.3, 0.15))
109
+ if random.random() < 0.05:
110
+ jitter += random.uniform(2.0, 5.0)
111
+ time.sleep(self._request_delay - elapsed + jitter)
112
+
113
+ # ── Response cookies ────────────────────────────────────────────
114
+
115
+ def _merge_response_cookies(self, resp: httpx.Response) -> None:
116
+ """Merge Set-Cookie headers back into session jar."""
117
+ for name, value in resp.cookies.items():
118
+ if value:
119
+ self.client.cookies.set(name, value)
120
+
121
+ # ── Core request ────────────────────────────────────────────────
122
+
123
+ def _request(self, method: str, url: str, **kwargs: Any) -> Any:
124
+ """Rate-limited request with retry and cookie merge."""
125
+ self._rate_limit_delay()
126
+
127
+ last_exc: Exception | None = None
128
+ for attempt in range(self._max_retries):
129
+ t0 = time.time()
130
+ try:
131
+ resp = self.client.request(method, url, **kwargs)
132
+ elapsed = time.time() - t0
133
+ self._merge_response_cookies(resp)
134
+ self._request_count += 1
135
+ self._last_request_time = time.time()
136
+ logger.info(
137
+ "[#%d] %s %s → %d (%.2fs)",
138
+ self._request_count,
139
+ method,
140
+ url[:80],
141
+ resp.status_code,
142
+ elapsed,
143
+ )
144
+
145
+ # Retry on server errors
146
+ if resp.status_code == 429:
147
+ retry_after = float(resp.headers.get("Retry-After", 5))
148
+ logger.warning("Rate limited, waiting %.1fs", retry_after)
149
+ time.sleep(retry_after)
150
+ continue
151
+
152
+ if resp.status_code in (500, 502, 503, 504):
153
+ wait = (2**attempt) + random.uniform(0, 1)
154
+ logger.warning("HTTP %d, retrying in %.1fs", resp.status_code, wait)
155
+ time.sleep(wait)
156
+ continue
157
+
158
+ # Client errors
159
+ if resp.status_code == 401:
160
+ raise SessionExpiredError()
161
+ if resp.status_code == 403:
162
+ raise ForbiddenError()
163
+ if resp.status_code == 404:
164
+ raise NotFoundError()
165
+
166
+ resp.raise_for_status()
167
+
168
+ # Reddit returns HTML on some error pages
169
+ text = resp.text
170
+ if text.strip().startswith("<"):
171
+ raise RedditApiError("Received HTML instead of JSON (possible auth redirect)")
172
+
173
+ return resp.json()
174
+
175
+ except (httpx.TimeoutException, httpx.NetworkError) as exc:
176
+ last_exc = exc
177
+ wait = (2**attempt) + random.uniform(0, 1)
178
+ logger.warning("Network error: %s, retrying in %.1fs", exc, wait)
179
+ time.sleep(wait)
180
+
181
+ if last_exc:
182
+ raise RedditApiError(f"Request failed after {self._max_retries} retries: {last_exc}") from last_exc
183
+ raise RedditApiError(f"Request failed after {self._max_retries} retries")
184
+
185
+ def _get(self, url: str, params: dict[str, Any] | None = None) -> Any:
186
+ """GET request."""
187
+ return self._request("GET", url, params=params)
188
+
189
+ def _post(self, url: str, data: dict[str, Any] | None = None) -> Any:
190
+ """POST request."""
191
+ return self._request("POST", url, data=data)
192
+
193
+ # ── Listing helpers ─────────────────────────────────────────────
194
+
195
+ @staticmethod
196
+ def _extract_posts(data: dict) -> list[dict]:
197
+ """Extract post list from Reddit Listing response."""
198
+ if isinstance(data, list):
199
+ # Comments endpoint returns [post_listing, comments_listing]
200
+ return data
201
+ children = data.get("data", {}).get("children", [])
202
+ return [child.get("data", child) for child in children]
203
+
204
+ @staticmethod
205
+ def _extract_after(data: dict) -> str | None:
206
+ """Extract pagination cursor."""
207
+ if isinstance(data, list):
208
+ return None
209
+ return data.get("data", {}).get("after")
210
+
211
+ # ── Feed / Listing endpoints ────────────────────────────────────
212
+
213
+ def get_home(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
214
+ """Get home feed (requires login)."""
215
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
216
+ if after:
217
+ params["after"] = after
218
+ return self._get(HOME_URL, params=params)
219
+
220
+ def get_popular(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
221
+ """Get /r/popular."""
222
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
223
+ if after:
224
+ params["after"] = after
225
+ return self._get(POPULAR_URL, params=params)
226
+
227
+ def get_all(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
228
+ """Get /r/all."""
229
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
230
+ if after:
231
+ params["after"] = after
232
+ return self._get(ALL_URL, params=params)
233
+
234
+ def get_subreddit(
235
+ self,
236
+ subreddit: str,
237
+ sort: str = "hot",
238
+ limit: int = DEFAULT_LIMIT,
239
+ after: str | None = None,
240
+ time_filter: str | None = None,
241
+ ) -> dict:
242
+ """Get subreddit listing."""
243
+ url = f"/r/{subreddit}.json" if sort == "hot" else f"/r/{subreddit}/{sort}.json"
244
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
245
+ if after:
246
+ params["after"] = after
247
+ if time_filter and sort in ("top", "controversial"):
248
+ params["t"] = time_filter
249
+ return self._get(url, params=params)
250
+
251
+ def get_subreddit_about(self, subreddit: str) -> dict:
252
+ """Get subreddit info."""
253
+ data = self._get(SUBREDDIT_ABOUT_URL.format(subreddit=subreddit), params={"raw_json": 1})
254
+ return data.get("data", data)
255
+
256
+ # ── Post / Comments ─────────────────────────────────────────────
257
+
258
+ def get_post_comments(
259
+ self,
260
+ post_id: str,
261
+ subreddit: str | None = None,
262
+ sort: str = "best",
263
+ limit: int = DEFAULT_LIMIT,
264
+ ) -> list[dict]:
265
+ """Get post and its comments.
266
+
267
+ Returns [post_listing, comments_listing].
268
+ """
269
+ if subreddit:
270
+ url = POST_COMMENTS_URL.format(subreddit=subreddit, post_id=post_id)
271
+ else:
272
+ url = POST_COMMENTS_SHORT_URL.format(post_id=post_id)
273
+ params: dict[str, Any] = {"sort": sort, "limit": limit, "raw_json": 1}
274
+ return self._get(url, params=params)
275
+
276
+ # ── Search ──────────────────────────────────────────────────────
277
+
278
+ def search(
279
+ self,
280
+ query: str,
281
+ subreddit: str | None = None,
282
+ sort: str = "relevance",
283
+ time_filter: str = "all",
284
+ limit: int = DEFAULT_LIMIT,
285
+ after: str | None = None,
286
+ ) -> dict:
287
+ """Search posts."""
288
+ if subreddit:
289
+ url = SUBREDDIT_SEARCH_URL.format(subreddit=subreddit)
290
+ else:
291
+ url = SEARCH_URL
292
+ params: dict[str, Any] = {
293
+ "q": query,
294
+ "sort": sort,
295
+ "t": time_filter,
296
+ "limit": limit,
297
+ "restrict_sr": "on" if subreddit else "off",
298
+ "raw_json": 1,
299
+ }
300
+ if after:
301
+ params["after"] = after
302
+ return self._get(url, params=params)
303
+
304
+ # ── User ────────────────────────────────────────────────────────
305
+
306
+ def get_user_about(self, username: str) -> dict:
307
+ """Get user profile info."""
308
+ data = self._get(USER_ABOUT_URL.format(username=username), params={"raw_json": 1})
309
+ return data.get("data", data)
310
+
311
+ def get_user_posts(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
312
+ """Get user's submitted posts."""
313
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
314
+ if after:
315
+ params["after"] = after
316
+ return self._get(USER_POSTS_URL.format(username=username), params=params)
317
+
318
+ def get_user_comments(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
319
+ """Get user's comments."""
320
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
321
+ if after:
322
+ params["after"] = after
323
+ return self._get(USER_COMMENTS_URL.format(username=username), params=params)
324
+
325
+ # ── Identity (requires auth) ────────────────────────────────────
326
+
327
+ def get_me(self) -> dict:
328
+ """Get current user info. Uses oauth.reddit.com."""
329
+ # For the .json API, we try /api/v1/me or fallback to username based
330
+ try:
331
+ return self._get("/api/me.json", params={"raw_json": 1})
332
+ except RedditApiError:
333
+ # Fallback: try to get identity from cookie-based session
334
+ return {"error": "Identity endpoint requires OAuth token"}
335
+
336
+ # ── Write actions (require authentication) ──────────────────────
337
+
338
+ def vote(self, fullname: str, direction: int) -> dict:
339
+ """Vote on a post or comment. direction: 1=upvote, 0=unvote, -1=downvote."""
340
+ return self._post(VOTE_URL, data={"id": fullname, "dir": str(direction)})
341
+
342
+ def save_item(self, fullname: str) -> dict:
343
+ """Save a post or comment."""
344
+ return self._post(SAVE_URL, data={"id": fullname})
345
+
346
+ def unsave_item(self, fullname: str) -> dict:
347
+ """Unsave a post or comment."""
348
+ return self._post(UNSAVE_URL, data={"id": fullname})
349
+
350
+ def subscribe(self, subreddit: str, action: str = "sub") -> dict:
351
+ """Subscribe or unsubscribe. action: 'sub' or 'unsub'."""
352
+ return self._post(SUBSCRIBE_URL, data={"sr_name": subreddit, "action": action})
353
+
354
+ def post_comment(self, parent_fullname: str, text: str) -> dict:
355
+ """Post a comment."""
356
+ return self._post(COMMENT_URL, data={"parent": parent_fullname, "text": text})
File without changes