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
rdt_cli/__init__.py
ADDED
rdt_cli/__main__.py
ADDED
rdt_cli/auth.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Authentication for Reddit CLI.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
1. Try loading saved credential from ~/.config/rdt-cli/credential.json
|
|
5
|
+
2. Try extracting cookies from local browsers via browser-cookie3
|
|
6
|
+
3. No QR login — Reddit uses OAuth/cookie-based auth only
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .constants import CONFIG_DIR, CREDENTIAL_FILE, REQUIRED_COOKIES
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Credential TTL: attempt refresh after 7 days
|
|
23
|
+
CREDENTIAL_TTL_DAYS = 7
|
|
24
|
+
_CREDENTIAL_TTL_SECONDS = CREDENTIAL_TTL_DAYS * 86400
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Credential data class ───────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Credential:
|
|
31
|
+
"""Holds Reddit session cookies."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, cookies: dict[str, str]):
|
|
34
|
+
self.cookies = cookies
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_valid(self) -> bool:
|
|
38
|
+
return bool(self.cookies)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
return {"cookies": self.cookies, "saved_at": time.time()}
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: dict[str, Any]) -> Credential:
|
|
45
|
+
return cls(cookies=data.get("cookies", {}))
|
|
46
|
+
|
|
47
|
+
def as_cookie_header(self) -> str:
|
|
48
|
+
return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Persistence ─────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_credential(credential: Credential) -> None:
|
|
55
|
+
"""Save credential to disk with restricted permissions."""
|
|
56
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
CREDENTIAL_FILE.write_text(json.dumps(credential.to_dict(), indent=2, ensure_ascii=False))
|
|
58
|
+
CREDENTIAL_FILE.chmod(0o600)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_credential() -> Credential | None:
|
|
62
|
+
"""Load saved credential with TTL-based auto-refresh."""
|
|
63
|
+
if not CREDENTIAL_FILE.exists():
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(CREDENTIAL_FILE.read_text())
|
|
67
|
+
cred = Credential.from_dict(data)
|
|
68
|
+
if not cred.is_valid:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# TTL check — auto-refresh if stale
|
|
72
|
+
saved_at = data.get("saved_at", 0)
|
|
73
|
+
if saved_at and (time.time() - saved_at) > _CREDENTIAL_TTL_SECONDS:
|
|
74
|
+
logger.info("Credential older than %d days, attempting browser refresh", CREDENTIAL_TTL_DAYS)
|
|
75
|
+
fresh = extract_browser_credential()
|
|
76
|
+
if fresh:
|
|
77
|
+
logger.info("Auto-refreshed credential from browser")
|
|
78
|
+
return fresh
|
|
79
|
+
logger.warning("Cookie refresh failed; using existing cookies")
|
|
80
|
+
|
|
81
|
+
return cred
|
|
82
|
+
except (json.JSONDecodeError, KeyError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clear_credential() -> None:
|
|
87
|
+
"""Remove saved credential file."""
|
|
88
|
+
if CREDENTIAL_FILE.exists():
|
|
89
|
+
CREDENTIAL_FILE.unlink()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Browser cookie extraction ───────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_browser_credential() -> Credential | None:
|
|
96
|
+
"""Extract Reddit cookies from installed browsers.
|
|
97
|
+
|
|
98
|
+
Uses subprocess to avoid SQLite lock when browser is running.
|
|
99
|
+
"""
|
|
100
|
+
if shutil.which("uv"):
|
|
101
|
+
cred = _extract_subprocess()
|
|
102
|
+
if cred:
|
|
103
|
+
return cred
|
|
104
|
+
return _extract_direct()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _extract_subprocess() -> Credential | None:
|
|
108
|
+
"""Extract via uv subprocess — avoids SQLite lock."""
|
|
109
|
+
script = '''
|
|
110
|
+
import browser_cookie3, json
|
|
111
|
+
cookies = {}
|
|
112
|
+
for browser_fn in [browser_cookie3.chrome, browser_cookie3.firefox, browser_cookie3.edge, browser_cookie3.brave]:
|
|
113
|
+
try:
|
|
114
|
+
jar = browser_fn(domain_name=".reddit.com")
|
|
115
|
+
for c in jar:
|
|
116
|
+
cookies[c.name] = c.value
|
|
117
|
+
if cookies:
|
|
118
|
+
break
|
|
119
|
+
except Exception:
|
|
120
|
+
continue
|
|
121
|
+
if cookies:
|
|
122
|
+
print(json.dumps(cookies))
|
|
123
|
+
'''
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["uv", "run", "--with", "browser-cookie3", "python3", "-c", script],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
timeout=30,
|
|
130
|
+
)
|
|
131
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
132
|
+
cookies = json.loads(result.stdout.strip())
|
|
133
|
+
if any(k in cookies for k in REQUIRED_COOKIES):
|
|
134
|
+
cred = Credential(cookies=cookies)
|
|
135
|
+
save_credential(cred)
|
|
136
|
+
return cred
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.debug("Subprocess extraction failed: %s", e)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _extract_direct() -> Credential | None:
|
|
143
|
+
"""Fallback direct extraction (may fail if browser is open)."""
|
|
144
|
+
try:
|
|
145
|
+
import browser_cookie3
|
|
146
|
+
except ImportError:
|
|
147
|
+
logger.warning("browser-cookie3 not available for direct extraction")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
for fn in [browser_cookie3.chrome, browser_cookie3.firefox, browser_cookie3.edge, browser_cookie3.brave]:
|
|
151
|
+
try:
|
|
152
|
+
jar = fn(domain_name=".reddit.com")
|
|
153
|
+
cookies = {c.name: c.value for c in jar}
|
|
154
|
+
if any(k in cookies for k in REQUIRED_COOKIES):
|
|
155
|
+
cred = Credential(cookies=cookies)
|
|
156
|
+
save_credential(cred)
|
|
157
|
+
return cred
|
|
158
|
+
except Exception:
|
|
159
|
+
continue
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── Credential chain ────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_credential() -> Credential | None:
|
|
167
|
+
"""Try saved → browser → return None."""
|
|
168
|
+
cred = load_credential()
|
|
169
|
+
if cred:
|
|
170
|
+
return cred
|
|
171
|
+
cred = extract_browser_credential()
|
|
172
|
+
if cred:
|
|
173
|
+
return cred
|
|
174
|
+
return None
|
rdt_cli/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CLI entry point for rdt-cli.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
rdt login / status / logout
|
|
5
|
+
rdt feed / popular / all / sub <subreddit>
|
|
6
|
+
rdt read <post_id> / show <index> / open <id_or_index>
|
|
7
|
+
rdt search <query> / export <query>
|
|
8
|
+
rdt user <username> / user-posts <username>
|
|
9
|
+
rdt upvote / save / subscribe / comment
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .commands import auth, browse, post, search, social
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
@click.version_option(version=__version__, prog_name="rdt")
|
|
24
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging (show request URLs, timing)")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def cli(ctx: click.Context, verbose: bool) -> None:
|
|
27
|
+
"""rdt — Reddit in your terminal 📖"""
|
|
28
|
+
ctx.ensure_object(dict)
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=logging.INFO if verbose else logging.WARNING,
|
|
31
|
+
format="%(name)s %(message)s",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─── Auth commands ───────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
cli.add_command(auth.login)
|
|
38
|
+
cli.add_command(auth.logout)
|
|
39
|
+
cli.add_command(auth.status)
|
|
40
|
+
cli.add_command(auth.whoami)
|
|
41
|
+
|
|
42
|
+
# ─── Browse commands ─────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
cli.add_command(browse.feed)
|
|
45
|
+
cli.add_command(browse.popular)
|
|
46
|
+
cli.add_command(browse.all_cmd)
|
|
47
|
+
cli.add_command(browse.sub)
|
|
48
|
+
cli.add_command(browse.sub_info)
|
|
49
|
+
cli.add_command(browse.user)
|
|
50
|
+
cli.add_command(browse.user_posts)
|
|
51
|
+
cli.add_command(browse.open_post)
|
|
52
|
+
|
|
53
|
+
# ─── Post commands ───────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
cli.add_command(post.read)
|
|
56
|
+
cli.add_command(post.show)
|
|
57
|
+
|
|
58
|
+
# ─── Search & Export ─────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
cli.add_command(search.search)
|
|
61
|
+
cli.add_command(search.export)
|
|
62
|
+
|
|
63
|
+
# ─── Social commands ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
cli.add_command(social.upvote)
|
|
66
|
+
cli.add_command(social.save)
|
|
67
|
+
cli.add_command(social.subscribe)
|
|
68
|
+
cli.add_command(social.comment)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
cli()
|
rdt_cli/client.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""API client for Reddit with rate limiting, retry, and anti-detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .constants import (
|
|
13
|
+
ALL_URL,
|
|
14
|
+
BASE_URL,
|
|
15
|
+
COMMENT_URL,
|
|
16
|
+
DEFAULT_LIMIT,
|
|
17
|
+
HEADERS,
|
|
18
|
+
HOME_URL,
|
|
19
|
+
POPULAR_URL,
|
|
20
|
+
POST_COMMENTS_SHORT_URL,
|
|
21
|
+
POST_COMMENTS_URL,
|
|
22
|
+
SAVE_URL,
|
|
23
|
+
SEARCH_URL,
|
|
24
|
+
SUBREDDIT_ABOUT_URL,
|
|
25
|
+
SUBREDDIT_SEARCH_URL,
|
|
26
|
+
SUBSCRIBE_URL,
|
|
27
|
+
UNSAVE_URL,
|
|
28
|
+
USER_ABOUT_URL,
|
|
29
|
+
USER_COMMENTS_URL,
|
|
30
|
+
USER_POSTS_URL,
|
|
31
|
+
VOTE_URL,
|
|
32
|
+
)
|
|
33
|
+
from .exceptions import (
|
|
34
|
+
ForbiddenError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
RedditApiError,
|
|
37
|
+
SessionExpiredError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RedditClient:
|
|
44
|
+
"""Reddit API client with Gaussian jitter, exponential backoff, and session-stable identity.
|
|
45
|
+
|
|
46
|
+
Anti-detection strategy:
|
|
47
|
+
- Gaussian jitter delay between requests
|
|
48
|
+
- 5% chance of random long pause (2-5s) to mimic reading
|
|
49
|
+
- Exponential backoff on HTTP 429/5xx (up to 3 retries)
|
|
50
|
+
- Response cookies merged back into session jar
|
|
51
|
+
- Per-request logging with counter
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
credential: object | None = None,
|
|
57
|
+
timeout: float = 30.0,
|
|
58
|
+
request_delay: float = 1.0,
|
|
59
|
+
max_retries: int = 3,
|
|
60
|
+
):
|
|
61
|
+
self.credential = credential
|
|
62
|
+
self._timeout = timeout
|
|
63
|
+
self._request_delay = request_delay
|
|
64
|
+
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),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def client(self) -> httpx.Client:
|
|
83
|
+
if not self._http:
|
|
84
|
+
raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
|
|
85
|
+
return self._http
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> RedditClient:
|
|
88
|
+
self._http = self._build_client()
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __exit__(self, *args: Any) -> None:
|
|
92
|
+
if self._http:
|
|
93
|
+
self._http.close()
|
|
94
|
+
self._http = None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
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)
|
|
120
|
+
|
|
121
|
+
# ── Core request ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
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")
|
|
184
|
+
|
|
185
|
+
def _get(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
|
186
|
+
"""GET request."""
|
|
187
|
+
return self._request("GET", url, params=params)
|
|
188
|
+
|
|
189
|
+
def _post(self, url: str, data: dict[str, Any] | None = None) -> Any:
|
|
190
|
+
"""POST request."""
|
|
191
|
+
return self._request("POST", url, data=data)
|
|
192
|
+
|
|
193
|
+
# ── Listing helpers ─────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _extract_posts(data: dict) -> list[dict]:
|
|
197
|
+
"""Extract post list from Reddit Listing response."""
|
|
198
|
+
if isinstance(data, list):
|
|
199
|
+
# Comments endpoint returns [post_listing, comments_listing]
|
|
200
|
+
return data
|
|
201
|
+
children = data.get("data", {}).get("children", [])
|
|
202
|
+
return [child.get("data", child) for child in children]
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _extract_after(data: dict) -> str | None:
|
|
206
|
+
"""Extract pagination cursor."""
|
|
207
|
+
if isinstance(data, list):
|
|
208
|
+
return None
|
|
209
|
+
return data.get("data", {}).get("after")
|
|
210
|
+
|
|
211
|
+
# ── Feed / Listing endpoints ────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def get_home(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
|
|
214
|
+
"""Get home feed (requires login)."""
|
|
215
|
+
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
|
|
216
|
+
if after:
|
|
217
|
+
params["after"] = after
|
|
218
|
+
return self._get(HOME_URL, params=params)
|
|
219
|
+
|
|
220
|
+
def get_popular(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
|
|
221
|
+
"""Get /r/popular."""
|
|
222
|
+
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
|
|
223
|
+
if after:
|
|
224
|
+
params["after"] = after
|
|
225
|
+
return self._get(POPULAR_URL, params=params)
|
|
226
|
+
|
|
227
|
+
def get_all(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
|
|
228
|
+
"""Get /r/all."""
|
|
229
|
+
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
|
|
230
|
+
if after:
|
|
231
|
+
params["after"] = after
|
|
232
|
+
return self._get(ALL_URL, params=params)
|
|
233
|
+
|
|
234
|
+
def get_subreddit(
|
|
235
|
+
self,
|
|
236
|
+
subreddit: str,
|
|
237
|
+
sort: str = "hot",
|
|
238
|
+
limit: int = DEFAULT_LIMIT,
|
|
239
|
+
after: str | None = None,
|
|
240
|
+
time_filter: str | None = None,
|
|
241
|
+
) -> dict:
|
|
242
|
+
"""Get subreddit listing."""
|
|
243
|
+
url = f"/r/{subreddit}.json" if sort == "hot" else f"/r/{subreddit}/{sort}.json"
|
|
244
|
+
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
|
|
245
|
+
if after:
|
|
246
|
+
params["after"] = after
|
|
247
|
+
if time_filter and sort in ("top", "controversial"):
|
|
248
|
+
params["t"] = time_filter
|
|
249
|
+
return self._get(url, params=params)
|
|
250
|
+
|
|
251
|
+
def get_subreddit_about(self, subreddit: str) -> dict:
|
|
252
|
+
"""Get subreddit info."""
|
|
253
|
+
data = self._get(SUBREDDIT_ABOUT_URL.format(subreddit=subreddit), params={"raw_json": 1})
|
|
254
|
+
return data.get("data", data)
|
|
255
|
+
|
|
256
|
+
# ── Post / Comments ─────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
def get_post_comments(
|
|
259
|
+
self,
|
|
260
|
+
post_id: str,
|
|
261
|
+
subreddit: str | None = None,
|
|
262
|
+
sort: str = "best",
|
|
263
|
+
limit: int = DEFAULT_LIMIT,
|
|
264
|
+
) -> list[dict]:
|
|
265
|
+
"""Get post and its comments.
|
|
266
|
+
|
|
267
|
+
Returns [post_listing, comments_listing].
|
|
268
|
+
"""
|
|
269
|
+
if subreddit:
|
|
270
|
+
url = POST_COMMENTS_URL.format(subreddit=subreddit, post_id=post_id)
|
|
271
|
+
else:
|
|
272
|
+
url = POST_COMMENTS_SHORT_URL.format(post_id=post_id)
|
|
273
|
+
params: dict[str, Any] = {"sort": sort, "limit": limit, "raw_json": 1}
|
|
274
|
+
return self._get(url, params=params)
|
|
275
|
+
|
|
276
|
+
# ── Search ──────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def search(
|
|
279
|
+
self,
|
|
280
|
+
query: str,
|
|
281
|
+
subreddit: str | None = None,
|
|
282
|
+
sort: str = "relevance",
|
|
283
|
+
time_filter: str = "all",
|
|
284
|
+
limit: int = DEFAULT_LIMIT,
|
|
285
|
+
after: str | None = None,
|
|
286
|
+
) -> dict:
|
|
287
|
+
"""Search posts."""
|
|
288
|
+
if subreddit:
|
|
289
|
+
url = SUBREDDIT_SEARCH_URL.format(subreddit=subreddit)
|
|
290
|
+
else:
|
|
291
|
+
url = SEARCH_URL
|
|
292
|
+
params: dict[str, Any] = {
|
|
293
|
+
"q": query,
|
|
294
|
+
"sort": sort,
|
|
295
|
+
"t": time_filter,
|
|
296
|
+
"limit": limit,
|
|
297
|
+
"restrict_sr": "on" if subreddit else "off",
|
|
298
|
+
"raw_json": 1,
|
|
299
|
+
}
|
|
300
|
+
if after:
|
|
301
|
+
params["after"] = after
|
|
302
|
+
return self._get(url, params=params)
|
|
303
|
+
|
|
304
|
+
# ── User ────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def get_user_about(self, username: str) -> dict:
|
|
307
|
+
"""Get user profile info."""
|
|
308
|
+
data = self._get(USER_ABOUT_URL.format(username=username), params={"raw_json": 1})
|
|
309
|
+
return data.get("data", data)
|
|
310
|
+
|
|
311
|
+
def get_user_posts(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
|
|
312
|
+
"""Get user's submitted posts."""
|
|
313
|
+
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
|
|
314
|
+
if after:
|
|
315
|
+
params["after"] = after
|
|
316
|
+
return self._get(USER_POSTS_URL.format(username=username), params=params)
|
|
317
|
+
|
|
318
|
+
def get_user_comments(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
|
|
319
|
+
"""Get user's comments."""
|
|
320
|
+
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
|
|
321
|
+
if after:
|
|
322
|
+
params["after"] = after
|
|
323
|
+
return self._get(USER_COMMENTS_URL.format(username=username), params=params)
|
|
324
|
+
|
|
325
|
+
# ── Identity (requires auth) ────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
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
|
|
330
|
+
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"}
|
|
335
|
+
|
|
336
|
+
# ── Write actions (require authentication) ──────────────────────
|
|
337
|
+
|
|
338
|
+
def vote(self, fullname: str, direction: int) -> dict:
|
|
339
|
+
"""Vote on a post or comment. direction: 1=upvote, 0=unvote, -1=downvote."""
|
|
340
|
+
return self._post(VOTE_URL, data={"id": fullname, "dir": str(direction)})
|
|
341
|
+
|
|
342
|
+
def save_item(self, fullname: str) -> dict:
|
|
343
|
+
"""Save a post or comment."""
|
|
344
|
+
return self._post(SAVE_URL, data={"id": fullname})
|
|
345
|
+
|
|
346
|
+
def unsave_item(self, fullname: str) -> dict:
|
|
347
|
+
"""Unsave a post or comment."""
|
|
348
|
+
return self._post(UNSAVE_URL, data={"id": fullname})
|
|
349
|
+
|
|
350
|
+
def subscribe(self, subreddit: str, action: str = "sub") -> dict:
|
|
351
|
+
"""Subscribe or unsubscribe. action: 'sub' or 'unsub'."""
|
|
352
|
+
return self._post(SUBSCRIBE_URL, data={"sr_name": subreddit, "action": action})
|
|
353
|
+
|
|
354
|
+
def post_comment(self, parent_fullname: str, text: str) -> dict:
|
|
355
|
+
"""Post a comment."""
|
|
356
|
+
return self._post(COMMENT_URL, data={"parent": parent_fullname, "text": text})
|
|
File without changes
|