praw-cli 0.6.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,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: praw-cli
3
+ Version: 0.6.0
4
+ Summary: Composable Reddit crawler CLI with lazy pipelines, powerful filtering, reproducible datasets, and flexible output formats.
5
+ Project-URL: Repository, https://github.com/othonhugo/praw-cli
6
+ Project-URL: Bug-Tracker, https://github.com/othonhugo/praw-cli/issues
7
+ Project-URL: Documentation, https://github.com/othonhugo/praw-cli#documentation
8
+ Author: Othon H.
9
+ Keywords: cli,crawler,data-science,dataset,nlp,praw,reddit
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: praw>=8.0.2
19
+ Requires-Dist: tenacity>=9.1.4
20
+ Requires-Dist: typer[all]>=0.12.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # praw-cli
24
+
25
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
26
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-purple.svg)](https://github.com/astral-sh/ruff)
27
+
28
+ A composable Reddit crawler for the command line. Fetch posts and comments from any source, shape the data through a lazy filter pipeline, and emit it in any format — all without writing a single line of Python.
29
+
30
+ ```bash
31
+ # stream the top 500 posts from r/MachineLearning as JSONL
32
+ praw-cli posts r/MachineLearning --sort top --time year --limit 500 --format jsonl
33
+
34
+ # search across r/programming, keep only high-signal posts, project to four fields
35
+ praw-cli search "New web framework" --sub r/programming \
36
+ --filter "score>=100" --filter "num_comments>=10" \
37
+ --fields id,title,score,url --format csv --output results.csv
38
+
39
+ # full comment tree of a submission, depth-first, minimum score 5
40
+ praw-cli comments https://reddit.com/r/Python/comments/xyz/ \
41
+ --depth 10 --min-score 5 --format jsonl
42
+ ```
43
+
44
+ ## Key Features & How They Solve Your Problems
45
+
46
+ ### Smart Memory & Reliability
47
+
48
+ - **Constant Memory Footprint (Lazy Pipelines)**: Typical scrapers buffer huge arrays in memory, causing out-of-memory crashes on large crawls. praw-cli processes data lazily item-by-item (`Iterator[Record]`). Streaming 100,000 posts uses the same constant memory as streaming 10.
49
+ - **Resumable Extractions (Checkpointing)**: Long-running scrapes often fail halfway due to network drops or API limits. praw-cli checkpoints progress, letting you `--resume` interrupted sessions without refetching from scratch.
50
+
51
+ ### Zero-Code Data Preparation
52
+
53
+ - **Data Science-Focused DSL**: Stop writing custom Python scripts just to filter text. Chain `--filter` conditions directly in the CLI:
54
+ - _NLP Cleaning_: Keep long-form content using `selftext len>= 500`.
55
+ - _Temporal Sorting_: Restrict dates natively using ISO-8601 strings, like `created_utc >= 2024-01-01`.
56
+ - _Targeted Mining_: Target specific topics with keyword groupings (`has`, `has_all`) or regular expressions (`title ~= \bbot\b`).
57
+ - **Field Projections**: Keep output datasets lightweight and clean by extracting only the schema columns you need (e.g., `--fields id,title,score,author`).
58
+
59
+ ### Instant Pandas & R Integrations
60
+
61
+ - **Diverse Output Formats**: Stream outputs directly to `jsonl` (preferred for streaming/Pandas), standard `json`, `csv` (for R/Excel), or rich console tables and Markdown reports.
62
+ - **Multi-Sink Pipeline**: Write the raw data to a `.jsonl` database while simultaneously writing a preview to a `.csv` summary in a single pass.
63
+
64
+ ### Built-in Scientific Rigor
65
+
66
+ - **Crawl Manifests**: Every execution automatically generates a `.manifest.json` detailing exact parameters, versioning, records filtered, and a config fingerprint, preventing configuration drift in research environments.
67
+ - **Deterministic Sampling**: Extract reproducible subsets of huge subreddits using systematic or randomized sampling (e.g., Bernoulli trial at `rate = 0.1` with a fixed seed).
68
+
69
+ ## Installation
70
+
71
+ Requires Python 3.12 or later and a [Reddit API application](https://www.reddit.com/prefs/apps) (free, read-only access is sufficient for most use cases).
72
+
73
+ ### Via PyPI (Recommended)
74
+
75
+ Install the package directly using `pip` or `pipx`:
76
+
77
+ ```bash
78
+ pip install praw-cli
79
+ ```
80
+
81
+ ### From Source (Using `uv`)
82
+
83
+ If you want to run it locally or contribute to development:
84
+
85
+ ```bash
86
+ git clone https://github.com/othonhugo/praw-cli
87
+ cd praw-cli
88
+ uv sync
89
+ source .venv/bin/activate
90
+ ```
91
+
92
+ ## Documentation
93
+
94
+ Full documentation is available in the `docs/` directory.
95
+
96
+ - **[Getting Started](docs/getting-started.md)**: Installation and Authentication
97
+ - **Usage Guide**:
98
+ - [Commands](docs/usage/commands.md)
99
+ - [Pipelines & Filters](docs/usage/pipelines-filters.md)
100
+ - [Formatters & Outputs](docs/usage/outputs.md)
101
+ - [Scientific Use & Reproducibility](docs/usage/reproducibility.md)
102
+ - **Development**:
103
+ - [System Architecture](docs/development/architecture.md)
104
+ - [Decision Log (ADR)](docs/development/decisions.md)
105
+ - [Contributing Guide](docs/development/contributing.md)
@@ -0,0 +1,34 @@
1
+ prawler/__init__.py,sha256=owmIFA0vd2sD8dBFANuZbwABXBwvpj3O5oAvTEcFRMc,158
2
+ prawler/__main__.py,sha256=SYfzNEfMAT8TgmMXWbCgkIRxRZighyEbNfAdYHP-mPo,66
3
+ prawler/config.py,sha256=1JMoUAetz8fdmK6c42xZhpwWCnamr55gWQd2h65HREY,844
4
+ prawler/cli/__init__.py,sha256=jArxVms_ZS6eug_h3jSt4pbNpU0XFAAkZ_qbrA9Xrk8,40
5
+ prawler/cli/app.py,sha256=4IwyS30TkOg0oViMErMLzqXBa_xzKkSy5faB8Qep934,346
6
+ prawler/cli/options.py,sha256=0txgvzy8E1HALi6XCDKV047dd6NNTQZJLCORLTeyASg,2012
7
+ prawler/cli/commands/__init__.py,sha256=dGdARbvOMhKCBM-kLldFyRqqdWxhr9acaYry1ieXHiM,176
8
+ prawler/cli/commands/comments.py,sha256=P-PZ-yW6aHzosGuP4prQTqsTwH05JuMoCvig0ybN1is,1605
9
+ prawler/cli/commands/posts.py,sha256=p9_FVYJlEA5jm9rNTMbHL7jiOrqqHsRVyMU8h1rh64Y,1333
10
+ prawler/cli/commands/search.py,sha256=IAd4bTjMA4eDCds5dQB62mOjwigGKayWIJN2FNPqE2s,1362
11
+ prawler/cli/commands/user.py,sha256=STidxqcJInGKjpckHPzayiJmEigywkL21SxASywkLEo,1843
12
+ prawler/client/__init__.py,sha256=5-oDVQ7RTbnCLpKScTYtbg-0_lOHEJdaORIOhBxexZw,81
13
+ prawler/client/reddit_praw.py,sha256=3ZQyw-zm29X8XWb9T5ym-zbMMklfFnKcuxqzss-lgn4,1908
14
+ prawler/crawler/__init__.py,sha256=4sbAbfHZJfoRz19rtCfO-XU1fnukgNrqvHy-e4iqssQ,670
15
+ prawler/crawler/comment.py,sha256=4xEs2JNQ1DslLUd6yK2uLxhjB3dDjR5e_XLp18ZL1zg,1821
16
+ prawler/crawler/post.py,sha256=AjAX75AWgsiQob6393nnPqpbsnmeaXWY74KCTauKfm0,3463
17
+ prawler/crawler/redditor.py,sha256=Qru29mQVM6Ut19EI9fV5TJGF9mxXuI5LnawMMZa3Z4I,649
18
+ prawler/model/__init__.py,sha256=fW9OOdESvnf0hQ-qqo7Ck7tUc3PtzSPb6US3dTzTSYY,141
19
+ prawler/model/comment.py,sha256=QJXBjGJJhaRThRUoo1JztdiTlN0pjDe9iOPyPyuw0lw,1859
20
+ prawler/model/post.py,sha256=NsQ9-XtY5ZBeawEG6bYdB5SEDAtqVCo2xPeFjnM1MPw,1798
21
+ prawler/model/redditor.py,sha256=yqYXtCODrjlKvR1AWfG9hYe94CqdxezFM4Loy_znFFM,1525
22
+ prawler/output/__init__.py,sha256=DvaqvqnIQQj-HUJhIzAZKWKXmrJ-YskKDzZ6BxGTUDQ,295
23
+ prawler/output/base.py,sha256=Ghki6bGr-U0bz3iDTMNa9WpxpFHD19TgYx98Jo3DT48,336
24
+ prawler/output/formatters.py,sha256=vJD2lP6esYL7SvVxp2xvvQ40O-twRfcu7Zya9hqT5gQ,1941
25
+ prawler/output/registry.py,sha256=ClLBRFuJ72ih55RflBegs0LtiLN7huLZDJtDc9V2UQc,602
26
+ prawler/output/sinks.py,sha256=t3bhOrhg4IofEU13gtohfjSCns7CK9iEPvJ7bDoAedw,938
27
+ prawler/pipeline/__init__.py,sha256=cUW5_3e8BYXfjcNU2KfbDeBuFaDJ2rbPuuKajxE7LZM,250
28
+ prawler/pipeline/filters.py,sha256=rIj3hjHpAI0Z4rF3Gac4X7EBN4Nh8erIKBQSYbwMa4I,3650
29
+ prawler/pipeline/stage.py,sha256=2dHPDBqcEESBv2F8e7s0HjAaBTUR0aqzzzvZ3TG9hm4,391
30
+ prawler/pipeline/transforms.py,sha256=M71MVAHl-EASQ2fXKm2Llz1zL4iInSEV95_n0m7zS7E,425
31
+ praw_cli-0.6.0.dist-info/METADATA,sha256=v0GzbBymAHvDjtkeY9t65j7uWjRfk5lOye03a4jZfU0,5079
32
+ praw_cli-0.6.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
33
+ praw_cli-0.6.0.dist-info/entry_points.txt,sha256=X9Pzmn9IzPX_iyysKUVAgt_ZHAwhrTWIfFL_ICZPfeU,45
34
+ praw_cli-0.6.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ praw-cli = prawler.cli:app
prawler/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from . import cli, client, crawler, model, output, pipeline
2
+
3
+ __all__ = [
4
+ "cli",
5
+ "client",
6
+ "crawler",
7
+ "model",
8
+ "output",
9
+ "pipeline",
10
+ ]
prawler/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from prawler.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,3 @@
1
+ from .app import app
2
+
3
+ __all__ = ["app"]
prawler/cli/app.py ADDED
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from prawler.cli.commands import comments, posts, search, user
6
+
7
+ app = typer.Typer(
8
+ name="prawler",
9
+ help="Composable Reddit crawler. Fetch posts, comments, and user data.",
10
+ no_args_is_help=True,
11
+ )
12
+
13
+ app.command()(posts)
14
+ app.command()(comments)
15
+ app.command()(search)
16
+ app.command()(user)
@@ -0,0 +1,11 @@
1
+ from .comments import comments
2
+ from .posts import posts
3
+ from .search import search
4
+ from .user import user
5
+
6
+ __all__ = [
7
+ "comments",
8
+ "posts",
9
+ "search",
10
+ "user",
11
+ ]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from prawler.cli.options import FieldsOption, FilterOption, FormatOption, LimitOption, OutputOption, SortOption, SourceArg
4
+ from prawler.client import RedditPrawClient
5
+ from prawler.config import get_config
6
+ from prawler.crawler import CommentCrawler, SubmissionCommentConfig, UserCommentConfig
7
+ from prawler.output import get_formatter, make_sink
8
+ from prawler.pipeline import build_pipeline, make_field_select_stage, make_filter_stage
9
+
10
+
11
+ def comments(
12
+ source: SourceArg,
13
+ sort: SortOption = "new",
14
+ limit: LimitOption = 100,
15
+ format: FormatOption = "jsonl",
16
+ output: OutputOption = "-",
17
+ fields: FieldsOption = None,
18
+ filter: FilterOption = None,
19
+ ) -> None:
20
+ """Crawl comments from a submission (URL or ID) or a user (u/username)."""
21
+
22
+ cfg = get_config()
23
+ client = RedditPrawClient.from_config(cfg)
24
+ crawler = CommentCrawler(client)
25
+
26
+ if source.startswith("u/"):
27
+ username = source.removeprefix("u/")
28
+ stream = crawler.from_user(UserCommentConfig(username, sort, limit))
29
+ elif source.startswith("http"):
30
+ stream = crawler.from_submission(SubmissionCommentConfig(submission_url=source, limit=limit))
31
+ else:
32
+ stream = crawler.from_submission(SubmissionCommentConfig(submission_id=source, limit=limit))
33
+
34
+ records = (comment.to_dict() for comment in stream)
35
+
36
+ pipeline = build_pipeline(
37
+ *[make_filter_stage(f) for f in (filter or [])],
38
+ make_field_select_stage(fields.split(",") if fields else None),
39
+ )
40
+
41
+ make_sink(output).write(get_formatter(format).format(pipeline(records)))
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from prawler.cli.options import FieldsOption, FilterOption, FormatOption, LimitOption, OutputOption, SubredditArg, SubredditSortOption, TimeFilterOption
4
+ from prawler.client import RedditPrawClient
5
+ from prawler.config import get_config
6
+ from prawler.crawler import PostCrawler, SubredditCrawlConfig, SubredditSort, TimeFilter
7
+ from prawler.output import get_formatter, make_sink
8
+ from prawler.pipeline import build_pipeline, make_field_select_stage, make_filter_stage
9
+
10
+
11
+ def posts(
12
+ subreddit: SubredditArg,
13
+ sort: SubredditSortOption = SubredditSort.HOT,
14
+ time_filter: TimeFilterOption = TimeFilter.ALL,
15
+ limit: LimitOption = 100,
16
+ format: FormatOption = "jsonl",
17
+ output: OutputOption = "-",
18
+ fields: FieldsOption = None,
19
+ filter: FilterOption = None,
20
+ ) -> None:
21
+ """Crawl posts from a subreddit."""
22
+
23
+ cfg = get_config()
24
+ client = RedditPrawClient.from_config(cfg)
25
+ stream = PostCrawler(client).from_subreddit(SubredditCrawlConfig(subreddit, sort, time_filter, limit))
26
+ records = (post.to_dict() for post in stream)
27
+
28
+ pipeline = build_pipeline(
29
+ *[make_filter_stage(f) for f in (filter or [])],
30
+ make_field_select_stage(fields.split(",") if fields else None),
31
+ )
32
+
33
+ make_sink(output).write(get_formatter(format).format(pipeline(records)))
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from prawler.cli.options import FieldsOption, FilterOption, FormatOption, LimitOption, OutputOption, QueryArg, SearchSortOption, SubredditOption, TimeFilterOption
4
+ from prawler.client import RedditPrawClient
5
+ from prawler.config import get_config
6
+ from prawler.crawler import PostCrawler, SearchCrawlConfig, SearchSort, TimeFilter
7
+ from prawler.output import get_formatter, make_sink
8
+ from prawler.pipeline import build_pipeline, make_field_select_stage, make_filter_stage
9
+
10
+
11
+ def search(
12
+ query: QueryArg,
13
+ subreddit: SubredditOption = "all",
14
+ sort: SearchSortOption = SearchSort.RELEVANCE,
15
+ time_filter: TimeFilterOption = TimeFilter.ALL,
16
+ limit: LimitOption = 100,
17
+ format: FormatOption = "jsonl",
18
+ output: OutputOption = "-",
19
+ fields: FieldsOption = None,
20
+ filter: FilterOption = None,
21
+ ) -> None:
22
+ """Search Reddit posts."""
23
+
24
+ cfg = get_config()
25
+ client = RedditPrawClient.from_config(cfg)
26
+ stream = PostCrawler(client).from_search(SearchCrawlConfig(query, subreddit, sort, time_filter, limit))
27
+ records = (post.to_dict() for post in stream)
28
+
29
+ pipeline = build_pipeline(
30
+ *[make_filter_stage(f) for f in (filter or [])],
31
+ make_field_select_stage(fields.split(",") if fields else None),
32
+ )
33
+
34
+ make_sink(output).write(get_formatter(format).format(pipeline(records)))
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from prawler.cli.options import FieldsOption, FilterOption, FormatOption, LimitOption, OutputOption, SortOption, UserMode, UserModeOption, UsernameArg
4
+ from prawler.client import RedditPrawClient
5
+ from prawler.config import get_config
6
+ from prawler.crawler import CommentCrawler, PostCrawler, RedditorCrawler, RedditorProfileConfig, SubredditSort, UserCommentConfig, UserCrawlConfig
7
+ from prawler.output import get_formatter, make_sink
8
+ from prawler.pipeline import build_pipeline, make_field_select_stage, make_filter_stage
9
+
10
+
11
+ def user(
12
+ username: UsernameArg,
13
+ mode: UserModeOption = UserMode.POSTS,
14
+ sort: SortOption = "new",
15
+ limit: LimitOption = 100,
16
+ format: FormatOption = "jsonl",
17
+ output: OutputOption = "-",
18
+ fields: FieldsOption = None,
19
+ filter: FilterOption = None,
20
+ ) -> None:
21
+ """Crawl a Redditor's posts, comments, or profile."""
22
+
23
+ cfg = get_config()
24
+ client = RedditPrawClient.from_config(cfg)
25
+
26
+ match mode:
27
+ case UserMode.POSTS:
28
+ post_stream = PostCrawler(client).from_user(UserCrawlConfig(username, SubredditSort(sort), limit))
29
+ records = (item.to_dict() for item in post_stream)
30
+
31
+ case UserMode.COMMENTS:
32
+ comment_stream = CommentCrawler(client).from_user(UserCommentConfig(username, sort, limit))
33
+ records = (item.to_dict() for item in comment_stream)
34
+
35
+ case UserMode.PROFILE:
36
+ redditor_stream = RedditorCrawler(client).from_usernames(RedditorProfileConfig([username]))
37
+ records = (item.to_dict() for item in redditor_stream)
38
+
39
+ pipeline = build_pipeline(
40
+ *[make_filter_stage(f) for f in (filter or [])],
41
+ make_field_select_stage(fields.split(",") if fields else None),
42
+ )
43
+
44
+ make_sink(output).write(get_formatter(format).format(pipeline(records)))
prawler/cli/options.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from prawler.crawler import SearchSort, SubredditSort, TimeFilter
9
+
10
+ # Arguments
11
+
12
+ SubredditArg = Annotated[
13
+ str,
14
+ typer.Argument(help="Subreddit name (without r/)."),
15
+ ]
16
+
17
+ QueryArg = Annotated[
18
+ str,
19
+ typer.Argument(help="Search query string."),
20
+ ]
21
+
22
+ UsernameArg = Annotated[
23
+ str,
24
+ typer.Argument(help="Reddit username (without u/)."),
25
+ ]
26
+
27
+ SourceArg = Annotated[
28
+ str,
29
+ typer.Argument(help="Submission URL/ID or username (prefix with u/ for user)."),
30
+ ]
31
+
32
+ # Shared options
33
+
34
+ FormatOption = Annotated[
35
+ str,
36
+ typer.Option("--format", "-f", help="Output format: json | jsonl | csv | table | markdown."),
37
+ ]
38
+
39
+ OutputOption = Annotated[
40
+ str,
41
+ typer.Option("--output", "-o", help="Output destination. Use '-' for stdout."),
42
+ ]
43
+
44
+ LimitOption = Annotated[
45
+ int | None,
46
+ typer.Option("--limit", "-n", help="Maximum number of items to fetch."),
47
+ ]
48
+
49
+ FieldsOption = Annotated[
50
+ str | None,
51
+ typer.Option("--fields", help="Comma-separated list of fields to include."),
52
+ ]
53
+
54
+ FilterOption = Annotated[
55
+ list[str] | None,
56
+ typer.Option("--filter", "-F", help="Filter expression (repeatable)."),
57
+ ]
58
+
59
+ TimeFilterOption = Annotated[
60
+ TimeFilter,
61
+ typer.Option("--time", "-t", help="Time window (top/controversial only)."),
62
+ ]
63
+
64
+ SortOption = Annotated[
65
+ str,
66
+ typer.Option("--sort", "-s", help="Sort order."),
67
+ ]
68
+
69
+ # Command-specific options
70
+
71
+ SubredditSortOption = Annotated[
72
+ SubredditSort,
73
+ typer.Option("--sort", "-s", help="Sort order."),
74
+ ]
75
+
76
+ SearchSortOption = Annotated[
77
+ SearchSort,
78
+ typer.Option("--sort", "-s", help="Sort order."),
79
+ ]
80
+
81
+ SubredditOption = Annotated[
82
+ str,
83
+ typer.Option("--sub", help="Subreddit to search within."),
84
+ ]
85
+
86
+
87
+ class UserMode(StrEnum):
88
+ POSTS = "posts"
89
+ COMMENTS = "comments"
90
+ PROFILE = "profile"
91
+
92
+
93
+ UserModeOption = Annotated[
94
+ UserMode,
95
+ typer.Option("--mode", help="What to fetch: posts | comments | profile."),
96
+ ]
@@ -0,0 +1,5 @@
1
+ from .reddit_praw import RedditPrawClient
2
+
3
+ __all__ = [
4
+ "RedditPrawClient",
5
+ ]
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import praw
4
+ import praw.models
5
+ from tenacity import retry, stop_after_attempt, wait_exponential
6
+
7
+ from prawler.config import Config
8
+
9
+
10
+ class RedditPrawClient:
11
+ def __init__(
12
+ self,
13
+ client_id: str,
14
+ client_secret: str,
15
+ user_agent: str,
16
+ username: str | None = None,
17
+ password: str | None = None,
18
+ ) -> None:
19
+ if username and password:
20
+ self._reddit = praw.Reddit(
21
+ client_id=client_id,
22
+ client_secret=client_secret,
23
+ user_agent=user_agent,
24
+ username=username,
25
+ password=password,
26
+ )
27
+ self._reddit.read_only = False
28
+ else:
29
+ self._reddit = praw.Reddit(
30
+ client_id=client_id,
31
+ client_secret=client_secret,
32
+ user_agent=user_agent,
33
+ )
34
+ self._reddit.read_only = True
35
+
36
+ def subreddit(self, name: str) -> praw.models.Subreddit:
37
+ return self._reddit.subreddit(name)
38
+
39
+ def redditor(self, name: str) -> praw.models.Redditor:
40
+ return self._reddit.redditor(name)
41
+
42
+ @retry(
43
+ stop=stop_after_attempt(3),
44
+ wait=wait_exponential(multiplier=1, min=2, max=30),
45
+ reraise=True,
46
+ )
47
+ def submission(self, *, url: str | None = None, id: str | None = None) -> praw.models.Submission:
48
+ if url:
49
+ return self._reddit.submission(url=url)
50
+
51
+ if id:
52
+ return self._reddit.submission(id=id)
53
+
54
+ raise ValueError("provide url or id")
55
+
56
+ @classmethod
57
+ def from_config(cls, cfg: Config) -> "RedditPrawClient":
58
+ return cls(
59
+ client_id=cfg.reddit_client_id,
60
+ client_secret=cfg.reddit_client_secret,
61
+ user_agent=cfg.reddit_user_agent,
62
+ username=cfg.reddit_username,
63
+ password=cfg.reddit_password,
64
+ )
prawler/config.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ class Config:
7
+ reddit_client_id: str
8
+ reddit_client_secret: str
9
+ reddit_user_agent: str
10
+ reddit_username: str | None
11
+ reddit_password: str | None
12
+
13
+ def __init__(self) -> None:
14
+ self.reddit_client_id = self._require("PRAWLER_CLIENT_ID")
15
+ self.reddit_client_secret = self._require("PRAWLER_CLIENT_SECRET")
16
+ self.reddit_user_agent = os.getenv("PRAWLER_USER_AGENT", "prawler/0.2")
17
+ self.reddit_username = os.getenv("PRAWLER_USERNAME")
18
+ self.reddit_password = os.getenv("PRAWLER_PASSWORD")
19
+
20
+ @staticmethod
21
+ def _require(key: str) -> str:
22
+ value = os.getenv(key)
23
+
24
+ if not value:
25
+ raise SystemExit(f"Missing required environment variable: {key}")
26
+
27
+ return value
28
+
29
+
30
+ def get_config() -> Config:
31
+ return Config()
@@ -0,0 +1,35 @@
1
+ from .comment import (
2
+ CommentCrawler,
3
+ SubmissionCommentConfig,
4
+ UserCommentConfig,
5
+ )
6
+ from .post import (
7
+ PostCrawler,
8
+ SearchCrawlConfig,
9
+ SearchSort,
10
+ SubredditCrawlConfig,
11
+ SubredditSort,
12
+ TimeFilter,
13
+ UrlCrawlConfig,
14
+ UserCrawlConfig,
15
+ )
16
+ from .redditor import (
17
+ RedditorCrawler,
18
+ RedditorProfileConfig,
19
+ )
20
+
21
+ __all__ = [
22
+ "CommentCrawler",
23
+ "PostCrawler",
24
+ "RedditorCrawler",
25
+ "RedditorProfileConfig",
26
+ "SearchCrawlConfig",
27
+ "SearchSort",
28
+ "SubmissionCommentConfig",
29
+ "SubredditCrawlConfig",
30
+ "SubredditSort",
31
+ "TimeFilter",
32
+ "UrlCrawlConfig",
33
+ "UserCommentConfig",
34
+ "UserCrawlConfig",
35
+ ]
@@ -0,0 +1,58 @@
1
+ from dataclasses import dataclass
2
+ from typing import Iterable, Iterator
3
+
4
+ import praw.models
5
+
6
+ from prawler.client import RedditPrawClient
7
+ from prawler.model import Comment
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SubmissionCommentConfig:
12
+ submission_id: str | None = None
13
+ submission_url: str | None = None
14
+ limit: int | None = None
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class UserCommentConfig:
19
+ username: str
20
+ sort: str = "new"
21
+ limit: int | None = 100
22
+
23
+
24
+ class CommentCrawler:
25
+ def __init__(self, client: RedditPrawClient) -> None:
26
+ self._client = client
27
+
28
+ def from_submission(self, cfg: SubmissionCommentConfig) -> Iterator[Comment]:
29
+ submission = self._client.submission(
30
+ url=cfg.submission_url,
31
+ id=cfg.submission_id,
32
+ )
33
+
34
+ submission.comments.replace_more(limit=cfg.limit)
35
+
36
+ yield from self._map(item for item in submission.comments.list() if isinstance(item, praw.models.Comment))
37
+
38
+ def from_user(self, cfg: UserCommentConfig) -> Iterator[Comment]:
39
+ redditor = self._client.redditor(cfg.username)
40
+
41
+ match cfg.sort:
42
+ case "hot":
43
+ comments = redditor.comments.hot(limit=cfg.limit)
44
+ case "top":
45
+ comments = redditor.comments.top(limit=cfg.limit)
46
+ case "controversial":
47
+ comments = redditor.comments.controversial(limit=cfg.limit)
48
+ case _:
49
+ comments = redditor.comments.new(limit=cfg.limit)
50
+
51
+ yield from self._map(item for item in comments if isinstance(item, praw.models.Comment))
52
+
53
+ def _map(self, comments: Iterable[praw.models.Comment]) -> Iterator[Comment]:
54
+ for comment in comments:
55
+ try:
56
+ yield Comment.from_praw(comment)
57
+ except Exception: # noqa: BLE001
58
+ pass