reddipy 1.0.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.
- reddipy/__init__.py +4 -0
- reddipy/__main__.py +3 -0
- reddipy/cli.py +238 -0
- reddipy/client.py +254 -0
- reddipy/config.py +44 -0
- reddipy/exceptions.py +29 -0
- reddipy/exporters/__init__.py +5 -0
- reddipy/exporters/base.py +9 -0
- reddipy/exporters/console.py +96 -0
- reddipy/exporters/csv_exporter.py +59 -0
- reddipy/exporters/json_exporter.py +24 -0
- reddipy/models.py +151 -0
- reddipy/scrapers/__init__.py +6 -0
- reddipy/scrapers/base.py +16 -0
- reddipy/scrapers/post.py +30 -0
- reddipy/scrapers/search.py +36 -0
- reddipy/scrapers/subreddit.py +29 -0
- reddipy/scrapers/user.py +55 -0
- reddipy/sdk.py +146 -0
- reddipy/utils.py +70 -0
- reddipy-1.0.0.dist-info/METADATA +645 -0
- reddipy-1.0.0.dist-info/RECORD +26 -0
- reddipy-1.0.0.dist-info/WHEEL +5 -0
- reddipy-1.0.0.dist-info/entry_points.txt +2 -0
- reddipy-1.0.0.dist-info/licenses/LICENSE +21 -0
- reddipy-1.0.0.dist-info/top_level.txt +1 -0
reddipy/__init__.py
ADDED
reddipy/__main__.py
ADDED
reddipy/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""CLI entry point for RedMiner."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from reddipy.client import RedditClient
|
|
10
|
+
from reddipy.config import get_cookie, set_cookie, clear_config
|
|
11
|
+
from reddipy.exceptions import (
|
|
12
|
+
NetworkError,
|
|
13
|
+
PostNotFoundError,
|
|
14
|
+
ProxyError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
RedMinerError,
|
|
17
|
+
SubredditNotFoundError,
|
|
18
|
+
UserNotFoundError,
|
|
19
|
+
)
|
|
20
|
+
from reddipy.exporters import ConsoleExporter, CSVExporter, JSONExporter
|
|
21
|
+
from reddipy.scrapers import PostScraper, SearchScraper, SubredditScraper, UserScraper
|
|
22
|
+
from reddipy.utils import generate_filename
|
|
23
|
+
|
|
24
|
+
console = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
"""Add flags shared by all subcommands."""
|
|
29
|
+
parser.add_argument("--limit", type=int, default=25)
|
|
30
|
+
parser.add_argument("--time", "-t", default="all",
|
|
31
|
+
choices=["day", "week", "month", "year", "all"])
|
|
32
|
+
parser.add_argument("--export", default="console",
|
|
33
|
+
choices=["json", "csv", "console"])
|
|
34
|
+
parser.add_argument("--output", "-o", default=None)
|
|
35
|
+
parser.add_argument("--proxy", default=None)
|
|
36
|
+
parser.add_argument("--proxy-file", default=None)
|
|
37
|
+
parser.add_argument("--cookie", default=None,
|
|
38
|
+
help="Reddit session cookie (overrides stored config)")
|
|
39
|
+
parser.add_argument("--cookie-file", default=None,
|
|
40
|
+
help="Path to JSON file with cookies")
|
|
41
|
+
parser.add_argument("--delay", type=float, default=2.0)
|
|
42
|
+
parser.add_argument("--user-agent", default=None)
|
|
43
|
+
parser.add_argument("--verbose", "-v", action="store_true", default=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
"""Build the argument parser with all subcommands."""
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="reddipy",
|
|
50
|
+
description="RedMiner - Scrape Reddit data from the command line.",
|
|
51
|
+
)
|
|
52
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
53
|
+
|
|
54
|
+
# configure
|
|
55
|
+
config_parser = subparsers.add_parser("configure",
|
|
56
|
+
help="Save Reddit session cookie for persistent use")
|
|
57
|
+
config_parser.add_argument("cookie", type=str,
|
|
58
|
+
help="Your reddit_session cookie value")
|
|
59
|
+
|
|
60
|
+
# logout
|
|
61
|
+
subparsers.add_parser("logout", help="Remove stored authentication")
|
|
62
|
+
|
|
63
|
+
# search
|
|
64
|
+
search_parser = subparsers.add_parser("search", help="Search Reddit posts")
|
|
65
|
+
search_parser.add_argument("query", type=str)
|
|
66
|
+
search_parser.add_argument("--sort", default="relevance")
|
|
67
|
+
search_parser.add_argument("--subreddit", "-s", default=None)
|
|
68
|
+
_add_common_flags(search_parser)
|
|
69
|
+
|
|
70
|
+
# post
|
|
71
|
+
post_parser = subparsers.add_parser("post", help="Fetch a single post and its comments")
|
|
72
|
+
post_parser.add_argument("url", type=str)
|
|
73
|
+
post_parser.add_argument("--sort", default="best")
|
|
74
|
+
_add_common_flags(post_parser)
|
|
75
|
+
|
|
76
|
+
# subreddit
|
|
77
|
+
sub_parser = subparsers.add_parser("subreddit", help="Fetch posts from a subreddit")
|
|
78
|
+
sub_parser.add_argument("name", type=str)
|
|
79
|
+
sub_parser.add_argument("--sort", default="hot")
|
|
80
|
+
_add_common_flags(sub_parser)
|
|
81
|
+
|
|
82
|
+
# user
|
|
83
|
+
user_parser = subparsers.add_parser("user", help="Fetch user activity")
|
|
84
|
+
user_parser.add_argument("username", type=str)
|
|
85
|
+
user_parser.add_argument("--sort", default="new")
|
|
86
|
+
user_parser.add_argument("--type", default="all",
|
|
87
|
+
choices=["posts", "comments", "all"])
|
|
88
|
+
_add_common_flags(user_parser)
|
|
89
|
+
|
|
90
|
+
return parser
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _resolve_cookie(args: argparse.Namespace) -> str | None:
|
|
94
|
+
"""Resolve cookie from args or stored config."""
|
|
95
|
+
if getattr(args, "cookie", None):
|
|
96
|
+
return args.cookie
|
|
97
|
+
if getattr(args, "cookie_file", None):
|
|
98
|
+
return None # client handles cookie_file directly
|
|
99
|
+
return get_cookie()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def handle_search(client: RedditClient, args: argparse.Namespace) -> list:
|
|
103
|
+
scraper = SearchScraper(client)
|
|
104
|
+
return scraper.scrape(
|
|
105
|
+
args.query,
|
|
106
|
+
sort=args.sort,
|
|
107
|
+
time_filter=args.time,
|
|
108
|
+
limit=args.limit,
|
|
109
|
+
subreddit=getattr(args, "subreddit", None),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def handle_post(client: RedditClient, args: argparse.Namespace) -> tuple:
|
|
114
|
+
scraper = PostScraper(client)
|
|
115
|
+
post, comments = scraper.scrape(args.url)
|
|
116
|
+
return (post, comments)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def handle_subreddit(client: RedditClient, args: argparse.Namespace) -> list:
|
|
120
|
+
scraper = SubredditScraper(client)
|
|
121
|
+
return scraper.scrape(
|
|
122
|
+
args.name,
|
|
123
|
+
sort=args.sort,
|
|
124
|
+
time_filter=args.time,
|
|
125
|
+
limit=args.limit,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def handle_user(client: RedditClient, args: argparse.Namespace) -> list:
|
|
130
|
+
scraper = UserScraper(client)
|
|
131
|
+
return scraper.scrape(
|
|
132
|
+
args.username,
|
|
133
|
+
limit=args.limit,
|
|
134
|
+
sort=args.sort,
|
|
135
|
+
activity_type=args.type,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def export_data(data, args: argparse.Namespace) -> None:
|
|
140
|
+
"""Export scraped data using the selected exporter."""
|
|
141
|
+
if args.export == "json":
|
|
142
|
+
exporter = JSONExporter()
|
|
143
|
+
ext = "json"
|
|
144
|
+
elif args.export == "csv":
|
|
145
|
+
exporter = CSVExporter()
|
|
146
|
+
ext = "csv"
|
|
147
|
+
else:
|
|
148
|
+
exporter = ConsoleExporter()
|
|
149
|
+
ext = None
|
|
150
|
+
|
|
151
|
+
output_path = getattr(args, "output", None)
|
|
152
|
+
if output_path is None and ext is not None:
|
|
153
|
+
output_path = generate_filename(args.command, ext)
|
|
154
|
+
|
|
155
|
+
if isinstance(data, tuple):
|
|
156
|
+
post, comments = data
|
|
157
|
+
if args.export == "console":
|
|
158
|
+
console.print("\n[bold]Post[/bold]")
|
|
159
|
+
exporter.export([post])
|
|
160
|
+
if comments:
|
|
161
|
+
console.print(f"\n[bold]Comments[/bold] ({len(comments)})")
|
|
162
|
+
exporter.export(comments)
|
|
163
|
+
elif args.export == "json":
|
|
164
|
+
combined = {
|
|
165
|
+
"post": post.to_dict(),
|
|
166
|
+
"comments": [c.to_dict() for c in comments],
|
|
167
|
+
}
|
|
168
|
+
if output_path:
|
|
169
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
170
|
+
json.dump(combined, f, indent=2)
|
|
171
|
+
console.print(f"[green]Saved to {output_path}[/green]")
|
|
172
|
+
else:
|
|
173
|
+
print(json.dumps(combined, indent=2))
|
|
174
|
+
else:
|
|
175
|
+
if comments:
|
|
176
|
+
exporter.export(comments, output_path)
|
|
177
|
+
else:
|
|
178
|
+
exporter.export([post], output_path)
|
|
179
|
+
if output_path:
|
|
180
|
+
console.print(f"[green]Saved to {output_path}[/green]")
|
|
181
|
+
else:
|
|
182
|
+
exporter.export(data, output_path)
|
|
183
|
+
if output_path and ext:
|
|
184
|
+
console.print(f"[green]Saved to {output_path}[/green]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main() -> None:
|
|
188
|
+
"""CLI entry point."""
|
|
189
|
+
parser = build_parser()
|
|
190
|
+
args = parser.parse_args()
|
|
191
|
+
|
|
192
|
+
if not hasattr(args, "command") or args.command is None:
|
|
193
|
+
parser.print_help()
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
# Handle configure/logout commands
|
|
197
|
+
if args.command == "configure":
|
|
198
|
+
set_cookie(args.cookie)
|
|
199
|
+
console.print("[green]Cookie saved successfully.[/green] You can now use reddipy without --cookie.")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if args.command == "logout":
|
|
203
|
+
clear_config()
|
|
204
|
+
console.print("[yellow]Stored authentication removed.[/yellow]")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
cookie = _resolve_cookie(args)
|
|
209
|
+
client = RedditClient(
|
|
210
|
+
delay=args.delay,
|
|
211
|
+
proxy=args.proxy,
|
|
212
|
+
proxy_file=args.proxy_file,
|
|
213
|
+
user_agent=args.user_agent,
|
|
214
|
+
verbose=args.verbose,
|
|
215
|
+
cookie=cookie,
|
|
216
|
+
cookie_file=getattr(args, "cookie_file", None),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if args.command == "search":
|
|
220
|
+
data = handle_search(client, args)
|
|
221
|
+
elif args.command == "post":
|
|
222
|
+
data = handle_post(client, args)
|
|
223
|
+
elif args.command == "subreddit":
|
|
224
|
+
data = handle_subreddit(client, args)
|
|
225
|
+
elif args.command == "user":
|
|
226
|
+
data = handle_user(client, args)
|
|
227
|
+
|
|
228
|
+
export_data(data, args)
|
|
229
|
+
|
|
230
|
+
except (SubredditNotFoundError, PostNotFoundError, UserNotFoundError) as e:
|
|
231
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
232
|
+
sys.exit(3)
|
|
233
|
+
except (NetworkError, RateLimitError, ProxyError) as e:
|
|
234
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
235
|
+
sys.exit(2)
|
|
236
|
+
except RedMinerError as e:
|
|
237
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
238
|
+
sys.exit(1)
|
reddipy/client.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""RedditClient: HTTP layer with rate limiting, pagination, and proxy support."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from reddipy.exceptions import (
|
|
10
|
+
NetworkError,
|
|
11
|
+
PostNotFoundError,
|
|
12
|
+
ProxyError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
SubredditNotFoundError,
|
|
15
|
+
UserNotFoundError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DEFAULT_USER_AGENT = (
|
|
19
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
20
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
21
|
+
"Chrome/149.0.0.0 Safari/537.36"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_HEADERS = {
|
|
25
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
26
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
27
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
28
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
29
|
+
"Cache-Control": "no-cache",
|
|
30
|
+
"Pragma": "no-cache",
|
|
31
|
+
"Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
|
32
|
+
"Sec-CH-UA-Mobile": "?0",
|
|
33
|
+
"Sec-CH-UA-Platform": '"Windows"',
|
|
34
|
+
"Sec-Fetch-Dest": "document",
|
|
35
|
+
"Sec-Fetch-Mode": "navigate",
|
|
36
|
+
"Sec-Fetch-Site": "none",
|
|
37
|
+
"Sec-Fetch-User": "?1",
|
|
38
|
+
"Upgrade-Insecure-Requests": "1",
|
|
39
|
+
"Connection": "keep-alive",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
MAX_RETRIES = 3
|
|
43
|
+
BACKOFF_SCHEDULE = [2, 4, 8]
|
|
44
|
+
DEAD_PROXY_COOLDOWN = 60 # seconds
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RedditClient:
|
|
48
|
+
"""HTTP client for Reddit's public JSON endpoints."""
|
|
49
|
+
|
|
50
|
+
BASE_URL = "https://www.reddit.com"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
delay: float = 2.0,
|
|
55
|
+
proxy: str | None = None,
|
|
56
|
+
proxy_file: str | None = None,
|
|
57
|
+
user_agent: str | None = None,
|
|
58
|
+
verbose: bool = False,
|
|
59
|
+
cookie: str | None = None,
|
|
60
|
+
cookie_file: str | None = None,
|
|
61
|
+
):
|
|
62
|
+
self.delay = delay
|
|
63
|
+
self.verbose = verbose
|
|
64
|
+
self._headers = dict(DEFAULT_HEADERS)
|
|
65
|
+
if user_agent:
|
|
66
|
+
self._headers["User-Agent"] = user_agent
|
|
67
|
+
self._last_request_time: float = 0.0
|
|
68
|
+
self._dead_proxies: dict[str, float] = {}
|
|
69
|
+
|
|
70
|
+
# Build proxy pool
|
|
71
|
+
self._proxies: list[str] = []
|
|
72
|
+
if proxy:
|
|
73
|
+
self._proxies = [proxy]
|
|
74
|
+
elif proxy_file:
|
|
75
|
+
self._proxies = self._load_proxy_file(proxy_file)
|
|
76
|
+
|
|
77
|
+
# Build cookie jar
|
|
78
|
+
self._cookies: dict[str, str] = {}
|
|
79
|
+
if cookie:
|
|
80
|
+
self._cookies["reddit_session"] = cookie
|
|
81
|
+
elif cookie_file:
|
|
82
|
+
self._cookies = self._load_cookie_file(cookie_file)
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Public API
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def fetch(self, url: str, params: dict | None = None) -> dict:
|
|
89
|
+
"""Single HTTP GET with rate limiting, retries, and proxy support."""
|
|
90
|
+
self._rate_limit()
|
|
91
|
+
|
|
92
|
+
proxy_dict = self._select_proxy()
|
|
93
|
+
|
|
94
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
95
|
+
try:
|
|
96
|
+
resp = requests.get(
|
|
97
|
+
url,
|
|
98
|
+
headers=self._headers,
|
|
99
|
+
params=params or {},
|
|
100
|
+
proxies=proxy_dict,
|
|
101
|
+
cookies=self._cookies or None,
|
|
102
|
+
timeout=30,
|
|
103
|
+
)
|
|
104
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
105
|
+
# Mark the proxy dead if one was used
|
|
106
|
+
if proxy_dict:
|
|
107
|
+
proxy_url = proxy_dict.get("http")
|
|
108
|
+
if proxy_url:
|
|
109
|
+
self._dead_proxies[proxy_url] = time.time()
|
|
110
|
+
raise NetworkError(str(exc)) from exc
|
|
111
|
+
|
|
112
|
+
status = resp.status_code
|
|
113
|
+
|
|
114
|
+
# 403 without cookies = auth issue, not "not found"
|
|
115
|
+
if status == 403 and not self._cookies:
|
|
116
|
+
raise NetworkError(
|
|
117
|
+
"Reddit returned 403 Forbidden. You likely need to authenticate. "
|
|
118
|
+
"Run: Reddit.configure(cookie='your_reddit_session') or use --cookie"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Permanent errors (not retryable)
|
|
122
|
+
if status in (403, 404):
|
|
123
|
+
self._raise_not_found(url, status)
|
|
124
|
+
|
|
125
|
+
# Rate limited
|
|
126
|
+
if status == 429:
|
|
127
|
+
if attempt < MAX_RETRIES:
|
|
128
|
+
wait = BACKOFF_SCHEDULE[attempt]
|
|
129
|
+
self._log(f"Rate limited (429). Cooling down {wait}s before retry {attempt + 1}/{MAX_RETRIES}...")
|
|
130
|
+
time.sleep(wait)
|
|
131
|
+
continue
|
|
132
|
+
raise RateLimitError(
|
|
133
|
+
f"Rate limited after {MAX_RETRIES} retries: {url}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Server errors
|
|
137
|
+
if status >= 500:
|
|
138
|
+
if attempt < MAX_RETRIES:
|
|
139
|
+
wait = BACKOFF_SCHEDULE[attempt]
|
|
140
|
+
self._log(f"Server error ({status}). Cooling down {wait}s before retry {attempt + 1}/{MAX_RETRIES}...")
|
|
141
|
+
time.sleep(BACKOFF_SCHEDULE[attempt])
|
|
142
|
+
continue
|
|
143
|
+
raise NetworkError(
|
|
144
|
+
f"Server error {status} after {MAX_RETRIES} retries: {url}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Success
|
|
148
|
+
return resp.json()
|
|
149
|
+
|
|
150
|
+
# Should not be reachable, but just in case
|
|
151
|
+
raise NetworkError(f"Unexpected state after retries: {url}") # pragma: no cover
|
|
152
|
+
|
|
153
|
+
def fetch_all(
|
|
154
|
+
self, url: str, params: dict | None = None, limit: int = 25
|
|
155
|
+
) -> list[dict]:
|
|
156
|
+
"""Paginated fetch that collects items across multiple pages."""
|
|
157
|
+
collected: list[dict] = []
|
|
158
|
+
after: str | None = None
|
|
159
|
+
params = dict(params) if params else {}
|
|
160
|
+
|
|
161
|
+
while len(collected) < limit:
|
|
162
|
+
remaining = limit - len(collected)
|
|
163
|
+
page_params = {**params, "limit": min(100, remaining)}
|
|
164
|
+
if after:
|
|
165
|
+
page_params["after"] = after
|
|
166
|
+
|
|
167
|
+
data = self.fetch(url, params=page_params)
|
|
168
|
+
children = data.get("data", {}).get("children", [])
|
|
169
|
+
|
|
170
|
+
for child in children:
|
|
171
|
+
if len(collected) >= limit:
|
|
172
|
+
break
|
|
173
|
+
collected.append(child.get("data", {}))
|
|
174
|
+
|
|
175
|
+
after = data.get("data", {}).get("after")
|
|
176
|
+
if not after:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
return collected
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Proxy management
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _select_proxy(self) -> dict | None:
|
|
186
|
+
"""Pick a live proxy from the pool, or None if no proxies configured."""
|
|
187
|
+
if not self._proxies:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
now = time.time()
|
|
191
|
+
alive = [
|
|
192
|
+
p
|
|
193
|
+
for p in self._proxies
|
|
194
|
+
if p not in self._dead_proxies
|
|
195
|
+
or (now - self._dead_proxies[p]) >= DEAD_PROXY_COOLDOWN
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
if not alive:
|
|
199
|
+
raise ProxyError("All proxies are dead")
|
|
200
|
+
|
|
201
|
+
chosen = random.choice(alive)
|
|
202
|
+
return {"http": chosen, "https": chosen}
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Internal helpers
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _rate_limit(self) -> None:
|
|
209
|
+
"""Sleep if needed to enforce minimum delay between requests."""
|
|
210
|
+
if self._last_request_time > 0 and self.delay > 0:
|
|
211
|
+
elapsed = time.time() - self._last_request_time
|
|
212
|
+
if elapsed < self.delay:
|
|
213
|
+
time.sleep(self.delay - elapsed)
|
|
214
|
+
self._last_request_time = time.time()
|
|
215
|
+
|
|
216
|
+
def _raise_not_found(self, url: str, status: int) -> None:
|
|
217
|
+
"""Raise the appropriate not-found exception based on URL pattern."""
|
|
218
|
+
if "/comments/" in url:
|
|
219
|
+
raise PostNotFoundError(f"Post not found ({status}): {url}")
|
|
220
|
+
if "/user/" in url or "/u/" in url:
|
|
221
|
+
raise UserNotFoundError(f"User not found ({status}): {url}")
|
|
222
|
+
# Default to subreddit
|
|
223
|
+
raise SubredditNotFoundError(f"Subreddit not found ({status}): {url}")
|
|
224
|
+
|
|
225
|
+
def _log(self, message: str) -> None:
|
|
226
|
+
"""Print to stderr if verbose mode is on."""
|
|
227
|
+
if self.verbose:
|
|
228
|
+
print(message, file=sys.stderr)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _load_proxy_file(path: str) -> list[str]:
|
|
232
|
+
"""Load proxy URLs from a file, one per line."""
|
|
233
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
234
|
+
proxies = [line.strip() for line in f if line.strip()]
|
|
235
|
+
return proxies
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _load_cookie_file(path: str) -> dict[str, str]:
|
|
239
|
+
"""Load cookies from a JSON file.
|
|
240
|
+
|
|
241
|
+
Expects either:
|
|
242
|
+
- A JSON object: {"reddit_session": "value", ...}
|
|
243
|
+
- A JSON array (browser export): [{"name": "reddit_session", "value": "..."}, ...]
|
|
244
|
+
"""
|
|
245
|
+
import json as _json
|
|
246
|
+
|
|
247
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
248
|
+
data = _json.load(f)
|
|
249
|
+
|
|
250
|
+
if isinstance(data, dict):
|
|
251
|
+
return data
|
|
252
|
+
if isinstance(data, list):
|
|
253
|
+
return {item["name"]: item["value"] for item in data if "name" in item and "value" in item}
|
|
254
|
+
return {}
|
reddipy/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Persistent configuration for RedMiner (stored at ~/.reddipy/config.json)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".reddipy")
|
|
7
|
+
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ensure_dir() -> None:
|
|
11
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config() -> dict:
|
|
15
|
+
"""Load config from ~/.reddipy/config.json. Returns empty dict if missing."""
|
|
16
|
+
if not os.path.exists(CONFIG_FILE):
|
|
17
|
+
return {}
|
|
18
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
19
|
+
return json.load(f)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def save_config(config: dict) -> None:
|
|
23
|
+
"""Save config to ~/.reddipy/config.json."""
|
|
24
|
+
_ensure_dir()
|
|
25
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
26
|
+
json.dump(config, f, indent=2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_cookie() -> str | None:
|
|
30
|
+
"""Get stored reddit_session cookie, or None."""
|
|
31
|
+
return load_config().get("reddit_session")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_cookie(cookie: str) -> None:
|
|
35
|
+
"""Store reddit_session cookie persistently."""
|
|
36
|
+
config = load_config()
|
|
37
|
+
config["reddit_session"] = cookie
|
|
38
|
+
save_config(config)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clear_config() -> None:
|
|
42
|
+
"""Remove stored config."""
|
|
43
|
+
if os.path.exists(CONFIG_FILE):
|
|
44
|
+
os.remove(CONFIG_FILE)
|
reddipy/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Domain-specific exceptions for RedMiner."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RedMinerError(Exception):
|
|
5
|
+
"""Base exception for all RedMiner errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SubredditNotFoundError(RedMinerError):
|
|
9
|
+
"""Raised when a subreddit is private, banned, or nonexistent (403/404)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PostNotFoundError(RedMinerError):
|
|
13
|
+
"""Raised when a post URL is deleted or invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserNotFoundError(RedMinerError):
|
|
17
|
+
"""Raised when a user is suspended, shadow-banned, or nonexistent."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitError(RedMinerError):
|
|
21
|
+
"""Raised when all retries are exhausted after repeated 429 responses."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NetworkError(RedMinerError):
|
|
25
|
+
"""Raised on connection failures or timeouts."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProxyError(RedMinerError):
|
|
29
|
+
"""Raised when all proxies are exhausted or dead."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Console exporter: prints rich formatted tables to stdout."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from reddipy.exporters.base import BaseExporter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _truncate(text: str | None, max_len: int) -> str:
|
|
10
|
+
if text is None:
|
|
11
|
+
return ""
|
|
12
|
+
text = text.replace("\n", " ").replace("\r", "")
|
|
13
|
+
if len(text) <= max_len:
|
|
14
|
+
return text
|
|
15
|
+
return text[:max_len] + "..."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConsoleExporter(BaseExporter):
|
|
19
|
+
def export(self, data: list, output_path: str | None = None) -> None:
|
|
20
|
+
if not data:
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
first = data[0]
|
|
24
|
+
class_name = type(first).__name__
|
|
25
|
+
|
|
26
|
+
if class_name == "Post":
|
|
27
|
+
self._print_posts(data)
|
|
28
|
+
elif class_name == "Comment":
|
|
29
|
+
self._print_comments(data)
|
|
30
|
+
elif class_name == "UserActivity":
|
|
31
|
+
self._print_user_activity(data)
|
|
32
|
+
|
|
33
|
+
def _print_posts(self, data: list) -> None:
|
|
34
|
+
table = Table(show_header=True, header_style="bold cyan", border_style="dim",
|
|
35
|
+
pad_edge=False, show_lines=False, expand=True)
|
|
36
|
+
table.add_column("ID", style="dim", no_wrap=True, ratio=1)
|
|
37
|
+
table.add_column("Title", ratio=5)
|
|
38
|
+
table.add_column("Author", style="green", ratio=2)
|
|
39
|
+
table.add_column("Subreddit", style="magenta", ratio=2)
|
|
40
|
+
table.add_column("Score", justify="right", style="yellow", ratio=1)
|
|
41
|
+
table.add_column("Comments", justify="right", style="blue", ratio=1)
|
|
42
|
+
|
|
43
|
+
for item in data:
|
|
44
|
+
table.add_row(
|
|
45
|
+
item.id,
|
|
46
|
+
_truncate(item.title, 62),
|
|
47
|
+
item.author,
|
|
48
|
+
f"r/{item.subreddit}",
|
|
49
|
+
str(item.score),
|
|
50
|
+
str(item.num_comments),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
Console().print(table)
|
|
54
|
+
|
|
55
|
+
def _print_comments(self, data: list) -> None:
|
|
56
|
+
table = Table(show_header=True, header_style="bold cyan", border_style="dim",
|
|
57
|
+
pad_edge=False, show_lines=False, expand=True)
|
|
58
|
+
table.add_column("ID", style="dim", no_wrap=True, ratio=1)
|
|
59
|
+
table.add_column("Author", style="green", ratio=2)
|
|
60
|
+
table.add_column("Comment", ratio=5)
|
|
61
|
+
table.add_column("Score", justify="right", style="yellow", ratio=1)
|
|
62
|
+
table.add_column("Depth", justify="right", style="blue", ratio=1)
|
|
63
|
+
|
|
64
|
+
for item in data:
|
|
65
|
+
table.add_row(
|
|
66
|
+
item.id,
|
|
67
|
+
item.author,
|
|
68
|
+
_truncate(item.body, 52),
|
|
69
|
+
str(item.score),
|
|
70
|
+
str(item.depth),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
Console().print(table)
|
|
74
|
+
|
|
75
|
+
def _print_user_activity(self, data: list) -> None:
|
|
76
|
+
table = Table(show_header=True, header_style="bold cyan", border_style="dim",
|
|
77
|
+
pad_edge=False, show_lines=False, expand=True)
|
|
78
|
+
table.add_column("Type", style="bold", ratio=1)
|
|
79
|
+
table.add_column("ID", style="dim", no_wrap=True, ratio=1)
|
|
80
|
+
table.add_column("Author", style="green", ratio=2)
|
|
81
|
+
table.add_column("Subreddit", style="magenta", ratio=2)
|
|
82
|
+
table.add_column("Score", justify="right", style="yellow", ratio=1)
|
|
83
|
+
table.add_column("Content", ratio=4)
|
|
84
|
+
|
|
85
|
+
for item in data:
|
|
86
|
+
content = item.title if item.title is not None else item.body
|
|
87
|
+
table.add_row(
|
|
88
|
+
item.type,
|
|
89
|
+
item.id,
|
|
90
|
+
item.author,
|
|
91
|
+
f"r/{item.subreddit}",
|
|
92
|
+
str(item.score),
|
|
93
|
+
_truncate(content, 47),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
Console().print(table)
|