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.
- praw_cli-0.6.0.dist-info/METADATA +105 -0
- praw_cli-0.6.0.dist-info/RECORD +34 -0
- praw_cli-0.6.0.dist-info/WHEEL +4 -0
- praw_cli-0.6.0.dist-info/entry_points.txt +2 -0
- prawler/__init__.py +10 -0
- prawler/__main__.py +4 -0
- prawler/cli/__init__.py +3 -0
- prawler/cli/app.py +16 -0
- prawler/cli/commands/__init__.py +11 -0
- prawler/cli/commands/comments.py +41 -0
- prawler/cli/commands/posts.py +33 -0
- prawler/cli/commands/search.py +34 -0
- prawler/cli/commands/user.py +44 -0
- prawler/cli/options.py +96 -0
- prawler/client/__init__.py +5 -0
- prawler/client/reddit_praw.py +64 -0
- prawler/config.py +31 -0
- prawler/crawler/__init__.py +35 -0
- prawler/crawler/comment.py +58 -0
- prawler/crawler/post.py +125 -0
- prawler/crawler/redditor.py +23 -0
- prawler/model/__init__.py +9 -0
- prawler/model/comment.py +71 -0
- prawler/model/post.py +71 -0
- prawler/model/redditor.py +57 -0
- prawler/output/__init__.py +14 -0
- prawler/output/base.py +15 -0
- prawler/output/formatters.py +79 -0
- prawler/output/registry.py +22 -0
- prawler/output/sinks.py +41 -0
- prawler/pipeline/__init__.py +10 -0
- prawler/pipeline/filters.py +133 -0
- prawler/pipeline/stage.py +14 -0
- prawler/pipeline/transforms.py +15 -0
|
@@ -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
|
+
[](https://www.python.org/downloads/)
|
|
26
|
+
[](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,,
|
prawler/__init__.py
ADDED
prawler/__main__.py
ADDED
prawler/cli/__init__.py
ADDED
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,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,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
|