rdt-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (40) hide show
  1. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/PKG-INFO +18 -4
  2. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/README.md +17 -3
  3. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/SKILL.md +17 -4
  4. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/pyproject.toml +1 -1
  5. rdt_cli-0.3.0/rdt_cli/__init__.py +3 -0
  6. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/auth.py +36 -5
  7. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/cli.py +5 -1
  8. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/client.py +114 -119
  9. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/auth.py +48 -13
  10. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/browse.py +104 -31
  11. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/post.py +89 -52
  12. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/search.py +15 -14
  13. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/social.py +15 -16
  14. rdt_cli-0.3.0/rdt_cli/config.py +19 -0
  15. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/constants.py +1 -0
  16. rdt_cli-0.3.0/rdt_cli/fingerprint.py +60 -0
  17. rdt_cli-0.3.0/rdt_cli/models.py +114 -0
  18. rdt_cli-0.3.0/rdt_cli/parser.py +176 -0
  19. rdt_cli-0.3.0/rdt_cli/session.py +111 -0
  20. rdt_cli-0.3.0/rdt_cli/transports.py +170 -0
  21. rdt_cli-0.3.0/tests/fixtures/listing.json +48 -0
  22. rdt_cli-0.3.0/tests/fixtures/morechildren.json +34 -0
  23. rdt_cli-0.3.0/tests/fixtures/post_detail.json +69 -0
  24. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/tests/test_cli.py +72 -12
  25. rdt_cli-0.3.0/tests/test_client.py +69 -0
  26. rdt_cli-0.3.0/tests/test_parser.py +77 -0
  27. rdt_cli-0.3.0/tests/test_session.py +41 -0
  28. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/tests/test_smoke.py +281 -0
  29. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/uv.lock +1 -1
  30. rdt_cli-0.2.0/rdt_cli/__init__.py +0 -3
  31. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  32. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  33. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/.gitignore +0 -0
  34. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/SCHEMA.md +0 -0
  35. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/__main__.py +0 -0
  36. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/__init__.py +0 -0
  37. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/_common.py +0 -0
  38. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/exceptions.py +0 -0
  39. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/index_cache.py +0 -0
  40. {rdt_cli-0.2.0 → rdt_cli-0.3.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rdt-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A CLI for Reddit — browse feeds, read posts, search, and interact via terminal 📖
5
5
  Project-URL: Homepage, https://github.com/jackwener/rdt-cli
6
6
  Project-URL: Repository, https://github.com/jackwener/rdt-cli
@@ -45,10 +45,11 @@ A CLI for Reddit — browse feeds, read posts, search, and interact via reverse-
45
45
  - 🏠 **Feed** — browse home feed, popular, and /r/all
46
46
  - 📋 **Subreddits** — browse any subreddit with sort/time filters, view subreddit info
47
47
  - 📰 **Posts** — read posts and comment trees with syntax highlighting
48
+ - 💬 **Expanded comments** — `--expand-more` loads additional `more comments` entries
48
49
  - 🔢 **Short-index navigation** — `rdt show 3` to read, `rdt open 3` to browser
49
50
  - 🔍 **Search** — full-text search with subreddit, sort, and time filters
50
51
  - 📤 **Export** — export search results to CSV or JSON; `-o file.json` on any listing
51
- - 👤 **Users** — view user profiles and post history
52
+ - 👤 **Users** — view user profiles, post history, comment history, saved and upvoted items
52
53
  - ⬆️ **Interactions** — upvote/downvote, save/unsave, subscribe/unsubscribe, comment (with 1.5-4s rate-limit delay)
53
54
  - 🛡️ **Anti-detection** — consistent Chrome 133 fingerprint, `sec-ch-ua` alignment, Gaussian jitter, exponential backoff
54
55
  - 📊 **Structured output** — `--yaml`, `--json`, `--output FILE`, `--compact`, `--full-text`
@@ -102,6 +103,9 @@ rdt sub programming -s top -t week # Sort + time filter
102
103
  rdt sub-info python # Subreddit info (subscribers, etc.)
103
104
  rdt user spez # User profile
104
105
  rdt user-posts spez # User's submitted posts
106
+ rdt user-comments spez # User's comments
107
+ rdt saved # Your saved posts/items
108
+ rdt upvoted # Your upvoted posts
105
109
 
106
110
  # Short index works after list commands (feed/popular/sub/search)
107
111
  rdt sub python
@@ -111,7 +115,9 @@ rdt upvote 1 # Upvote post #1
111
115
 
112
116
  # ─── Reading ──────────────────────────────────────
113
117
  rdt read 1abc123 # Read post by ID
118
+ rdt read 1abc123 --expand-more # Expand top-level "more comments"
114
119
  rdt show 3 # Read result #3 from last listing
120
+ rdt show 3 --expand-more # Expand additional comments from cache-backed post
115
121
  rdt show 1 -s top # Sort comments by top
116
122
  rdt open 3 # Open in browser
117
123
 
@@ -226,7 +232,7 @@ rdt_cli/
226
232
  └── commands/
227
233
  ├── _common.py # Shared helpers (envelope, output routing, formatters)
228
234
  ├── auth.py # login, logout, status, whoami
229
- ├── browse.py # feed, popular, all, sub, sub-info, user, user-posts, open
235
+ ├── browse.py # feed, popular, all, sub, sub-info, user, user-posts, user-comments, saved, upvoted, open
230
236
  ├── post.py # read, show
231
237
  ├── search.py # search, export
232
238
  └── social.py # upvote, save, subscribe, comment
@@ -291,10 +297,11 @@ The built-in Gaussian jitter delay (~1s between requests) is intentional to mimi
291
297
  - 🏠 **浏览** — 首页 Feed、Popular、/r/all
292
298
  - 📋 **子版块** — 浏览任意 subreddit(排序/时间过滤),查看子版块信息
293
299
  - 📰 **帖子** — 阅读帖子和评论树
300
+ - 💬 **评论展开** — `--expand-more` 可展开额外评论
294
301
  - 🔢 **短索引导航** — `rdt show 3` 阅读、`rdt open 3` 浏览器打开
295
302
  - 🔍 **搜索** — 全文搜索,支持子版块、排序、时间过滤
296
303
  - 📤 **导出** — 搜索结果导出为 CSV 或 JSON
297
- - 👤 **用户** — 查看用户资料和发帖历史
304
+ - 👤 **用户** — 查看用户资料、发帖历史、评论历史、收藏和点赞记录
298
305
  - ⬆️ **互动** — 点赞/踩、收藏、订阅、评论
299
306
  - 🛡️ **反风控** — Chrome 133 指纹一致性、高斯抖动延迟、指数退避重试
300
307
  - 📊 **结构化输出** — `--yaml` / `--json`,非 TTY 默认输出 YAML
@@ -341,10 +348,17 @@ rdt all # /r/all
341
348
  rdt sub python # 浏览子版块
342
349
  rdt sub programming -s top -t week # 排序 + 时间过滤
343
350
  rdt sub-info python # 子版块信息
351
+ rdt user spez # 用户资料
352
+ rdt user-posts spez # 用户发帖
353
+ rdt user-comments spez # 用户评论
354
+ rdt saved # 你的收藏
355
+ rdt upvoted # 你的点赞
344
356
 
345
357
  # 阅读
346
358
  rdt read 1abc123 # 按 ID 阅读帖子
359
+ rdt read 1abc123 --expand-more # 展开更多评论
347
360
  rdt show 3 # 阅读最近一次列表里的第 3 条
361
+ rdt show 3 --expand-more # 展开缓存帖子里的更多评论
348
362
  rdt open 3 # 在浏览器打开
349
363
 
350
364
  # 搜索
@@ -22,10 +22,11 @@ A CLI for Reddit — browse feeds, read posts, search, and interact via reverse-
22
22
  - 🏠 **Feed** — browse home feed, popular, and /r/all
23
23
  - 📋 **Subreddits** — browse any subreddit with sort/time filters, view subreddit info
24
24
  - 📰 **Posts** — read posts and comment trees with syntax highlighting
25
+ - 💬 **Expanded comments** — `--expand-more` loads additional `more comments` entries
25
26
  - 🔢 **Short-index navigation** — `rdt show 3` to read, `rdt open 3` to browser
26
27
  - 🔍 **Search** — full-text search with subreddit, sort, and time filters
27
28
  - 📤 **Export** — export search results to CSV or JSON; `-o file.json` on any listing
28
- - 👤 **Users** — view user profiles and post history
29
+ - 👤 **Users** — view user profiles, post history, comment history, saved and upvoted items
29
30
  - ⬆️ **Interactions** — upvote/downvote, save/unsave, subscribe/unsubscribe, comment (with 1.5-4s rate-limit delay)
30
31
  - 🛡️ **Anti-detection** — consistent Chrome 133 fingerprint, `sec-ch-ua` alignment, Gaussian jitter, exponential backoff
31
32
  - 📊 **Structured output** — `--yaml`, `--json`, `--output FILE`, `--compact`, `--full-text`
@@ -79,6 +80,9 @@ rdt sub programming -s top -t week # Sort + time filter
79
80
  rdt sub-info python # Subreddit info (subscribers, etc.)
80
81
  rdt user spez # User profile
81
82
  rdt user-posts spez # User's submitted posts
83
+ rdt user-comments spez # User's comments
84
+ rdt saved # Your saved posts/items
85
+ rdt upvoted # Your upvoted posts
82
86
 
83
87
  # Short index works after list commands (feed/popular/sub/search)
84
88
  rdt sub python
@@ -88,7 +92,9 @@ rdt upvote 1 # Upvote post #1
88
92
 
89
93
  # ─── Reading ──────────────────────────────────────
90
94
  rdt read 1abc123 # Read post by ID
95
+ rdt read 1abc123 --expand-more # Expand top-level "more comments"
91
96
  rdt show 3 # Read result #3 from last listing
97
+ rdt show 3 --expand-more # Expand additional comments from cache-backed post
92
98
  rdt show 1 -s top # Sort comments by top
93
99
  rdt open 3 # Open in browser
94
100
 
@@ -203,7 +209,7 @@ rdt_cli/
203
209
  └── commands/
204
210
  ├── _common.py # Shared helpers (envelope, output routing, formatters)
205
211
  ├── auth.py # login, logout, status, whoami
206
- ├── browse.py # feed, popular, all, sub, sub-info, user, user-posts, open
212
+ ├── browse.py # feed, popular, all, sub, sub-info, user, user-posts, user-comments, saved, upvoted, open
207
213
  ├── post.py # read, show
208
214
  ├── search.py # search, export
209
215
  └── social.py # upvote, save, subscribe, comment
@@ -268,10 +274,11 @@ The built-in Gaussian jitter delay (~1s between requests) is intentional to mimi
268
274
  - 🏠 **浏览** — 首页 Feed、Popular、/r/all
269
275
  - 📋 **子版块** — 浏览任意 subreddit(排序/时间过滤),查看子版块信息
270
276
  - 📰 **帖子** — 阅读帖子和评论树
277
+ - 💬 **评论展开** — `--expand-more` 可展开额外评论
271
278
  - 🔢 **短索引导航** — `rdt show 3` 阅读、`rdt open 3` 浏览器打开
272
279
  - 🔍 **搜索** — 全文搜索,支持子版块、排序、时间过滤
273
280
  - 📤 **导出** — 搜索结果导出为 CSV 或 JSON
274
- - 👤 **用户** — 查看用户资料和发帖历史
281
+ - 👤 **用户** — 查看用户资料、发帖历史、评论历史、收藏和点赞记录
275
282
  - ⬆️ **互动** — 点赞/踩、收藏、订阅、评论
276
283
  - 🛡️ **反风控** — Chrome 133 指纹一致性、高斯抖动延迟、指数退避重试
277
284
  - 📊 **结构化输出** — `--yaml` / `--json`,非 TTY 默认输出 YAML
@@ -318,10 +325,17 @@ rdt all # /r/all
318
325
  rdt sub python # 浏览子版块
319
326
  rdt sub programming -s top -t week # 排序 + 时间过滤
320
327
  rdt sub-info python # 子版块信息
328
+ rdt user spez # 用户资料
329
+ rdt user-posts spez # 用户发帖
330
+ rdt user-comments spez # 用户评论
331
+ rdt saved # 你的收藏
332
+ rdt upvoted # 你的点赞
321
333
 
322
334
  # 阅读
323
335
  rdt read 1abc123 # 按 ID 阅读帖子
336
+ rdt read 1abc123 --expand-more # 展开更多评论
324
337
  rdt show 3 # 阅读最近一次列表里的第 3 条
338
+ rdt show 3 --expand-more # 展开缓存帖子里的更多评论
325
339
  rdt open 3 # 在浏览器打开
326
340
 
327
341
  # 搜索
@@ -51,7 +51,7 @@ Verify with:
51
51
 
52
52
  ```bash
53
53
  rdt status
54
- rdt whoami --json | jq '.data.name'
54
+ rdt whoami --json | jq '.data.name // .data._session.username'
55
55
  ```
56
56
 
57
57
  ### Step 2: Handle common auth issues
@@ -88,6 +88,9 @@ Payloads live under `.data`.
88
88
  | `rdt sub-info <name>` | View subreddit info | `rdt sub-info rust --json` |
89
89
  | `rdt user <name>` | View user profile | `rdt user spez --json` |
90
90
  | `rdt user-posts <name>` | View user's posts | `rdt user-posts spez -n 5 --json` |
91
+ | `rdt user-comments <name>` | View user's comments | `rdt user-comments spez -n 5 --json` |
92
+ | `rdt saved` | View your saved items | `rdt saved -n 10 --json` |
93
+ | `rdt upvoted` | View your upvoted posts | `rdt upvoted -n 10 --json` |
91
94
  | `rdt open <id_or_index>` | Open post in browser | `rdt open 3` |
92
95
 
93
96
  ### Reading
@@ -95,7 +98,9 @@ Payloads live under `.data`.
95
98
  | Command | Description | Example |
96
99
  |---------|-------------|---------|
97
100
  | `rdt read <post_id>` | Read a post + comments | `rdt read 1abc123 --json` |
98
- | `rdt show <index>` | Read by short-index | `rdt show 3 --full-text` |
101
+ | `rdt read <post_id> --expand-more` | Expand top-level `more comments` | `rdt read 1abc123 --expand-more --json` |
102
+ | `rdt show <index>` | Read by short-index | `rdt show 3` |
103
+ | `rdt show <index> --expand-more` | Expand `more comments` from cached result | `rdt show 3 --expand-more --json` |
99
104
  | `rdt whoami` | View your profile (karma, age) | `rdt whoami --json` |
100
105
 
101
106
  ### Search & Export
@@ -131,7 +136,7 @@ Payloads live under `.data`.
131
136
 
132
137
  ## Listing Options
133
138
 
134
- All listing commands (feed, popular, all, sub, user-posts, search) support:
139
+ All listing commands (feed, popular, all, sub, user-posts, user-comments, saved, upvoted, search) support:
135
140
 
136
141
  | Flag | Description |
137
142
  |------|-------------|
@@ -147,7 +152,7 @@ All listing commands (feed, popular, all, sub, user-posts, search) support:
147
152
 
148
153
  ```bash
149
154
  rdt sub python -s top -t week -n 5
150
- rdt show 1
155
+ rdt show 1 --expand-more
151
156
  rdt upvote 1
152
157
  ```
153
158
 
@@ -170,6 +175,14 @@ rdt search "python tips" -n 20 --compact -o tips.json
170
175
  ```bash
171
176
  rdt user spez --json | jq '.data | {name, link_karma, comment_karma}'
172
177
  rdt user-posts spez -n 10 --compact --json
178
+ rdt user-comments spez -n 10 --compact --json
179
+ ```
180
+
181
+ ### Saved / Upvoted review
182
+
183
+ ```bash
184
+ rdt saved -n 20 --compact --json
185
+ rdt upvoted -n 20 --compact --json
173
186
  ```
174
187
 
175
188
  ### Subreddit discovery
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rdt-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A CLI for Reddit — browse feeds, read posts, search, and interact via terminal 📖"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,3 @@
1
+ """Reddit CLI."""
2
+
3
+ __version__ = "0.3.0"
@@ -30,19 +30,48 @@ _CREDENTIAL_TTL_SECONDS = CREDENTIAL_TTL_DAYS * 86400
30
30
  class Credential:
31
31
  """Holds Reddit session cookies."""
32
32
 
33
- def __init__(self, cookies: dict[str, str]):
33
+ def __init__(
34
+ self,
35
+ cookies: dict[str, str],
36
+ *,
37
+ source: str = "unknown",
38
+ username: str | None = None,
39
+ modhash: str | None = None,
40
+ saved_at: float | None = None,
41
+ last_verified_at: float | None = None,
42
+ ):
34
43
  self.cookies = cookies
44
+ self.source = source
45
+ self.username = username
46
+ self.modhash = modhash
47
+ self.saved_at = saved_at
48
+ self.last_verified_at = last_verified_at
35
49
 
36
50
  @property
37
51
  def is_valid(self) -> bool:
38
52
  return bool(self.cookies)
39
53
 
40
54
  def to_dict(self) -> dict[str, Any]:
41
- return {"cookies": self.cookies, "saved_at": time.time()}
55
+ saved_at = self.saved_at or time.time()
56
+ return {
57
+ "cookies": self.cookies,
58
+ "source": self.source,
59
+ "username": self.username,
60
+ "modhash": self.modhash,
61
+ "saved_at": saved_at,
62
+ "last_verified_at": self.last_verified_at,
63
+ }
42
64
 
43
65
  @classmethod
44
66
  def from_dict(cls, data: dict[str, Any]) -> Credential:
45
- return cls(cookies=data.get("cookies", {}))
67
+ return cls(
68
+ cookies=data.get("cookies", {}),
69
+ source=data.get("source", "saved"),
70
+ username=data.get("username"),
71
+ modhash=data.get("modhash"),
72
+ saved_at=data.get("saved_at"),
73
+ last_verified_at=data.get("last_verified_at"),
74
+ )
46
75
 
47
76
  def as_cookie_header(self) -> str:
48
77
  return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
@@ -54,6 +83,8 @@ class Credential:
54
83
  def save_credential(credential: Credential) -> None:
55
84
  """Save credential to disk with restricted permissions."""
56
85
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
86
+ if credential.saved_at is None:
87
+ credential.saved_at = time.time()
57
88
  CREDENTIAL_FILE.write_text(json.dumps(credential.to_dict(), indent=2, ensure_ascii=False))
58
89
  CREDENTIAL_FILE.chmod(0o600)
59
90
 
@@ -131,7 +162,7 @@ if cookies:
131
162
  if result.returncode == 0 and result.stdout.strip():
132
163
  cookies = json.loads(result.stdout.strip())
133
164
  if any(k in cookies for k in REQUIRED_COOKIES):
134
- cred = Credential(cookies=cookies)
165
+ cred = Credential(cookies=cookies, source="browser:subprocess")
135
166
  save_credential(cred)
136
167
  return cred
137
168
  except Exception as e:
@@ -152,7 +183,7 @@ def _extract_direct() -> Credential | None:
152
183
  jar = fn(domain_name=".reddit.com")
153
184
  cookies = {c.name: c.value for c in jar}
154
185
  if any(k in cookies for k in REQUIRED_COOKIES):
155
- cred = Credential(cookies=cookies)
186
+ cred = Credential(cookies=cookies, source=f"browser:{fn.__name__}")
156
187
  save_credential(cred)
157
188
  return cred
158
189
  except Exception:
@@ -5,7 +5,8 @@ Usage:
5
5
  rdt feed / popular / all / sub <subreddit>
6
6
  rdt read <post_id> / show <index> / open <id_or_index>
7
7
  rdt search <query> / export <query>
8
- rdt user <username> / user-posts <username>
8
+ rdt user <username> / user-posts <username> / user-comments <username>
9
+ rdt saved / upvoted
9
10
  rdt upvote / save / subscribe / comment
10
11
  """
11
12
 
@@ -48,6 +49,9 @@ cli.add_command(browse.sub)
48
49
  cli.add_command(browse.sub_info)
49
50
  cli.add_command(browse.user)
50
51
  cli.add_command(browse.user_posts)
52
+ cli.add_command(browse.user_comments)
53
+ cli.add_command(browse.saved)
54
+ cli.add_command(browse.upvoted)
51
55
  cli.add_command(browse.open_post)
52
56
 
53
57
  # ─── Post commands ───────────────────────────────────────────────────
@@ -3,19 +3,15 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- import random
7
- import time
8
6
  from typing import Any
9
7
 
10
- import httpx
11
-
8
+ from .config import DEFAULT_CONFIG, RuntimeConfig
12
9
  from .constants import (
13
10
  ALL_URL,
14
- BASE_URL,
15
11
  COMMENT_URL,
16
12
  DEFAULT_LIMIT,
17
- HEADERS,
18
13
  HOME_URL,
14
+ MORECHILDREN_URL,
19
15
  POPULAR_URL,
20
16
  POST_COMMENTS_SHORT_URL,
21
17
  POST_COMMENTS_URL,
@@ -28,14 +24,16 @@ from .constants import (
28
24
  USER_ABOUT_URL,
29
25
  USER_COMMENTS_URL,
30
26
  USER_POSTS_URL,
27
+ USER_SAVED_URL,
28
+ USER_UPVOTED_URL,
31
29
  VOTE_URL,
32
30
  )
33
31
  from .exceptions import (
34
- ForbiddenError,
35
- NotFoundError,
36
32
  RedditApiError,
37
- SessionExpiredError,
38
33
  )
34
+ from .fingerprint import BrowserFingerprint
35
+ from .session import SessionState
36
+ from .transports import ReadTransport, WriteTransport
39
37
 
40
38
  logger = logging.getLogger(__name__)
41
39
 
@@ -62,125 +60,69 @@ class RedditClient:
62
60
  self._timeout = timeout
63
61
  self._request_delay = request_delay
64
62
  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),
63
+ self._config = RuntimeConfig(
64
+ timeout=timeout,
65
+ read_request_delay=request_delay,
66
+ write_request_delay=max(request_delay, DEFAULT_CONFIG.write_request_delay),
67
+ max_retries=max_retries,
68
+ status_check_timeout=min(timeout, DEFAULT_CONFIG.status_check_timeout),
79
69
  )
70
+ self._fingerprint = BrowserFingerprint.chrome133_mac()
71
+ self.session = SessionState.from_credential(credential)
72
+ self._read_transport: ReadTransport | None = None
73
+ self._write_transport: WriteTransport | None = None
74
+ self._http = None
80
75
 
81
76
  @property
82
- def client(self) -> httpx.Client:
83
- if not self._http:
77
+ def client(self):
78
+ if not self._read_transport:
84
79
  raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
85
- return self._http
80
+ return self._read_transport.client
86
81
 
87
82
  def __enter__(self) -> RedditClient:
88
- self._http = self._build_client()
83
+ self._read_transport = ReadTransport(
84
+ self.session,
85
+ config=self._config,
86
+ fingerprint=self._fingerprint,
87
+ request_delay=self._config.read_request_delay,
88
+ )
89
+ self._write_transport = WriteTransport(
90
+ self.session,
91
+ config=self._config,
92
+ fingerprint=self._fingerprint,
93
+ request_delay=self._config.write_request_delay,
94
+ )
95
+ self._http = self._read_transport.client
89
96
  return self
90
97
 
91
98
  def __exit__(self, *args: Any) -> None:
92
- if self._http:
93
- self._http.close()
94
- self._http = None
99
+ if self._read_transport:
100
+ self._read_transport.close()
101
+ self._read_transport = None
102
+ if self._write_transport:
103
+ self._write_transport.close()
104
+ self._write_transport = None
105
+ self._http = None
95
106
 
96
107
  @property
97
108
  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)
109
+ read_count = self._read_transport.request_count if self._read_transport else 0
110
+ write_count = self._write_transport.request_count if self._write_transport else 0
111
+ return {"request_count": read_count + write_count}
120
112
 
121
113
  # ── Core request ────────────────────────────────────────────────
122
114
 
123
115
  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")
116
+ """Read request through the low-risk transport."""
117
+ if not self._read_transport:
118
+ raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
119
+ return self._read_transport.request(method, url, **kwargs)
120
+
121
+ def _write_request(self, method: str, url: str, **kwargs: Any) -> Any:
122
+ """Write request through the authenticated transport."""
123
+ if not self._write_transport:
124
+ raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
125
+ return self._write_transport.request(method, url, **kwargs)
184
126
 
185
127
  def _get(self, url: str, params: dict[str, Any] | None = None) -> Any:
186
128
  """GET request."""
@@ -188,7 +130,7 @@ class RedditClient:
188
130
 
189
131
  def _post(self, url: str, data: dict[str, Any] | None = None) -> Any:
190
132
  """POST request."""
191
- return self._request("POST", url, data=data)
133
+ return self._write_request("POST", url, data=data)
192
134
 
193
135
  # ── Listing helpers ─────────────────────────────────────────────
194
136
 
@@ -273,6 +215,26 @@ class RedditClient:
273
215
  params: dict[str, Any] = {"sort": sort, "limit": limit, "raw_json": 1}
274
216
  return self._get(url, params=params)
275
217
 
218
+ def get_more_comments(
219
+ self,
220
+ post_id: str,
221
+ children: list[str],
222
+ *,
223
+ sort: str = "best",
224
+ ) -> dict:
225
+ """Expand additional comments for a post."""
226
+ if not children:
227
+ return {"json": {"data": {"things": []}}}
228
+ params: dict[str, Any] = {
229
+ "api_type": "json",
230
+ "link_id": f"t3_{post_id}",
231
+ "children": ",".join(children),
232
+ "sort": sort,
233
+ "limit_children": False,
234
+ "raw_json": 1,
235
+ }
236
+ return self._get(MORECHILDREN_URL, params=params)
237
+
276
238
  # ── Search ──────────────────────────────────────────────────────
277
239
 
278
240
  def search(
@@ -322,16 +284,49 @@ class RedditClient:
322
284
  params["after"] = after
323
285
  return self._get(USER_COMMENTS_URL.format(username=username), params=params)
324
286
 
287
+ def get_user_saved(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
288
+ """Get user's saved items."""
289
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
290
+ if after:
291
+ params["after"] = after
292
+ return self._get(USER_SAVED_URL.format(username=username), params=params)
293
+
294
+ def get_user_upvoted(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
295
+ """Get user's upvoted items."""
296
+ params: dict[str, Any] = {"limit": limit, "raw_json": 1}
297
+ if after:
298
+ params["after"] = after
299
+ return self._get(USER_UPVOTED_URL.format(username=username), params=params)
300
+
325
301
  # ── Identity (requires auth) ────────────────────────────────────
326
302
 
327
303
  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
304
+ """Get current user info and enrich session capabilities."""
305
+ data = self._get("/api/me.json", params={"raw_json": 1})
306
+ if isinstance(data, dict):
307
+ self.session.apply_identity(data)
308
+ return data
309
+
310
+ def validate_session(self) -> dict[str, Any]:
311
+ """Probe a lightweight auth endpoint to classify current credential."""
330
312
  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"}
313
+ identity = self.get_me()
314
+ return {
315
+ "authenticated": True,
316
+ "username": self.session.username,
317
+ "capabilities": sorted(self.session.capabilities),
318
+ "modhash_present": bool(self.session.modhash),
319
+ "identity": identity,
320
+ }
321
+ except RedditApiError as exc:
322
+ self.session.apply_validation_error(str(exc))
323
+ return {
324
+ "authenticated": False,
325
+ "username": self.session.username,
326
+ "capabilities": sorted(self.session.capabilities),
327
+ "modhash_present": bool(self.session.modhash),
328
+ "error": str(exc),
329
+ }
335
330
 
336
331
  # ── Write actions (require authentication) ──────────────────────
337
332