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.
- cli_web/reddit/README.md +68 -0
- cli_web/reddit/__init__.py +3 -0
- cli_web/reddit/__main__.py +6 -0
- cli_web/reddit/commands/__init__.py +0 -0
- cli_web/reddit/commands/actions.py +268 -0
- cli_web/reddit/commands/auth_cmd.py +73 -0
- cli_web/reddit/commands/feed.py +115 -0
- cli_web/reddit/commands/me.py +139 -0
- cli_web/reddit/commands/post.py +93 -0
- cli_web/reddit/commands/search.py +66 -0
- cli_web/reddit/commands/subreddit.py +184 -0
- cli_web/reddit/commands/user.py +90 -0
- cli_web/reddit/core/__init__.py +0 -0
- cli_web/reddit/core/auth.py +204 -0
- cli_web/reddit/core/client.py +475 -0
- cli_web/reddit/core/exceptions.py +63 -0
- cli_web/reddit/core/models.py +253 -0
- cli_web/reddit/reddit_cli.py +174 -0
- cli_web/reddit/skills/SKILL.md +143 -0
- cli_web/reddit/tests/TEST.md +109 -0
- cli_web/reddit/tests/__init__.py +0 -0
- cli_web/reddit/tests/conftest.py +9 -0
- cli_web/reddit/tests/test_core.py +568 -0
- cli_web/reddit/tests/test_e2e.py +312 -0
- cli_web/reddit/utils/__init__.py +0 -0
- cli_web/reddit/utils/doctor.py +188 -0
- cli_web/reddit/utils/helpers.py +91 -0
- cli_web/reddit/utils/mcp_server.py +290 -0
- cli_web/reddit/utils/output.py +133 -0
- cli_web/reddit/utils/repl_skin.py +486 -0
- cli_web_reddit-0.1.0.dist-info/METADATA +15 -0
- cli_web_reddit-0.1.0.dist-info/RECORD +35 -0
- cli_web_reddit-0.1.0.dist-info/WHEEL +5 -0
- cli_web_reddit-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_reddit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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}
|