rdt-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rdt_cli/__init__.py +3 -0
- rdt_cli/__main__.py +5 -0
- rdt_cli/auth.py +174 -0
- rdt_cli/cli.py +72 -0
- rdt_cli/client.py +356 -0
- rdt_cli/commands/__init__.py +0 -0
- rdt_cli/commands/_common.py +353 -0
- rdt_cli/commands/auth.py +105 -0
- rdt_cli/commands/browse.py +386 -0
- rdt_cli/commands/post.py +183 -0
- rdt_cli/commands/search.py +227 -0
- rdt_cli/commands/social.py +163 -0
- rdt_cli/constants.py +83 -0
- rdt_cli/exceptions.py +69 -0
- rdt_cli/index_cache.py +77 -0
- rdt_cli-0.2.0.dist-info/METADATA +398 -0
- rdt_cli-0.2.0.dist-info/RECORD +19 -0
- rdt_cli-0.2.0.dist-info/WHEEL +4 -0
- rdt_cli-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Search and export commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from ..client import RedditClient
|
|
15
|
+
from ..constants import SEARCH_SORT_OPTIONS, TIME_FILTERS
|
|
16
|
+
from ..exceptions import RedditApiError
|
|
17
|
+
from ..index_cache import save_index
|
|
18
|
+
from ._common import (
|
|
19
|
+
compact_posts,
|
|
20
|
+
console,
|
|
21
|
+
exit_for_error,
|
|
22
|
+
format_score,
|
|
23
|
+
listing_options,
|
|
24
|
+
maybe_print_structured,
|
|
25
|
+
optional_auth,
|
|
26
|
+
save_output_to_file,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _render_search_table(
|
|
33
|
+
posts: list[dict], query: str, full_text: bool = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Render search results as a Rich table."""
|
|
36
|
+
if not posts:
|
|
37
|
+
console.print(f"[yellow]No results for '{query}'[/yellow]")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
save_index(posts, source=f"search:{query}")
|
|
41
|
+
max_title = 200 if full_text else 45
|
|
42
|
+
|
|
43
|
+
table = Table(title=f'🔍 Search: "{query}" — {len(posts)} results', show_lines=True)
|
|
44
|
+
table.add_column("#", style="dim", width=3)
|
|
45
|
+
table.add_column("Score", style="yellow", width=6, justify="right")
|
|
46
|
+
table.add_column("Subreddit", style="magenta", max_width=15)
|
|
47
|
+
table.add_column(
|
|
48
|
+
"Title", style="bold cyan",
|
|
49
|
+
max_width=max_title if not full_text else None,
|
|
50
|
+
)
|
|
51
|
+
table.add_column("Author", style="green", max_width=12)
|
|
52
|
+
table.add_column("💬", style="dim", width=5, justify="right")
|
|
53
|
+
|
|
54
|
+
for i, post in enumerate(posts, 1):
|
|
55
|
+
title_text = post.get("title", "-")
|
|
56
|
+
if not full_text:
|
|
57
|
+
title_text = title_text[:max_title]
|
|
58
|
+
table.add_row(
|
|
59
|
+
str(i),
|
|
60
|
+
format_score(post.get("score", 0)),
|
|
61
|
+
f"r/{post.get('subreddit', '?')}",
|
|
62
|
+
title_text,
|
|
63
|
+
post.get("author", "-")[:12],
|
|
64
|
+
str(post.get("num_comments", 0)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
console.print(table)
|
|
68
|
+
console.print("\n [dim]💡 Use [bold]rdt show <#>[/bold] to read a result[/dim]")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── search ──────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@click.command()
|
|
75
|
+
@click.argument("query")
|
|
76
|
+
@click.option("-r", "--subreddit", default=None, help="Search within subreddit")
|
|
77
|
+
@click.option(
|
|
78
|
+
"-s", "--sort", type=click.Choice(SEARCH_SORT_OPTIONS),
|
|
79
|
+
default="relevance", help="Sort order",
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"-t", "--time", "time_filter",
|
|
83
|
+
type=click.Choice(TIME_FILTERS), default="all", help="Time filter",
|
|
84
|
+
)
|
|
85
|
+
@click.option("-n", "--limit", default=25, type=int, help="Number of results")
|
|
86
|
+
@click.option("--after", default=None, help="Pagination cursor")
|
|
87
|
+
@listing_options
|
|
88
|
+
def search(
|
|
89
|
+
query: str,
|
|
90
|
+
subreddit: str | None,
|
|
91
|
+
sort: str,
|
|
92
|
+
time_filter: str,
|
|
93
|
+
limit: int,
|
|
94
|
+
after: str | None,
|
|
95
|
+
as_json: bool,
|
|
96
|
+
as_yaml: bool,
|
|
97
|
+
output_file: str | None,
|
|
98
|
+
full_text: bool,
|
|
99
|
+
compact: bool,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Search Reddit posts
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
rdt search "python async"
|
|
105
|
+
rdt search "rust vs go" -r programming --sort top --time year
|
|
106
|
+
"""
|
|
107
|
+
cred = optional_auth()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with RedditClient(cred) as client:
|
|
111
|
+
data = client.search(
|
|
112
|
+
query=query,
|
|
113
|
+
subreddit=subreddit,
|
|
114
|
+
sort=sort,
|
|
115
|
+
time_filter=time_filter,
|
|
116
|
+
limit=limit,
|
|
117
|
+
after=after,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
posts = RedditClient._extract_posts(data)
|
|
121
|
+
if posts:
|
|
122
|
+
save_index(posts, source=f"search:{query}")
|
|
123
|
+
|
|
124
|
+
# --output: save to file
|
|
125
|
+
if output_file:
|
|
126
|
+
out_data = compact_posts(posts) if compact else data
|
|
127
|
+
save_output_to_file(out_data, output_file)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# --compact: strip fields for structured output
|
|
131
|
+
out_data = data
|
|
132
|
+
if compact and (as_json or as_yaml):
|
|
133
|
+
out_data = compact_posts(posts)
|
|
134
|
+
|
|
135
|
+
if maybe_print_structured(out_data, as_json=as_json, as_yaml=as_yaml):
|
|
136
|
+
# Show pagination hint
|
|
137
|
+
cursor = RedditClient._extract_after(data)
|
|
138
|
+
if cursor:
|
|
139
|
+
console.print(
|
|
140
|
+
f' [dim]▸ More: rdt search "{query}" --after {cursor}[/dim]',
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
_render_search_table(posts, query, full_text=full_text)
|
|
145
|
+
|
|
146
|
+
# Show pagination hint
|
|
147
|
+
cursor = RedditClient._extract_after(data)
|
|
148
|
+
if cursor and sys.stdout.isatty():
|
|
149
|
+
console.print(f' [dim]▸ More: rdt search "{query}" --after {cursor}[/dim]')
|
|
150
|
+
|
|
151
|
+
except RedditApiError as exc:
|
|
152
|
+
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml, prefix="Search failed")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ── export ──────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@click.command()
|
|
159
|
+
@click.argument("query")
|
|
160
|
+
@click.option("-r", "--subreddit", default=None, help="Search within subreddit")
|
|
161
|
+
@click.option("-s", "--sort", type=click.Choice(SEARCH_SORT_OPTIONS), default="relevance", help="Sort order")
|
|
162
|
+
@click.option("-n", "--count", default=50, type=int, help="Number of results to export")
|
|
163
|
+
@click.option("-o", "--output", "output_file", default=None, help="Output file path")
|
|
164
|
+
@click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="Output format")
|
|
165
|
+
def export(query: str, subreddit: str | None, sort: str, count: int, output_file: str | None, fmt: str) -> None:
|
|
166
|
+
"""Export search results to CSV or JSON
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
rdt export "machine learning" -n 100 -o results.csv
|
|
170
|
+
rdt export "python tips" --format json -o tips.json
|
|
171
|
+
"""
|
|
172
|
+
cred = optional_auth()
|
|
173
|
+
all_posts: list[dict] = []
|
|
174
|
+
after = None
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with RedditClient(cred) as client:
|
|
178
|
+
pages = 0
|
|
179
|
+
max_pages = (count + 24) // 25
|
|
180
|
+
|
|
181
|
+
while len(all_posts) < count and pages < max_pages:
|
|
182
|
+
data = client.search(query=query, subreddit=subreddit, sort=sort, limit=25, after=after)
|
|
183
|
+
posts = RedditClient._extract_posts(data)
|
|
184
|
+
if not posts:
|
|
185
|
+
break
|
|
186
|
+
all_posts.extend(posts)
|
|
187
|
+
after = RedditClient._extract_after(data)
|
|
188
|
+
if not after:
|
|
189
|
+
break
|
|
190
|
+
pages += 1
|
|
191
|
+
|
|
192
|
+
all_posts = all_posts[:count]
|
|
193
|
+
|
|
194
|
+
if not all_posts:
|
|
195
|
+
console.print(f"[yellow]No results found for '{query}'[/yellow]")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
if fmt == "json":
|
|
199
|
+
text = json.dumps(all_posts, indent=2, ensure_ascii=False)
|
|
200
|
+
else:
|
|
201
|
+
buf = io.StringIO()
|
|
202
|
+
fieldnames = ["title", "subreddit", "author", "score", "num_comments", "url", "permalink"]
|
|
203
|
+
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
|
|
204
|
+
writer.writeheader()
|
|
205
|
+
for p in all_posts:
|
|
206
|
+
row = {
|
|
207
|
+
"title": p.get("title", ""),
|
|
208
|
+
"subreddit": p.get("subreddit", ""),
|
|
209
|
+
"author": p.get("author", ""),
|
|
210
|
+
"score": p.get("score", 0),
|
|
211
|
+
"num_comments": p.get("num_comments", 0),
|
|
212
|
+
"url": p.get("url", ""),
|
|
213
|
+
"permalink": f"https://reddit.com{p.get('permalink', '')}",
|
|
214
|
+
}
|
|
215
|
+
writer.writerow(row)
|
|
216
|
+
text = buf.getvalue()
|
|
217
|
+
|
|
218
|
+
if output_file:
|
|
219
|
+
encoding = "utf-8-sig" if fmt == "csv" else "utf-8"
|
|
220
|
+
with open(output_file, "w", encoding=encoding) as f:
|
|
221
|
+
f.write(text)
|
|
222
|
+
console.print(f"[green]✅ Exported {len(all_posts)} results to {output_file}[/green]")
|
|
223
|
+
else:
|
|
224
|
+
click.echo(text)
|
|
225
|
+
|
|
226
|
+
except RedditApiError as exc:
|
|
227
|
+
exit_for_error(exc, prefix="Export failed")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Social / interaction commands: upvote, save, subscribe, comment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..client import RedditClient
|
|
8
|
+
from ..exceptions import RedditApiError
|
|
9
|
+
from ..index_cache import get_item_by_index
|
|
10
|
+
from ._common import console, exit_for_error, require_auth, write_delay
|
|
11
|
+
|
|
12
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_fullname(id_or_index: str) -> str | None:
|
|
16
|
+
"""Resolve an ID or short-index to a Reddit fullname (t3_xxx).
|
|
17
|
+
|
|
18
|
+
Accepts:
|
|
19
|
+
- Short index (e.g., "3") → from cache
|
|
20
|
+
- Bare post ID (e.g., "1abc123") → prepend t3_
|
|
21
|
+
- Full name (e.g., "t3_1abc123") → as-is
|
|
22
|
+
"""
|
|
23
|
+
# Try as short-index first
|
|
24
|
+
try:
|
|
25
|
+
idx = int(id_or_index)
|
|
26
|
+
item = get_item_by_index(idx)
|
|
27
|
+
if item:
|
|
28
|
+
name = item.get("name", "")
|
|
29
|
+
if name:
|
|
30
|
+
return name
|
|
31
|
+
pid = item.get("id", "")
|
|
32
|
+
if pid:
|
|
33
|
+
return f"t3_{pid}"
|
|
34
|
+
console.print(f"[yellow]Index {idx} not found in cache[/yellow]")
|
|
35
|
+
return None
|
|
36
|
+
except ValueError:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
# Full name
|
|
40
|
+
if id_or_index.startswith("t3_") or id_or_index.startswith("t1_"):
|
|
41
|
+
return id_or_index
|
|
42
|
+
|
|
43
|
+
# Bare ID → assume post
|
|
44
|
+
return f"t3_{id_or_index}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── upvote ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@click.command()
|
|
51
|
+
@click.argument("id_or_index")
|
|
52
|
+
@click.option("--undo", is_flag=True, help="Remove vote")
|
|
53
|
+
@click.option("--down", is_flag=True, help="Downvote instead")
|
|
54
|
+
def upvote(id_or_index: str, undo: bool, down: bool) -> None:
|
|
55
|
+
"""Upvote a post (by ID or index number)
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
rdt upvote 3 # upvote result #3
|
|
59
|
+
rdt upvote 1abc123 # upvote by post ID
|
|
60
|
+
rdt upvote 3 --down # downvote
|
|
61
|
+
rdt upvote 3 --undo # remove vote
|
|
62
|
+
"""
|
|
63
|
+
cred = require_auth()
|
|
64
|
+
fullname = _resolve_fullname(id_or_index)
|
|
65
|
+
if not fullname:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
direction = 0 if undo else (-1 if down else 1)
|
|
69
|
+
action_label = "Unvoted" if undo else ("⬇ Downvoted" if down else "⬆ Upvoted")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
with RedditClient(cred) as client:
|
|
73
|
+
client.vote(fullname, direction=direction)
|
|
74
|
+
write_delay()
|
|
75
|
+
console.print(f"[green]✅ {action_label}[/green] {fullname}")
|
|
76
|
+
except RedditApiError as exc:
|
|
77
|
+
exit_for_error(exc, prefix="Vote failed")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── save / unsave ──────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.command()
|
|
84
|
+
@click.argument("id_or_index")
|
|
85
|
+
@click.option("--undo", is_flag=True, help="Unsave")
|
|
86
|
+
def save(id_or_index: str, undo: bool) -> None:
|
|
87
|
+
"""Save a post (by ID or index number)
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
rdt save 3 # save result #3
|
|
91
|
+
rdt save 3 --undo # unsave
|
|
92
|
+
"""
|
|
93
|
+
cred = require_auth()
|
|
94
|
+
fullname = _resolve_fullname(id_or_index)
|
|
95
|
+
if not fullname:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with RedditClient(cred) as client:
|
|
100
|
+
if undo:
|
|
101
|
+
client.unsave_item(fullname)
|
|
102
|
+
write_delay()
|
|
103
|
+
console.print(f"[green]✅ Unsaved[/green] {fullname}")
|
|
104
|
+
else:
|
|
105
|
+
client.save_item(fullname)
|
|
106
|
+
write_delay()
|
|
107
|
+
console.print(f"[green]✅ Saved[/green] {fullname}")
|
|
108
|
+
except RedditApiError as exc:
|
|
109
|
+
exit_for_error(exc, prefix="Save failed")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── subscribe / unsubscribe ────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@click.command()
|
|
116
|
+
@click.argument("subreddit")
|
|
117
|
+
@click.option("--undo", is_flag=True, help="Unsubscribe")
|
|
118
|
+
def subscribe(subreddit: str, undo: bool) -> None:
|
|
119
|
+
"""Subscribe to a subreddit
|
|
120
|
+
|
|
121
|
+
Examples:
|
|
122
|
+
rdt subscribe python
|
|
123
|
+
rdt subscribe python --undo
|
|
124
|
+
"""
|
|
125
|
+
cred = require_auth()
|
|
126
|
+
action = "unsub" if undo else "sub"
|
|
127
|
+
label = "Unsubscribed from" if undo else "Subscribed to"
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
with RedditClient(cred) as client:
|
|
131
|
+
client.subscribe(subreddit, action=action)
|
|
132
|
+
write_delay()
|
|
133
|
+
console.print(f"[green]✅ {label}[/green] r/{subreddit}")
|
|
134
|
+
except RedditApiError as exc:
|
|
135
|
+
exit_for_error(exc, prefix="Subscribe failed")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── comment ─────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@click.command()
|
|
142
|
+
@click.argument("id_or_index")
|
|
143
|
+
@click.argument("text")
|
|
144
|
+
def comment(id_or_index: str, text: str) -> None:
|
|
145
|
+
"""Post a comment on a post (by ID or index number)
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
rdt comment 3 "Great post!"
|
|
149
|
+
rdt comment 1abc123 "Thanks for sharing"
|
|
150
|
+
"""
|
|
151
|
+
cred = require_auth()
|
|
152
|
+
fullname = _resolve_fullname(id_or_index)
|
|
153
|
+
if not fullname:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
with RedditClient(cred) as client:
|
|
158
|
+
client.post_comment(fullname, text)
|
|
159
|
+
write_delay()
|
|
160
|
+
console.print(f"[green]✅ Comment posted[/green] on {fullname}")
|
|
161
|
+
except RedditApiError as exc:
|
|
162
|
+
exit_for_error(exc, prefix="Comment failed")
|
|
163
|
+
|
rdt_cli/constants.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Constants for Reddit CLI — API endpoints, headers, and config paths."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# ── Config ──────────────────────────────────────────────────────────
|
|
6
|
+
CONFIG_DIR = Path.home() / ".config" / "rdt-cli"
|
|
7
|
+
CREDENTIAL_FILE = CONFIG_DIR / "credential.json"
|
|
8
|
+
|
|
9
|
+
# ── Base URL ────────────────────────────────────────────────────────
|
|
10
|
+
BASE_URL = "https://www.reddit.com"
|
|
11
|
+
OAUTH_URL = "https://oauth.reddit.com"
|
|
12
|
+
|
|
13
|
+
# ── Reddit JSON API ─────────────────────────────────────────────────
|
|
14
|
+
# Reddit's public JSON API: append .json to any URL
|
|
15
|
+
# Authenticated endpoints use oauth.reddit.com
|
|
16
|
+
|
|
17
|
+
# Listing endpoints (GET, append .json)
|
|
18
|
+
HOME_URL = "/.json"
|
|
19
|
+
POPULAR_URL = "/r/popular.json"
|
|
20
|
+
ALL_URL = "/r/all.json"
|
|
21
|
+
SUBREDDIT_URL = "/r/{subreddit}.json" # hot by default
|
|
22
|
+
SUBREDDIT_NEW_URL = "/r/{subreddit}/new.json"
|
|
23
|
+
SUBREDDIT_TOP_URL = "/r/{subreddit}/top.json"
|
|
24
|
+
SUBREDDIT_RISING_URL = "/r/{subreddit}/rising.json"
|
|
25
|
+
SUBREDDIT_ABOUT_URL = "/r/{subreddit}/about.json"
|
|
26
|
+
|
|
27
|
+
# Post / comments
|
|
28
|
+
POST_COMMENTS_URL = "/r/{subreddit}/comments/{post_id}.json"
|
|
29
|
+
POST_COMMENTS_SHORT_URL = "/comments/{post_id}.json"
|
|
30
|
+
|
|
31
|
+
# Search
|
|
32
|
+
SEARCH_URL = "/search.json"
|
|
33
|
+
SUBREDDIT_SEARCH_URL = "/r/{subreddit}/search.json"
|
|
34
|
+
|
|
35
|
+
# User
|
|
36
|
+
USER_ABOUT_URL = "/user/{username}/about.json"
|
|
37
|
+
USER_POSTS_URL = "/user/{username}/submitted.json"
|
|
38
|
+
USER_COMMENTS_URL = "/user/{username}/comments.json"
|
|
39
|
+
USER_SAVED_URL = "/user/{username}/saved.json"
|
|
40
|
+
USER_UPVOTED_URL = "/user/{username}/upvoted.json"
|
|
41
|
+
|
|
42
|
+
# Auth / identity (OAuth)
|
|
43
|
+
ME_URL = "/api/v1/me"
|
|
44
|
+
|
|
45
|
+
# Write actions (OAuth, POST)
|
|
46
|
+
VOTE_URL = "/api/vote"
|
|
47
|
+
SAVE_URL = "/api/save"
|
|
48
|
+
UNSAVE_URL = "/api/unsave"
|
|
49
|
+
SUBSCRIBE_URL = "/api/subscribe"
|
|
50
|
+
COMMENT_URL = "/api/comment"
|
|
51
|
+
|
|
52
|
+
# ── Request Headers (Chrome 133, macOS) ─────────────────────────────
|
|
53
|
+
HEADERS = {
|
|
54
|
+
"User-Agent": (
|
|
55
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
56
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
57
|
+
"Chrome/133.0.0.0 Safari/537.36"
|
|
58
|
+
),
|
|
59
|
+
"sec-ch-ua": '"Chromium";v="133", "Not(A:Brand";v="99", "Google Chrome";v="133"',
|
|
60
|
+
"sec-ch-ua-mobile": "?0",
|
|
61
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
62
|
+
"Sec-Fetch-Dest": "empty",
|
|
63
|
+
"Sec-Fetch-Mode": "cors",
|
|
64
|
+
"Sec-Fetch-Site": "same-origin",
|
|
65
|
+
"Accept": "application/json, text/plain, */*",
|
|
66
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# ── Cookie keys required for authenticated sessions ─────────────────
|
|
70
|
+
REQUIRED_COOKIES = {"reddit_session"}
|
|
71
|
+
|
|
72
|
+
# ── Sort options ────────────────────────────────────────────────────
|
|
73
|
+
SORT_OPTIONS = ["hot", "new", "top", "rising", "controversial", "best"]
|
|
74
|
+
|
|
75
|
+
# ── Time filter for top/controversial ───────────────────────────────
|
|
76
|
+
TIME_FILTERS = ["hour", "day", "week", "month", "year", "all"]
|
|
77
|
+
|
|
78
|
+
# ── Search sort options ─────────────────────────────────────────────
|
|
79
|
+
SEARCH_SORT_OPTIONS = ["relevance", "hot", "top", "new", "comments"]
|
|
80
|
+
|
|
81
|
+
# ── Default page size ───────────────────────────────────────────────
|
|
82
|
+
DEFAULT_LIMIT = 25
|
|
83
|
+
MAX_LIMIT = 100
|
rdt_cli/exceptions.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Custom exceptions for Reddit CLI API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RedditApiError(Exception):
|
|
7
|
+
"""Base exception for Reddit API errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, code: int | str | None = None, response: dict | None = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
self.response = response
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionExpiredError(RedditApiError):
|
|
16
|
+
"""Raised when session cookies have expired."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
super().__init__(
|
|
20
|
+
"Session expired. Please re-login: rdt logout && rdt login",
|
|
21
|
+
code=401,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthRequiredError(RedditApiError):
|
|
26
|
+
"""Raised when user is not logged in."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__("Not logged in. Use 'rdt login' to authenticate")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RateLimitError(RedditApiError):
|
|
33
|
+
"""Raised when Reddit rate-limits the request."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, retry_after: float | None = None):
|
|
36
|
+
msg = "Rate limited by Reddit"
|
|
37
|
+
if retry_after:
|
|
38
|
+
msg += f" (retry after {retry_after:.0f}s)"
|
|
39
|
+
super().__init__(msg, code=429)
|
|
40
|
+
self.retry_after = retry_after
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotFoundError(RedditApiError):
|
|
44
|
+
"""Raised when a subreddit, user, or post is not found."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, resource: str = "Resource"):
|
|
47
|
+
super().__init__(f"{resource} not found", code=404)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ForbiddenError(RedditApiError):
|
|
51
|
+
"""Raised when access is forbidden (private subreddit, etc.)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, resource: str = "Resource"):
|
|
54
|
+
super().__init__(f"Access forbidden: {resource}", code=403)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def error_code_for_exception(exc: Exception) -> str:
|
|
58
|
+
"""Map domain exceptions to stable error code strings."""
|
|
59
|
+
if isinstance(exc, (AuthRequiredError, SessionExpiredError)):
|
|
60
|
+
return "not_authenticated"
|
|
61
|
+
if isinstance(exc, RateLimitError):
|
|
62
|
+
return "rate_limited"
|
|
63
|
+
if isinstance(exc, NotFoundError):
|
|
64
|
+
return "not_found"
|
|
65
|
+
if isinstance(exc, ForbiddenError):
|
|
66
|
+
return "forbidden"
|
|
67
|
+
if isinstance(exc, RedditApiError):
|
|
68
|
+
return "api_error"
|
|
69
|
+
return "unknown_error"
|
rdt_cli/index_cache.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Search result index cache for short-index navigation (rdt show 3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .constants import CONFIG_DIR
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
INDEX_CACHE_FILE = CONFIG_DIR / "index_cache.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save_index(items: list[dict], source: str = "search") -> None:
|
|
18
|
+
"""Save a list of posts/items to the index cache."""
|
|
19
|
+
if not items:
|
|
20
|
+
return
|
|
21
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
entries = []
|
|
24
|
+
for item in items:
|
|
25
|
+
entry = {
|
|
26
|
+
"id": item.get("id", ""),
|
|
27
|
+
"name": item.get("name", ""), # fullname like t3_abc123
|
|
28
|
+
"title": item.get("title", ""),
|
|
29
|
+
"subreddit": item.get("subreddit", ""),
|
|
30
|
+
"author": item.get("author", ""),
|
|
31
|
+
"score": item.get("score", 0),
|
|
32
|
+
"num_comments": item.get("num_comments", 0),
|
|
33
|
+
"permalink": item.get("permalink", ""),
|
|
34
|
+
"url": item.get("url", ""),
|
|
35
|
+
}
|
|
36
|
+
if entry["id"]:
|
|
37
|
+
entries.append(entry)
|
|
38
|
+
|
|
39
|
+
payload = {
|
|
40
|
+
"source": source,
|
|
41
|
+
"saved_at": time.time(),
|
|
42
|
+
"count": len(entries),
|
|
43
|
+
"items": entries,
|
|
44
|
+
}
|
|
45
|
+
INDEX_CACHE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
46
|
+
INDEX_CACHE_FILE.chmod(0o600)
|
|
47
|
+
logger.debug("Saved %d items to index cache (source=%s)", len(entries), source)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_item_by_index(index: int) -> dict | None:
|
|
51
|
+
"""Get a cached item by 1-based index."""
|
|
52
|
+
if index <= 0 or not INDEX_CACHE_FILE.exists():
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(INDEX_CACHE_FILE.read_text())
|
|
56
|
+
items = data.get("items", [])
|
|
57
|
+
if index <= len(items):
|
|
58
|
+
return items[index - 1]
|
|
59
|
+
return None
|
|
60
|
+
except (OSError, json.JSONDecodeError, IndexError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_index_info() -> dict[str, Any]:
|
|
65
|
+
"""Get metadata about the current index cache."""
|
|
66
|
+
if not INDEX_CACHE_FILE.exists():
|
|
67
|
+
return {"exists": False, "count": 0}
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(INDEX_CACHE_FILE.read_text())
|
|
70
|
+
return {
|
|
71
|
+
"exists": True,
|
|
72
|
+
"count": data.get("count", 0),
|
|
73
|
+
"source": data.get("source", ""),
|
|
74
|
+
"saved_at": data.get("saved_at", 0),
|
|
75
|
+
}
|
|
76
|
+
except (OSError, json.JSONDecodeError):
|
|
77
|
+
return {"exists": False, "count": 0}
|