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.
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/PKG-INFO +18 -4
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/README.md +17 -3
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/SKILL.md +17 -4
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/pyproject.toml +1 -1
- rdt_cli-0.3.0/rdt_cli/__init__.py +3 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/auth.py +36 -5
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/cli.py +5 -1
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/client.py +114 -119
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/auth.py +48 -13
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/browse.py +104 -31
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/post.py +89 -52
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/search.py +15 -14
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/social.py +15 -16
- rdt_cli-0.3.0/rdt_cli/config.py +19 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/constants.py +1 -0
- rdt_cli-0.3.0/rdt_cli/fingerprint.py +60 -0
- rdt_cli-0.3.0/rdt_cli/models.py +114 -0
- rdt_cli-0.3.0/rdt_cli/parser.py +176 -0
- rdt_cli-0.3.0/rdt_cli/session.py +111 -0
- rdt_cli-0.3.0/rdt_cli/transports.py +170 -0
- rdt_cli-0.3.0/tests/fixtures/listing.json +48 -0
- rdt_cli-0.3.0/tests/fixtures/morechildren.json +34 -0
- rdt_cli-0.3.0/tests/fixtures/post_detail.json +69 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/tests/test_cli.py +72 -12
- rdt_cli-0.3.0/tests/test_client.py +69 -0
- rdt_cli-0.3.0/tests/test_parser.py +77 -0
- rdt_cli-0.3.0/tests/test_session.py +41 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/tests/test_smoke.py +281 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/uv.lock +1 -1
- rdt_cli-0.2.0/rdt_cli/__init__.py +0 -3
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/.gitignore +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/SCHEMA.md +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/__main__.py +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/__init__.py +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/commands/_common.py +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/exceptions.py +0 -0
- {rdt_cli-0.2.0 → rdt_cli-0.3.0}/rdt_cli/index_cache.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -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__(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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)
|
|
83
|
-
if not self.
|
|
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.
|
|
80
|
+
return self._read_transport.client
|
|
86
81
|
|
|
87
82
|
def __enter__(self) -> RedditClient:
|
|
88
|
-
self.
|
|
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.
|
|
93
|
-
self.
|
|
94
|
-
self.
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
"""
|
|
125
|
-
self.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|