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.
- cli_web/reddit/README.md +68 -0
- cli_web/reddit/__init__.py +3 -0
- cli_web/reddit/__main__.py +6 -0
- cli_web/reddit/commands/__init__.py +0 -0
- cli_web/reddit/commands/actions.py +268 -0
- cli_web/reddit/commands/auth_cmd.py +73 -0
- cli_web/reddit/commands/feed.py +115 -0
- cli_web/reddit/commands/me.py +139 -0
- cli_web/reddit/commands/post.py +93 -0
- cli_web/reddit/commands/search.py +66 -0
- cli_web/reddit/commands/subreddit.py +184 -0
- cli_web/reddit/commands/user.py +90 -0
- cli_web/reddit/core/__init__.py +0 -0
- cli_web/reddit/core/auth.py +204 -0
- cli_web/reddit/core/client.py +475 -0
- cli_web/reddit/core/exceptions.py +63 -0
- cli_web/reddit/core/models.py +253 -0
- cli_web/reddit/reddit_cli.py +174 -0
- cli_web/reddit/skills/SKILL.md +143 -0
- cli_web/reddit/tests/TEST.md +109 -0
- cli_web/reddit/tests/__init__.py +0 -0
- cli_web/reddit/tests/conftest.py +9 -0
- cli_web/reddit/tests/test_core.py +568 -0
- cli_web/reddit/tests/test_e2e.py +312 -0
- cli_web/reddit/utils/__init__.py +0 -0
- cli_web/reddit/utils/doctor.py +188 -0
- cli_web/reddit/utils/helpers.py +91 -0
- cli_web/reddit/utils/mcp_server.py +290 -0
- cli_web/reddit/utils/output.py +133 -0
- cli_web/reddit/utils/repl_skin.py +486 -0
- cli_web_reddit-0.1.0.dist-info/METADATA +15 -0
- cli_web_reddit-0.1.0.dist-info/RECORD +35 -0
- cli_web_reddit-0.1.0.dist-info/WHEEL +5 -0
- cli_web_reddit-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_reddit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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")
|