cli-web-reddit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ """HTTP client for Reddit's public and authenticated OAuth APIs.
2
+
3
+ Uses curl_cffi with Chrome TLS impersonation to bypass bot detection.
4
+ Authenticated calls use oauth.reddit.com with Bearer token.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from curl_cffi import requests as curl_requests
10
+
11
+ from .auth import get_bearer_token, refresh_token
12
+ from .exceptions import (
13
+ AuthError,
14
+ NetworkError,
15
+ NotFoundError,
16
+ RateLimitError,
17
+ RedditError,
18
+ ServerError,
19
+ )
20
+
21
+ BASE_URL = "https://www.reddit.com"
22
+ OAUTH_URL = "https://oauth.reddit.com"
23
+
24
+
25
+ class RedditClient:
26
+ """Client for Reddit's public JSON API and authenticated OAuth API."""
27
+
28
+ def __init__(self) -> None:
29
+ self._session = curl_requests.Session(
30
+ impersonate="chrome",
31
+ timeout=30,
32
+ )
33
+ # Warm up session — Reddit requires initial request to set cookies/tokens
34
+ # before JSON API calls succeed. A quick GET to the homepage primes the session.
35
+ try:
36
+ self._session.get(f"{BASE_URL}/", timeout=10)
37
+ except Exception:
38
+ pass
39
+
40
+ def close(self) -> None:
41
+ self._session.close()
42
+
43
+ def __enter__(self) -> RedditClient:
44
+ return self
45
+
46
+ def __exit__(self, *args) -> None:
47
+ self.close()
48
+
49
+ # ── HTTP helpers ────────────────────────────────────────────
50
+
51
+ def _get(self, path: str, params: dict | None = None) -> dict | list:
52
+ url = f"{BASE_URL}{path}"
53
+ try:
54
+ resp = self._session.get(url, params=params)
55
+ except Exception as exc:
56
+ raise NetworkError(f"Request failed: {exc}") from exc
57
+ return self._handle_response(resp, path)
58
+
59
+ def _handle_response(self, resp, path: str = "") -> dict | list:
60
+ if resp.status_code == 401:
61
+ raise AuthError(
62
+ "Authentication required. Run: cli-web-reddit auth login", recoverable=True
63
+ )
64
+ if resp.status_code == 403:
65
+ raise AuthError(
66
+ "Access denied. Token may have expired. Run: cli-web-reddit auth login",
67
+ recoverable=True,
68
+ )
69
+ if resp.status_code == 404:
70
+ raise NotFoundError(f"Not found: {path}")
71
+ if resp.status_code == 429:
72
+ retry = resp.headers.get("retry-after")
73
+ raise RateLimitError(
74
+ "Rate limited by Reddit",
75
+ retry_after=float(retry) if retry else None,
76
+ )
77
+ if resp.status_code >= 500:
78
+ raise ServerError(f"Server error {resp.status_code}", status_code=resp.status_code)
79
+ if resp.status_code >= 400:
80
+ raise RedditError(f"HTTP {resp.status_code}: {resp.text[:200]}")
81
+ return resp.json()
82
+
83
+ def _get_listing(
84
+ self,
85
+ path: str,
86
+ limit: int = 25,
87
+ after: str | None = None,
88
+ extra_params: dict | None = None,
89
+ ) -> dict:
90
+ """Fetch a Reddit Listing and return raw response."""
91
+ params: dict = {"limit": limit}
92
+ if after:
93
+ params["after"] = after
94
+ if extra_params:
95
+ params.update(extra_params)
96
+ return self._get(path, params=params)
97
+
98
+ # ── OAuth helpers (authenticated) ────────────────────────────
99
+
100
+ def _oauth_headers(self) -> dict:
101
+ """Get OAuth bearer headers. Raises AuthError if not logged in."""
102
+ token = get_bearer_token()
103
+ if not token:
104
+ raise AuthError("Not logged in. Run: cli-web-reddit auth login")
105
+ return {
106
+ "Authorization": f"Bearer {token}",
107
+ "User-Agent": "cli-web-reddit/0.2.0",
108
+ }
109
+
110
+ def _oauth_get(self, path: str, params: dict | None = None) -> dict | list:
111
+ """Authenticated GET to oauth.reddit.com. Retries once on recoverable AuthError."""
112
+ return self._oauth_request("GET", path, params=params)
113
+
114
+ def _oauth_request(
115
+ self, method: str, path: str, params: dict | None = None, data: dict | None = None
116
+ ) -> dict | list:
117
+ """Execute an authenticated request with auto-refresh on token expiry.
118
+
119
+ Flow: try → if 401/403 retry once → if still failing, refresh token_v2
120
+ via headless browser → retry with new token → if still 403, check if
121
+ it's a permission issue (token valid but endpoint denied).
122
+ """
123
+ url = f"{OAUTH_URL}{path}"
124
+ last_resp = None
125
+ for attempt in range(3): # 0=first try, 1=quick retry, 2=after refresh
126
+ try:
127
+ if method == "GET":
128
+ last_resp = self._session.get(url, headers=self._oauth_headers(), params=params)
129
+ else:
130
+ last_resp = self._session.post(url, headers=self._oauth_headers(), data=data)
131
+ except AuthError:
132
+ if attempt < 2:
133
+ # Token might be missing — try refresh
134
+ new_token = refresh_token()
135
+ if new_token and attempt == 1:
136
+ continue
137
+ raise
138
+ except Exception as exc:
139
+ raise NetworkError(f"Request failed: {exc}") from exc
140
+ try:
141
+ return self._handle_response(last_resp, path)
142
+ except AuthError as exc:
143
+ if attempt == 0 and exc.recoverable:
144
+ continue # quick retry
145
+ if attempt == 1 and exc.recoverable:
146
+ # Token truly expired — try headless browser refresh
147
+ new_token = refresh_token()
148
+ if new_token:
149
+ continue # retry with refreshed token
150
+ # After all retries failed on 403, distinguish permission vs auth
151
+ if last_resp is not None and last_resp.status_code == 403:
152
+ try:
153
+ me_resp = self._session.get(
154
+ f"{OAUTH_URL}/api/v1/me",
155
+ headers=self._oauth_headers(),
156
+ )
157
+ if me_resp.status_code == 200:
158
+ raise RedditError(
159
+ f"Permission denied for {path}. "
160
+ f"Your account may not have access to this resource."
161
+ ) from exc
162
+ except (AuthError, NetworkError):
163
+ pass # token truly expired
164
+ raise
165
+
166
+ def _oauth_get_listing(
167
+ self,
168
+ path: str,
169
+ limit: int = 25,
170
+ after: str | None = None,
171
+ extra_params: dict | None = None,
172
+ ) -> dict:
173
+ params: dict = {"limit": limit}
174
+ if after:
175
+ params["after"] = after
176
+ if extra_params:
177
+ params.update(extra_params)
178
+ return self._oauth_get(path, params=params)
179
+
180
+ def _oauth_post(self, path: str, data: dict | None = None) -> dict:
181
+ """Authenticated POST to oauth.reddit.com. Retries once on recoverable AuthError."""
182
+ return self._oauth_request("POST", path, data=data)
183
+
184
+ # ── Feed ──────────────────────────────────────────────────
185
+
186
+ def feed_hot(self, limit: int = 25, after: str | None = None) -> dict:
187
+ return self._get_listing("/hot/.json", limit=limit, after=after)
188
+
189
+ def feed_new(self, limit: int = 25, after: str | None = None) -> dict:
190
+ return self._get_listing("/new/.json", limit=limit, after=after)
191
+
192
+ def feed_top(self, limit: int = 25, after: str | None = None, time: str = "day") -> dict:
193
+ return self._get_listing("/top/.json", limit=limit, after=after, extra_params={"t": time})
194
+
195
+ def feed_rising(self, limit: int = 25, after: str | None = None) -> dict:
196
+ return self._get_listing("/rising/.json", limit=limit, after=after)
197
+
198
+ def feed_popular(self, limit: int = 25, after: str | None = None) -> dict:
199
+ return self._get_listing("/r/popular/.json", limit=limit, after=after)
200
+
201
+ # ── Subreddit ──────────────────────────────────────────────
202
+
203
+ def sub_posts(
204
+ self,
205
+ name: str,
206
+ sort: str = "hot",
207
+ limit: int = 25,
208
+ after: str | None = None,
209
+ time: str | None = None,
210
+ ) -> dict:
211
+ path = f"/r/{name}/{sort}/.json"
212
+ extra = {}
213
+ if time and sort in ("top", "controversial"):
214
+ extra["t"] = time
215
+ return self._get_listing(path, limit=limit, after=after, extra_params=extra or None)
216
+
217
+ def sub_info(self, name: str) -> dict:
218
+ return self._get(f"/r/{name}/about.json")
219
+
220
+ def sub_rules(self, name: str) -> dict:
221
+ return self._get(f"/r/{name}/about/rules.json")
222
+
223
+ def sub_search(
224
+ self,
225
+ name: str,
226
+ query: str,
227
+ limit: int = 25,
228
+ sort: str = "relevance",
229
+ after: str | None = None,
230
+ ) -> dict:
231
+ return self._get_listing(
232
+ f"/r/{name}/search.json",
233
+ limit=limit,
234
+ after=after,
235
+ extra_params={"q": query, "restrict_sr": "on", "sort": sort},
236
+ )
237
+
238
+ def sub_join(self, name: str) -> dict:
239
+ """Subscribe to a subreddit (requires auth)."""
240
+ return self._oauth_post("/api/subscribe", data={"sr_name": name, "action": "sub"})
241
+
242
+ def sub_leave(self, name: str) -> dict:
243
+ """Unsubscribe from a subreddit (requires auth)."""
244
+ return self._oauth_post("/api/subscribe", data={"sr_name": name, "action": "unsub"})
245
+
246
+ # ── Search ──────────────────────────────────────────────────
247
+
248
+ def search_posts(
249
+ self,
250
+ query: str,
251
+ limit: int = 25,
252
+ sort: str = "relevance",
253
+ time: str | None = None,
254
+ after: str | None = None,
255
+ ) -> dict:
256
+ extra: dict = {"q": query, "sort": sort}
257
+ if time:
258
+ extra["t"] = time
259
+ return self._get_listing("/search.json", limit=limit, after=after, extra_params=extra)
260
+
261
+ def search_subreddits(
262
+ self,
263
+ query: str,
264
+ limit: int = 25,
265
+ after: str | None = None,
266
+ ) -> dict:
267
+ return self._get_listing(
268
+ "/subreddits/search.json",
269
+ limit=limit,
270
+ after=after,
271
+ extra_params={"q": query},
272
+ )
273
+
274
+ # ── User ──────────────────────────────────────────────────
275
+
276
+ def user_about(self, username: str) -> dict:
277
+ return self._get(f"/user/{username}/about.json")
278
+
279
+ def user_posts(
280
+ self,
281
+ username: str,
282
+ limit: int = 25,
283
+ after: str | None = None,
284
+ sort: str = "new",
285
+ time: str | None = None,
286
+ ) -> dict:
287
+ extra: dict = {"sort": sort}
288
+ if time:
289
+ extra["t"] = time
290
+ return self._get_listing(
291
+ f"/user/{username}/submitted.json",
292
+ limit=limit,
293
+ after=after,
294
+ extra_params=extra,
295
+ )
296
+
297
+ def user_comments(
298
+ self,
299
+ username: str,
300
+ limit: int = 25,
301
+ after: str | None = None,
302
+ sort: str = "new",
303
+ time: str | None = None,
304
+ ) -> dict:
305
+ extra: dict = {"sort": sort}
306
+ if time:
307
+ extra["t"] = time
308
+ return self._get_listing(
309
+ f"/user/{username}/comments.json",
310
+ limit=limit,
311
+ after=after,
312
+ extra_params=extra,
313
+ )
314
+
315
+ # ── Post detail ──────────────────────────────────────────────
316
+
317
+ def post_detail(
318
+ self,
319
+ subreddit: str,
320
+ post_id: str,
321
+ slug: str = "",
322
+ comment_limit: int = 50,
323
+ depth: int | None = None,
324
+ ) -> list:
325
+ """Get post + comments. Returns [post_listing, comments_listing].
326
+
327
+ If subreddit is empty, uses /comments/{id}.json which works without
328
+ knowing the subreddit (Reddit redirects internally).
329
+ """
330
+ if subreddit:
331
+ path = f"/r/{subreddit}/comments/{post_id}/{slug}.json"
332
+ else:
333
+ path = f"/comments/{post_id}.json"
334
+ params: dict = {"limit": comment_limit}
335
+ if depth is not None:
336
+ params["depth"] = depth
337
+ return self._get(path, params=params)
338
+
339
+ def more_children(self, link_id: str, children_ids: list[str]) -> list[dict]:
340
+ """Fetch collapsed/hidden comment children via /api/morechildren.json.
341
+
342
+ Reddit returns 'more' objects when comments are too deeply nested.
343
+ This fetches the actual comment data for those IDs.
344
+ """
345
+ if not children_ids:
346
+ return []
347
+ path = "/api/morechildren.json"
348
+ params = {
349
+ "api_type": "json",
350
+ "link_id": link_id if link_id.startswith("t3_") else f"t3_{link_id}",
351
+ "children": ",".join(children_ids[:100]), # Reddit limit: 100 per call
352
+ }
353
+ resp = self._get(path, params=params)
354
+ # Response: {"json": {"data": {"things": [{"kind": "t1", "data": {...}}, ...]}}}
355
+ if isinstance(resp, dict):
356
+ return resp.get("json", {}).get("data", {}).get("things", [])
357
+ return []
358
+
359
+ def comment_thread(
360
+ self, post_id: str, comment_id: str, context: int = 0, depth: int = 20
361
+ ) -> list:
362
+ """Fetch a specific comment thread via permalink .json.
363
+
364
+ Used to expand 'continue this thread' links (more objects with empty IDs).
365
+ Returns [post_listing, comment_listing] like post_detail.
366
+ """
367
+ path = f"/comments/{post_id}/_/{comment_id}.json"
368
+ return self._get(path, params={"context": context, "depth": depth})
369
+
370
+ # ── Authenticated: Me ────────────────────────────────────────
371
+
372
+ def me(self) -> dict:
373
+ """Get current user's profile (requires auth)."""
374
+ return self._oauth_get("/api/v1/me")
375
+
376
+ def me_saved(self, limit: int = 25, after: str | None = None) -> dict:
377
+ """Get current user's saved items (requires auth)."""
378
+ me = self.me()
379
+ return self._oauth_get_listing(f"/user/{me['name']}/saved", limit=limit, after=after)
380
+
381
+ def me_upvoted(self, limit: int = 25, after: str | None = None) -> dict:
382
+ """Get current user's upvoted items (requires auth)."""
383
+ me = self.me()
384
+ return self._oauth_get_listing(f"/user/{me['name']}/upvoted", limit=limit, after=after)
385
+
386
+ def me_subscriptions(self, limit: int = 100, after: str | None = None) -> dict:
387
+ """Get user's subscribed subreddits (requires auth)."""
388
+ return self._oauth_get_listing("/subreddits/mine/subscriber", limit=limit, after=after)
389
+
390
+ def me_inbox(self, limit: int = 25, after: str | None = None) -> dict:
391
+ """Get inbox messages (requires auth)."""
392
+ return self._oauth_get_listing("/message/inbox", limit=limit, after=after)
393
+
394
+ # ── Authenticated: Vote ──────────────────────────────────────
395
+
396
+ def vote(self, thing_id: str, direction: int) -> dict:
397
+ """Vote on a post or comment. direction: 1=up, -1=down, 0=unvote."""
398
+ return self._oauth_post("/api/vote", data={"id": thing_id, "dir": direction})
399
+
400
+ # ── Authenticated: Save ──────────────────────────────────────
401
+
402
+ def save(self, thing_id: str) -> dict:
403
+ """Save a post or comment (requires auth)."""
404
+ return self._oauth_post("/api/save", data={"id": thing_id})
405
+
406
+ def unsave(self, thing_id: str) -> dict:
407
+ """Unsave a post or comment (requires auth)."""
408
+ return self._oauth_post("/api/unsave", data={"id": thing_id})
409
+
410
+ # ── Authenticated: Submit ────────────────────────────────────
411
+
412
+ def get_subreddit_flairs(self, subreddit: str) -> list[dict]:
413
+ """Get available link flairs for a subreddit (requires auth)."""
414
+ result = self._oauth_get(f"/r/{subreddit}/api/link_flair_v2")
415
+ if not isinstance(result, list):
416
+ return []
417
+ return [{"id": f.get("id", ""), "text": f.get("text", "")} for f in result if f.get("id")]
418
+
419
+ def submit_text(
420
+ self, subreddit: str, title: str, text: str, flair_id: str | None = None
421
+ ) -> dict:
422
+ """Submit a text post (requires auth)."""
423
+ data = {
424
+ "sr": subreddit,
425
+ "kind": "self",
426
+ "title": title,
427
+ "text": text,
428
+ "api_type": "json",
429
+ }
430
+ if flair_id:
431
+ data["flair_id"] = flair_id
432
+ return self._oauth_post("/api/submit", data=data)
433
+
434
+ def submit_link(
435
+ self, subreddit: str, title: str, url: str, flair_id: str | None = None
436
+ ) -> dict:
437
+ """Submit a link post (requires auth)."""
438
+ data = {
439
+ "sr": subreddit,
440
+ "kind": "link",
441
+ "title": title,
442
+ "url": url,
443
+ "api_type": "json",
444
+ }
445
+ if flair_id:
446
+ data["flair_id"] = flair_id
447
+ return self._oauth_post("/api/submit", data=data)
448
+
449
+ # ── Authenticated: Comment ───────────────────────────────────
450
+
451
+ def comment(self, thing_id: str, text: str) -> dict:
452
+ """Add a comment to a post or reply to a comment (requires auth)."""
453
+ return self._oauth_post(
454
+ "/api/comment",
455
+ data={
456
+ "thing_id": thing_id,
457
+ "text": text,
458
+ "api_type": "json",
459
+ },
460
+ )
461
+
462
+ def edit(self, thing_id: str, text: str) -> dict:
463
+ """Edit own post or comment text (requires auth)."""
464
+ return self._oauth_post(
465
+ "/api/editusertext",
466
+ data={
467
+ "thing_id": thing_id,
468
+ "text": text,
469
+ "api_type": "json",
470
+ },
471
+ )
472
+
473
+ def delete(self, thing_id: str) -> dict:
474
+ """Delete own post or comment (requires auth)."""
475
+ return self._oauth_post("/api/del", data={"id": thing_id})
@@ -0,0 +1,63 @@
1
+ """Domain-specific exception hierarchy for cli-web-reddit."""
2
+
3
+
4
+ class RedditError(Exception):
5
+ """Base for all Reddit CLI errors."""
6
+
7
+ def __init__(self, message: str, code: str = "REDDIT_ERROR"):
8
+ self.message = message
9
+ self.code = code
10
+ super().__init__(message)
11
+
12
+ def to_dict(self) -> dict:
13
+ return {"error": True, "code": self.code, "message": self.message}
14
+
15
+
16
+ class AuthError(RedditError):
17
+ """401/403 — authentication required or expired."""
18
+
19
+ def __init__(self, message: str, recoverable: bool = False):
20
+ self.recoverable = recoverable
21
+ super().__init__(message, "AUTH_EXPIRED")
22
+
23
+
24
+ class RateLimitError(RedditError):
25
+ """429 — retry with backoff."""
26
+
27
+ def __init__(self, message: str, retry_after: float | None = None):
28
+ self.retry_after = retry_after
29
+ super().__init__(message, "RATE_LIMITED")
30
+
31
+ def to_dict(self) -> dict:
32
+ d = super().to_dict()
33
+ d["retry_after"] = self.retry_after
34
+ return d
35
+
36
+
37
+ class NetworkError(RedditError):
38
+ """Connection/DNS/timeout errors."""
39
+
40
+ def __init__(self, message: str):
41
+ super().__init__(message, "NETWORK_ERROR")
42
+
43
+
44
+ class ServerError(RedditError):
45
+ """5xx responses."""
46
+
47
+ def __init__(self, message: str, status_code: int = 500):
48
+ self.status_code = status_code
49
+ super().__init__(message, "SERVER_ERROR")
50
+
51
+
52
+ class NotFoundError(RedditError):
53
+ """404 — resource not found."""
54
+
55
+ def __init__(self, message: str = "Resource not found"):
56
+ super().__init__(message, "NOT_FOUND")
57
+
58
+
59
+ class SubmitError(RedditError):
60
+ """Reddit rejected a submit/comment action."""
61
+
62
+ def __init__(self, message: str):
63
+ super().__init__(message, "SUBMIT_ERROR")