cli-web-reddit 0.1.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.
@@ -0,0 +1,93 @@
1
+ """Post commands for cli-web-reddit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import click
8
+
9
+ from ..core.client import RedditClient
10
+ from ..core.models import format_post_detail
11
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
12
+ from ..utils.output import comment_table, post_detail_display
13
+
14
+
15
+ @click.group("post")
16
+ def post():
17
+ """View post details and comments."""
18
+
19
+
20
+ def _parse_post_url(url_or_id: str) -> tuple[str, str, str]:
21
+ """Parse a Reddit post URL or ID into (subreddit, post_id, slug).
22
+
23
+ Accepts:
24
+ - Full URL: https://www.reddit.com/r/python/comments/abc123/my_post/
25
+ - Short path: r/python/comments/abc123/my_post
26
+ - Just the post ID: abc123 (subreddit resolved by Reddit API)
27
+ - Fullname: t3_abc123 (stripped to abc123)
28
+ """
29
+ # Full URL or path with /r/sub/comments/id/slug pattern
30
+ match = re.search(r"r/([^/]+)/comments/([^/]+)(?:/([^/?]+))?", url_or_id)
31
+ if match:
32
+ return match.group(1), match.group(2), match.group(3) or ""
33
+ # Strip t3_ prefix if present
34
+ post_id = url_or_id.strip("/")
35
+ if post_id.startswith("t3_"):
36
+ post_id = post_id[3:]
37
+ return "", post_id, ""
38
+
39
+
40
+ @post.command("get")
41
+ @click.argument("url_or_id")
42
+ @click.option("--sub", default=None, help="Subreddit name (required if passing just a post ID).")
43
+ @click.option(
44
+ "--comments", "comment_limit", type=int, default=50, help="Number of comments to fetch."
45
+ )
46
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
47
+ def get(url_or_id, sub, comment_limit, use_json):
48
+ """Get post details and comments.
49
+
50
+ Pass a full Reddit URL or a post ID (with --sub).
51
+
52
+ Examples:
53
+ post get https://www.reddit.com/r/python/comments/abc123/my_post/
54
+ post get abc123 --sub python
55
+ """
56
+ use_json = resolve_json_mode(use_json)
57
+ with handle_errors(json_mode=use_json):
58
+ subreddit, post_id, slug = _parse_post_url(url_or_id)
59
+ if not subreddit and sub:
60
+ subreddit = sub
61
+
62
+ client = RedditClient()
63
+ data = client.post_detail(
64
+ subreddit, post_id, slug=slug, comment_limit=comment_limit, depth=50
65
+ )
66
+
67
+ # Reddit returns [post_listing, comments_listing]
68
+ post_listing = data[0] if len(data) > 0 else {}
69
+ comments_listing = data[1] if len(data) > 1 else {}
70
+
71
+ post_children = post_listing.get("data", {}).get("children", [])
72
+ post_data = post_children[0] if post_children else {}
73
+
74
+ # Extract link_id for fetching collapsed comments
75
+ link_id = post_data.get("data", {}).get("name", "") or f"t3_{post_id}"
76
+
77
+ result = format_post_detail(
78
+ post_data,
79
+ comments_listing,
80
+ more_children_fn=client.more_children,
81
+ link_id=link_id,
82
+ thread_fn=client.comment_thread,
83
+ post_id=post_id,
84
+ )
85
+
86
+ if use_json:
87
+ print_json(result)
88
+ else:
89
+ post_detail_display(result)
90
+ if result.get("comments"):
91
+ comment_table(result["comments"][:20], title="Top Comments")
92
+ if len(result["comments"]) > 20:
93
+ click.echo(f" ... and {len(result['comments']) - 20} more comments")
@@ -0,0 +1,66 @@
1
+ """Search commands for cli-web-reddit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import RedditClient
8
+ from ..core.models import extract_listing_posts, extract_listing_subreddits
9
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
10
+ from ..utils.output import post_table, subreddit_table
11
+
12
+ SORT_CHOICES = ["relevance", "hot", "top", "new", "comments"]
13
+ TIME_CHOICES = ["hour", "day", "week", "month", "year", "all"]
14
+
15
+
16
+ @click.group("search")
17
+ def search():
18
+ """Search Reddit posts and subreddits."""
19
+
20
+
21
+ @search.command("posts")
22
+ @click.argument("query")
23
+ @click.option("--sort", type=click.Choice(SORT_CHOICES), default="relevance", help="Sort order.")
24
+ @click.option(
25
+ "--time",
26
+ "time_filter",
27
+ type=click.Choice(TIME_CHOICES),
28
+ default=None,
29
+ help="Time period (for top sort).",
30
+ )
31
+ @click.option("--limit", type=int, default=25, help="Number of results (max 100).")
32
+ @click.option("--after", default=None, help="Pagination cursor.")
33
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
34
+ def posts(query, sort, time_filter, limit, after, use_json):
35
+ """Search posts across all of Reddit."""
36
+ use_json = resolve_json_mode(use_json)
37
+ with handle_errors(json_mode=use_json):
38
+ client = RedditClient()
39
+ data = client.search_posts(query, limit=limit, sort=sort, time=time_filter, after=after)
40
+ results, next_after = extract_listing_posts(data)
41
+ if use_json:
42
+ print_json({"query": query, "posts": results, "after": next_after})
43
+ else:
44
+ post_table(results, title=f"Search: {query}")
45
+ if next_after:
46
+ click.echo(f' Next page: search posts "{query}" --after {next_after}')
47
+
48
+
49
+ @search.command("subs")
50
+ @click.argument("query")
51
+ @click.option("--limit", type=int, default=25, help="Number of results (max 100).")
52
+ @click.option("--after", default=None, help="Pagination cursor.")
53
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
54
+ def subs(query, limit, after, use_json):
55
+ """Search for subreddits by name/description."""
56
+ use_json = resolve_json_mode(use_json)
57
+ with handle_errors(json_mode=use_json):
58
+ client = RedditClient()
59
+ data = client.search_subreddits(query, limit=limit, after=after)
60
+ results, next_after = extract_listing_subreddits(data)
61
+ if use_json:
62
+ print_json({"query": query, "subreddits": results, "after": next_after})
63
+ else:
64
+ subreddit_table(results, title=f"Subreddits: {query}")
65
+ if next_after:
66
+ click.echo(f' Next page: search subs "{query}" --after {next_after}')
@@ -0,0 +1,184 @@
1
+ """Subreddit commands for cli-web-reddit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import RedditClient
8
+ from ..core.models import extract_listing_posts, format_subreddit_info
9
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
10
+ from ..utils.output import post_table, subreddit_detail_display
11
+
12
+ SORT_CHOICES = ["hot", "new", "top", "rising"]
13
+ TIME_CHOICES = ["hour", "day", "week", "month", "year", "all"]
14
+ SEARCH_SORT_CHOICES = ["relevance", "hot", "top", "new", "comments"]
15
+
16
+
17
+ @click.group("sub")
18
+ def sub():
19
+ """Browse subreddits — posts, info, rules, search."""
20
+
21
+
22
+ @sub.command("hot")
23
+ @click.argument("name")
24
+ @click.option("--limit", type=int, default=25, help="Number of posts (max 100).")
25
+ @click.option("--after", default=None, help="Pagination cursor.")
26
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
27
+ def hot(name, limit, after, use_json):
28
+ """Hot posts in a subreddit."""
29
+ use_json = resolve_json_mode(use_json)
30
+ with handle_errors(json_mode=use_json):
31
+ client = RedditClient()
32
+ data = client.sub_posts(name, sort="hot", limit=limit, after=after)
33
+ posts, next_after = extract_listing_posts(data)
34
+ if use_json:
35
+ print_json({"subreddit": name, "posts": posts, "after": next_after})
36
+ else:
37
+ post_table(posts, title=f"r/{name} — Hot")
38
+ if next_after:
39
+ click.echo(f" Next page: sub hot {name} --after {next_after}")
40
+
41
+
42
+ @sub.command("new")
43
+ @click.argument("name")
44
+ @click.option("--limit", type=int, default=25, help="Number of posts (max 100).")
45
+ @click.option("--after", default=None, help="Pagination cursor.")
46
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
47
+ def new(name, limit, after, use_json):
48
+ """Newest posts in a subreddit."""
49
+ use_json = resolve_json_mode(use_json)
50
+ with handle_errors(json_mode=use_json):
51
+ client = RedditClient()
52
+ data = client.sub_posts(name, sort="new", limit=limit, after=after)
53
+ posts, next_after = extract_listing_posts(data)
54
+ if use_json:
55
+ print_json({"subreddit": name, "posts": posts, "after": next_after})
56
+ else:
57
+ post_table(posts, title=f"r/{name} — New")
58
+ if next_after:
59
+ click.echo(f" Next page: sub new {name} --after {next_after}")
60
+
61
+
62
+ @sub.command("top")
63
+ @click.argument("name")
64
+ @click.option(
65
+ "--time", "time_filter", type=click.Choice(TIME_CHOICES), default="day", help="Time period."
66
+ )
67
+ @click.option("--limit", type=int, default=25, help="Number of posts (max 100).")
68
+ @click.option("--after", default=None, help="Pagination cursor.")
69
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
70
+ def top(name, time_filter, limit, after, use_json):
71
+ """Top posts in a subreddit by time period."""
72
+ use_json = resolve_json_mode(use_json)
73
+ with handle_errors(json_mode=use_json):
74
+ client = RedditClient()
75
+ data = client.sub_posts(name, sort="top", limit=limit, after=after, time=time_filter)
76
+ posts, next_after = extract_listing_posts(data)
77
+ if use_json:
78
+ print_json({"subreddit": name, "posts": posts, "after": next_after})
79
+ else:
80
+ post_table(posts, title=f"r/{name} — Top ({time_filter})")
81
+ if next_after:
82
+ click.echo(f" Next page: sub top {name} --time {time_filter} --after {next_after}")
83
+
84
+
85
+ @sub.command("info")
86
+ @click.argument("name")
87
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
88
+ def info(name, use_json):
89
+ """Get subreddit info and stats."""
90
+ use_json = resolve_json_mode(use_json)
91
+ with handle_errors(json_mode=use_json):
92
+ client = RedditClient()
93
+ data = client.sub_info(name)
94
+ result = format_subreddit_info(data)
95
+ if use_json:
96
+ print_json(result)
97
+ else:
98
+ subreddit_detail_display(result)
99
+
100
+
101
+ @sub.command("rules")
102
+ @click.argument("name")
103
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
104
+ def rules(name, use_json):
105
+ """Get subreddit rules."""
106
+ use_json = resolve_json_mode(use_json)
107
+ with handle_errors(json_mode=use_json):
108
+ client = RedditClient()
109
+ data = client.sub_rules(name)
110
+ rule_list = data.get("rules", [])
111
+ formatted = [
112
+ {
113
+ "priority": r.get("priority", i),
114
+ "name": r.get("short_name", ""),
115
+ "description": r.get("description", ""),
116
+ "kind": r.get("kind", ""),
117
+ }
118
+ for i, r in enumerate(rule_list)
119
+ ]
120
+ if use_json:
121
+ print_json({"subreddit": name, "rules": formatted})
122
+ else:
123
+ click.echo(f"\n Rules for r/{name}:")
124
+ for r in formatted:
125
+ click.echo(f" {r['priority'] + 1}. {r['name']}")
126
+ if r["description"]:
127
+ desc = r["description"][:150].replace("\n", " ")
128
+ click.echo(f" {desc}")
129
+ click.echo()
130
+
131
+
132
+ @sub.command("search")
133
+ @click.argument("name")
134
+ @click.argument("query")
135
+ @click.option(
136
+ "--sort", type=click.Choice(SEARCH_SORT_CHOICES), default="relevance", help="Sort order."
137
+ )
138
+ @click.option("--limit", type=int, default=25, help="Number of results (max 100).")
139
+ @click.option("--after", default=None, help="Pagination cursor.")
140
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
141
+ def search(name, query, sort, limit, after, use_json):
142
+ """Search posts within a subreddit."""
143
+ use_json = resolve_json_mode(use_json)
144
+ with handle_errors(json_mode=use_json):
145
+ client = RedditClient()
146
+ data = client.sub_search(name, query, limit=limit, sort=sort, after=after)
147
+ posts, next_after = extract_listing_posts(data)
148
+ if use_json:
149
+ print_json({"subreddit": name, "query": query, "posts": posts, "after": next_after})
150
+ else:
151
+ click.echo(f" Search r/{name} for '{query}':")
152
+ post_table(posts, title=f"r/{name} Search: {query}")
153
+ if next_after:
154
+ click.echo(f' Next page: sub search {name} "{query}" --after {next_after}')
155
+
156
+
157
+ @sub.command("join")
158
+ @click.argument("name")
159
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
160
+ def join(name, use_json):
161
+ """Subscribe to a subreddit (requires login)."""
162
+ use_json = resolve_json_mode(use_json)
163
+ with handle_errors(json_mode=use_json):
164
+ client = RedditClient()
165
+ client.sub_join(name)
166
+ if use_json:
167
+ print_json({"success": True, "action": "subscribe", "subreddit": name})
168
+ else:
169
+ click.echo(f" Subscribed to r/{name}")
170
+
171
+
172
+ @sub.command("leave")
173
+ @click.argument("name")
174
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
175
+ def leave(name, use_json):
176
+ """Unsubscribe from a subreddit (requires login)."""
177
+ use_json = resolve_json_mode(use_json)
178
+ with handle_errors(json_mode=use_json):
179
+ client = RedditClient()
180
+ client.sub_leave(name)
181
+ if use_json:
182
+ print_json({"success": True, "action": "unsubscribe", "subreddit": name})
183
+ else:
184
+ click.echo(f" Unsubscribed from r/{name}")
@@ -0,0 +1,90 @@
1
+ """User commands for cli-web-reddit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import RedditClient
8
+ from ..core.models import extract_listing_comments, extract_listing_posts, format_user_info
9
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
10
+ from ..utils.output import comment_table, post_table, user_detail_display
11
+
12
+ SORT_CHOICES = ["hot", "new", "top", "controversial"]
13
+ TIME_CHOICES = ["hour", "day", "week", "month", "year", "all"]
14
+
15
+
16
+ @click.group("user")
17
+ def user():
18
+ """View user profiles and activity."""
19
+
20
+
21
+ @user.command("info")
22
+ @click.argument("username")
23
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
24
+ def info(username, use_json):
25
+ """Get user profile information."""
26
+ use_json = resolve_json_mode(use_json)
27
+ with handle_errors(json_mode=use_json):
28
+ client = RedditClient()
29
+ data = client.user_about(username)
30
+ result = format_user_info(data)
31
+ if use_json:
32
+ print_json(result)
33
+ else:
34
+ user_detail_display(result)
35
+
36
+
37
+ @user.command("posts")
38
+ @click.argument("username")
39
+ @click.option("--sort", type=click.Choice(SORT_CHOICES), default="new", help="Sort order.")
40
+ @click.option(
41
+ "--time",
42
+ "time_filter",
43
+ type=click.Choice(TIME_CHOICES),
44
+ default=None,
45
+ help="Time period (for top sort).",
46
+ )
47
+ @click.option("--limit", type=int, default=25, help="Number of posts (max 100).")
48
+ @click.option("--after", default=None, help="Pagination cursor.")
49
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
50
+ def posts(username, sort, time_filter, limit, after, use_json):
51
+ """View a user's submitted posts."""
52
+ use_json = resolve_json_mode(use_json)
53
+ with handle_errors(json_mode=use_json):
54
+ client = RedditClient()
55
+ data = client.user_posts(username, limit=limit, after=after, sort=sort, time=time_filter)
56
+ results, next_after = extract_listing_posts(data)
57
+ if use_json:
58
+ print_json({"username": username, "posts": results, "after": next_after})
59
+ else:
60
+ post_table(results, title=f"u/{username} — Posts")
61
+ if next_after:
62
+ click.echo(f" Next page: user posts {username} --after {next_after}")
63
+
64
+
65
+ @user.command("comments")
66
+ @click.argument("username")
67
+ @click.option("--sort", type=click.Choice(SORT_CHOICES), default="new", help="Sort order.")
68
+ @click.option(
69
+ "--time",
70
+ "time_filter",
71
+ type=click.Choice(TIME_CHOICES),
72
+ default=None,
73
+ help="Time period (for top sort).",
74
+ )
75
+ @click.option("--limit", type=int, default=25, help="Number of comments (max 100).")
76
+ @click.option("--after", default=None, help="Pagination cursor.")
77
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
78
+ def comments(username, sort, time_filter, limit, after, use_json):
79
+ """View a user's comments."""
80
+ use_json = resolve_json_mode(use_json)
81
+ with handle_errors(json_mode=use_json):
82
+ client = RedditClient()
83
+ data = client.user_comments(username, limit=limit, after=after, sort=sort, time=time_filter)
84
+ results, next_after = extract_listing_comments(data)
85
+ if use_json:
86
+ print_json({"username": username, "comments": results, "after": next_after})
87
+ else:
88
+ comment_table(results, title=f"u/{username} — Comments")
89
+ if next_after:
90
+ click.echo(f" Next page: user comments {username} --after {next_after}")
File without changes
@@ -0,0 +1,204 @@
1
+ """Auth management for cli-web-reddit.
2
+
3
+ Uses Python playwright for browser-based Reddit login.
4
+ Stores bearer token (token_v2) and cookies at ~/.config/cli-web-reddit/auth.json.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import platform
13
+ import stat
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .exceptions import AuthError
18
+
19
+ AUTH_DIR = Path.home() / ".config" / "cli-web-reddit"
20
+ AUTH_FILE = AUTH_DIR / "auth.json"
21
+
22
+ # Environment variable override for CI/CD
23
+ ENV_VAR = "CLI_WEB_REDDIT_AUTH_JSON"
24
+
25
+
26
+ def _ensure_dir() -> None:
27
+ AUTH_DIR.mkdir(parents=True, exist_ok=True)
28
+
29
+
30
+ def load_auth() -> dict | None:
31
+ """Load auth data from env var or file. Returns dict with 'token' and 'cookies' keys."""
32
+ # Env var override
33
+ env = os.environ.get(ENV_VAR)
34
+ if env:
35
+ try:
36
+ return json.loads(env)
37
+ except json.JSONDecodeError:
38
+ return None
39
+
40
+ if not AUTH_FILE.exists():
41
+ return None
42
+
43
+ try:
44
+ data = json.loads(AUTH_FILE.read_text(encoding="utf-8"))
45
+ # Handle both formats
46
+ if isinstance(data, dict) and "token" in data:
47
+ return data
48
+ if isinstance(data, list):
49
+ # Raw cookie list — extract token_v2
50
+ token = ""
51
+ cookie_dict = {}
52
+ for c in data:
53
+ if isinstance(c, dict):
54
+ cookie_dict[c["name"]] = c["value"]
55
+ if c["name"] == "token_v2":
56
+ token = c["value"]
57
+ return {"token": token, "cookies": cookie_dict}
58
+ if isinstance(data, dict):
59
+ # Plain dict without "token" key — try to find token_v2 in values
60
+ token = data.get("token_v2", "")
61
+ return {"token": token, "cookies": data}
62
+ return None
63
+ except (json.JSONDecodeError, KeyError):
64
+ return None
65
+
66
+
67
+ def save_auth(token: str, cookies: dict) -> None:
68
+ """Save auth data to file with restricted permissions."""
69
+ _ensure_dir()
70
+ data = {"token": token, "cookies": cookies}
71
+ AUTH_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
72
+ # chmod 600 on Unix
73
+ if platform.system() != "Windows":
74
+ AUTH_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
75
+
76
+
77
+ def clear_auth() -> None:
78
+ """Remove auth file."""
79
+ if AUTH_FILE.exists():
80
+ AUTH_FILE.unlink()
81
+
82
+
83
+ def get_bearer_token() -> str | None:
84
+ """Get the bearer token for OAuth API calls."""
85
+ auth = load_auth()
86
+ if not auth:
87
+ return None
88
+ return auth.get("token") or None
89
+
90
+
91
+ def get_cookies() -> dict:
92
+ """Get cookies dict for session warmup."""
93
+ auth = load_auth()
94
+ if not auth:
95
+ return {}
96
+ return auth.get("cookies", {})
97
+
98
+
99
+ def refresh_token() -> str | None:
100
+ """Silently refresh token_v2 using the persistent browser profile.
101
+
102
+ Launches a headless browser with the saved profile, navigates to Reddit
103
+ (which auto-refreshes the token_v2 cookie), extracts and saves the new token.
104
+ Returns the new token or None if refresh failed.
105
+ """
106
+ profile_dir = AUTH_DIR / "browser-profile"
107
+ if not profile_dir.exists():
108
+ return None
109
+
110
+ if sys.platform == "win32":
111
+ asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
112
+
113
+ try:
114
+ from playwright.sync_api import sync_playwright
115
+ except ImportError:
116
+ return None
117
+
118
+ try:
119
+ with sync_playwright() as p:
120
+ context = p.chromium.launch_persistent_context(
121
+ user_data_dir=str(profile_dir),
122
+ headless=True,
123
+ args=[
124
+ "--disable-blink-features=AutomationControlled",
125
+ "--no-first-run",
126
+ "--no-default-browser-check",
127
+ ],
128
+ )
129
+
130
+ page = context.pages[0] if context.pages else context.new_page()
131
+ page.goto("https://www.reddit.com/", wait_until="domcontentloaded")
132
+ page.wait_for_timeout(3000)
133
+
134
+ cookies = context.cookies()
135
+ token = ""
136
+ cookie_dict = {}
137
+
138
+ for c in cookies:
139
+ if "reddit.com" in c.get("domain", ""):
140
+ cookie_dict[c["name"]] = c["value"]
141
+ if c["name"] == "token_v2":
142
+ token = c["value"]
143
+
144
+ context.close()
145
+
146
+ if token:
147
+ save_auth(token, cookie_dict)
148
+ return token
149
+ return None
150
+ except Exception:
151
+ return None
152
+
153
+
154
+ def login_browser() -> dict:
155
+ """Open browser for Reddit login, extract cookies and token.
156
+
157
+ Returns dict with 'token' and 'cookies' keys.
158
+ """
159
+ # Windows event loop fix
160
+ if sys.platform == "win32":
161
+ asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
162
+
163
+ from playwright.sync_api import sync_playwright
164
+
165
+ with sync_playwright() as p:
166
+ context = p.chromium.launch_persistent_context(
167
+ user_data_dir=str(AUTH_DIR / "browser-profile"),
168
+ headless=False,
169
+ args=[
170
+ "--disable-blink-features=AutomationControlled",
171
+ "--no-first-run",
172
+ "--no-default-browser-check",
173
+ ],
174
+ )
175
+
176
+ page = context.pages[0] if context.pages else context.new_page()
177
+ page.goto("https://www.reddit.com/login")
178
+
179
+ print("\n Please log into Reddit in the browser window.")
180
+ print(" Press Enter here when you're logged in and see the Reddit homepage.\n")
181
+ input(" Waiting... ")
182
+
183
+ # Navigate to homepage to ensure all cookies are set
184
+ page.goto("https://www.reddit.com/")
185
+ page.wait_for_timeout(2000)
186
+
187
+ # Extract cookies
188
+ cookies = context.cookies()
189
+ cookie_dict = {}
190
+ token = ""
191
+
192
+ for c in cookies:
193
+ if "reddit.com" in c.get("domain", ""):
194
+ cookie_dict[c["name"]] = c["value"]
195
+ if c["name"] == "token_v2":
196
+ token = c["value"]
197
+
198
+ context.close()
199
+
200
+ if not token:
201
+ raise AuthError("Login failed — no token_v2 cookie found. Please try again.")
202
+
203
+ save_auth(token, cookie_dict)
204
+ return {"token": token, "cookies": cookie_dict}