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,125 @@
1
+ from dataclasses import dataclass
2
+ from enum import StrEnum
3
+ from typing import Iterator
4
+
5
+ from praw.models import Submission
6
+
7
+ from prawler.client import RedditPrawClient
8
+ from prawler.model import Post
9
+
10
+
11
+ class SubredditSort(StrEnum):
12
+ HOT = "hot"
13
+ NEW = "new"
14
+ TOP = "top"
15
+ RISING = "rising"
16
+ CONTROVERTIAL = "controversial"
17
+
18
+
19
+ class SearchSort(StrEnum):
20
+ RELEVANCE = "relevance"
21
+ HOT = "hot"
22
+ TOP = "top"
23
+ NEW = "new"
24
+ COMMENTS = "comments"
25
+
26
+
27
+ class TimeFilter(StrEnum):
28
+ HOUR = "hour"
29
+ DAY = "day"
30
+ WEEK = "week"
31
+ MONTH = "month"
32
+ YEAR = "year"
33
+ ALL = "all"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SubredditCrawlConfig:
38
+ subreddit: str
39
+ sort: SubredditSort = SubredditSort.HOT
40
+ time_filter: TimeFilter = TimeFilter.ALL
41
+ limit: int | None = 100
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SearchCrawlConfig:
46
+ query: str
47
+ subreddit: str = "all"
48
+ sort: SearchSort = SearchSort.RELEVANCE
49
+ time_filter: TimeFilter = TimeFilter.ALL
50
+ limit: int | None = 100
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class UserCrawlConfig:
55
+ username: str
56
+ sort: SubredditSort = SubredditSort.NEW
57
+ limit: int | None = 100
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class UrlCrawlConfig:
62
+ url: str
63
+
64
+
65
+ class PostCrawler:
66
+ def __init__(self, client: RedditPrawClient) -> None:
67
+ self._client = client
68
+
69
+ def from_subreddit(self, cfg: SubredditCrawlConfig) -> Iterator[Post]:
70
+ sub = self._client.subreddit(cfg.subreddit)
71
+
72
+ match cfg.sort:
73
+ case SubredditSort.HOT:
74
+ submissions = sub.hot(limit=cfg.limit)
75
+ case SubredditSort.NEW:
76
+ submissions = sub.new(limit=cfg.limit)
77
+ case SubredditSort.TOP:
78
+ submissions = sub.top(limit=cfg.limit, time_filter=cfg.time_filter.value)
79
+ case SubredditSort.RISING:
80
+ submissions = sub.rising(limit=cfg.limit)
81
+ case SubredditSort.CONTROVERTIAL:
82
+ submissions = sub.controversial(limit=cfg.limit, time_filter=cfg.time_filter.value)
83
+
84
+ yield from self._map(submissions)
85
+
86
+ def from_search(self, cfg: SearchCrawlConfig) -> Iterator[Post]:
87
+ sub = self._client.subreddit(cfg.subreddit)
88
+
89
+ results = sub.search(
90
+ query=cfg.query,
91
+ sort=cfg.sort.value,
92
+ time_filter=cfg.time_filter.value,
93
+ limit=cfg.limit,
94
+ )
95
+
96
+ yield from self._map(results)
97
+
98
+ def from_user(self, cfg: UserCrawlConfig) -> Iterator[Post]:
99
+ redditor = self._client.redditor(cfg.username)
100
+
101
+ match cfg.sort:
102
+ case SubredditSort.HOT:
103
+ submissions = redditor.hot()
104
+ case SubredditSort.NEW:
105
+ submissions = redditor.new()
106
+ case SubredditSort.TOP:
107
+ submissions = redditor.top()
108
+ case SubredditSort.RISING:
109
+ submissions = redditor.hot()
110
+ case SubredditSort.CONTROVERTIAL:
111
+ submissions = redditor.hot()
112
+
113
+ yield from self._map(item for item in submissions if isinstance(item, Submission))
114
+
115
+ def from_url(self, cfg: UrlCrawlConfig) -> Iterator[Post]:
116
+ submission = self._client.submission(url=cfg.url)
117
+
118
+ yield Post.from_submission(submission)
119
+
120
+ def _map(self, submissions: Iterator[Submission]) -> Iterator[Post]:
121
+ for sub in submissions:
122
+ try:
123
+ yield Post.from_submission(sub)
124
+ except Exception: # noqa: BLE001
125
+ pass
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+ from typing import Iterator
3
+
4
+ from prawler.client import RedditPrawClient
5
+ from prawler.model import Redditor
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class RedditorProfileConfig:
10
+ usernames: list[str]
11
+
12
+
13
+ class RedditorCrawler:
14
+ def __init__(self, client: RedditPrawClient) -> None:
15
+ self._client = client
16
+
17
+ def from_usernames(self, cfg: RedditorProfileConfig) -> Iterator[Redditor]:
18
+ for username in cfg.usernames:
19
+ try:
20
+ redditor = self._client.redditor(username)
21
+ yield Redditor.from_praw(redditor)
22
+ except Exception: # noqa: BLE001
23
+ pass
@@ -0,0 +1,9 @@
1
+ from .comment import Comment
2
+ from .post import Post
3
+ from .redditor import Redditor
4
+
5
+ __all__ = [
6
+ "Comment",
7
+ "Post",
8
+ "Redditor",
9
+ ]
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime, timezone
5
+
6
+ from praw.models import Comment as PrawComment
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Comment:
11
+ # Identity
12
+ id: str
13
+ permalink: str
14
+
15
+ # Relationships
16
+ submission_id: str
17
+ submission_title: str
18
+ subreddit: str
19
+ author: str | None
20
+ parent_id: str
21
+
22
+ # Content
23
+ body: str
24
+ depth: int
25
+
26
+ # Engagement
27
+ score: int
28
+
29
+ # Metadata
30
+ created_utc: datetime
31
+ is_submitter: bool
32
+ stickied: bool
33
+ locked: bool
34
+ nsfw: bool
35
+ edited: bool
36
+
37
+ def to_dict(self, fields: list[str] | None = None) -> dict[str, object]:
38
+ data = asdict(self)
39
+ data["created_utc"] = self.created_utc.isoformat()
40
+
41
+ if fields:
42
+ data = {k: data[k] for k in fields if k in data}
43
+
44
+ return data
45
+
46
+ @classmethod
47
+ def from_praw(cls, comment: PrawComment) -> "Comment":
48
+ return cls(
49
+ id=comment.id,
50
+ permalink=comment.permalink,
51
+ submission_id=comment.submission.id,
52
+ submission_title=comment.submission.title,
53
+ subreddit=comment.subreddit.display_name,
54
+ author=comment.author.name if comment.author else None,
55
+ parent_id=comment.parent_id,
56
+ body=comment.body or "",
57
+ depth=comment.depth,
58
+ score=comment.score,
59
+ created_utc=datetime.fromtimestamp(comment.created_utc, tz=timezone.utc),
60
+ is_submitter=comment.is_submitter,
61
+ stickied=comment.stickied,
62
+ locked=comment.locked,
63
+ nsfw=comment.submission.over_18,
64
+ edited=bool(comment.edited),
65
+ )
66
+
67
+ @property
68
+ def age_days(self) -> float:
69
+ delta = datetime.now(tz=timezone.utc) - self.created_utc
70
+
71
+ return delta.total_seconds() / 86_400
prawler/model/post.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime, timezone
5
+
6
+ from praw.models import Submission
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Post:
11
+ # Identity
12
+ id: str
13
+ title: str
14
+ url: str
15
+ permalink: str
16
+
17
+ # Engagement
18
+ score: int
19
+ upvote_ratio: float
20
+ num_comments: int
21
+
22
+ # Metadata
23
+ created_utc: datetime
24
+ subreddit: str
25
+ author: str | None
26
+ selftext: str
27
+ is_self: bool
28
+ flair: str | None
29
+ nsfw: bool
30
+ spoiler: bool
31
+ locked: bool
32
+ stickied: bool
33
+ domain: str
34
+
35
+ def to_dict(self, fields: list[str] | None = None) -> dict[str, object]:
36
+ data = asdict(self)
37
+ data["created_utc"] = self.created_utc.isoformat()
38
+
39
+ if fields:
40
+ data = {k: data[k] for k in fields if k in data}
41
+
42
+ return data
43
+
44
+ @classmethod
45
+ def from_submission(cls, sub: Submission) -> "Post":
46
+ return cls(
47
+ id=sub.id,
48
+ title=sub.title,
49
+ url=sub.url,
50
+ permalink=sub.permalink,
51
+ score=sub.score,
52
+ upvote_ratio=sub.upvote_ratio,
53
+ num_comments=sub.num_comments,
54
+ created_utc=datetime.fromtimestamp(sub.created_utc, tz=timezone.utc),
55
+ subreddit=sub.subreddit.display_name,
56
+ author=sub.author.name if sub.author else None,
57
+ selftext=sub.selftext or "",
58
+ is_self=sub.is_self,
59
+ flair=sub.link_flair_text,
60
+ nsfw=sub.over_18,
61
+ spoiler=sub.spoiler,
62
+ locked=sub.locked,
63
+ stickied=sub.stickied,
64
+ domain=sub.domain,
65
+ )
66
+
67
+ @property
68
+ def age_days(self) -> float:
69
+ delta = datetime.now(tz=timezone.utc) - self.created_utc
70
+
71
+ return delta.total_seconds() / 86_400
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime, timezone
5
+
6
+ from praw.models import Redditor as PrawRedditor
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Redditor:
11
+ # Identity
12
+ name: str
13
+ id: str
14
+
15
+ # Karma
16
+ link_karma: int
17
+ comment_karma: int
18
+
19
+ # Metadata
20
+ created_utc: datetime
21
+ is_mod: bool
22
+ is_gold: bool
23
+ has_verified_email: bool | None
24
+ icon_img: str
25
+
26
+ def to_dict(self, fields: list[str] | None = None) -> dict[str, object]:
27
+ data = asdict(self)
28
+ data["created_utc"] = self.created_utc.isoformat()
29
+
30
+ if fields:
31
+ data = {k: data[k] for k in fields if k in data}
32
+
33
+ return data
34
+
35
+ @classmethod
36
+ def from_praw(cls, redditor: PrawRedditor) -> "Redditor":
37
+ return cls(
38
+ name=redditor.name,
39
+ id=redditor.id,
40
+ link_karma=redditor.link_karma,
41
+ comment_karma=redditor.comment_karma,
42
+ created_utc=datetime.fromtimestamp(redditor.created_utc, tz=timezone.utc),
43
+ is_mod=redditor.is_mod,
44
+ is_gold=redditor.is_gold,
45
+ has_verified_email=getattr(redditor, "has_verified_email", None),
46
+ icon_img=getattr(redditor, "icon_img", ""),
47
+ )
48
+
49
+ @property
50
+ def total_karma(self) -> int:
51
+ return self.link_karma + self.comment_karma
52
+
53
+ @property
54
+ def age_days(self) -> float:
55
+ delta = datetime.now(tz=timezone.utc) - self.created_utc
56
+
57
+ return delta.total_seconds() / 86_400
@@ -0,0 +1,14 @@
1
+ from .base import Formatter, Sink
2
+ from .registry import FORMATTERS, get_formatter
3
+ from .sinks import FileSink, MultiSink, StdoutSink, make_sink
4
+
5
+ __all__ = [
6
+ "FORMATTERS",
7
+ "FileSink",
8
+ "Formatter",
9
+ "MultiSink",
10
+ "Sink",
11
+ "StdoutSink",
12
+ "get_formatter",
13
+ "make_sink",
14
+ ]
prawler/output/base.py ADDED
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterator, Protocol, runtime_checkable
4
+
5
+ Record = dict[str, object]
6
+
7
+
8
+ @runtime_checkable
9
+ class Formatter(Protocol):
10
+ def format(self, items: Iterator[Record]) -> Iterator[str]: ...
11
+
12
+
13
+ @runtime_checkable
14
+ class Sink(Protocol):
15
+ def write(self, chunks: Iterator[str]) -> None: ...
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from typing import Iterator
7
+
8
+ from prawler.output.base import Record
9
+
10
+
11
+ class JsonFormatter:
12
+ def format(self, items: Iterator[Record]) -> Iterator[str]:
13
+ yield json.dumps(list(items), ensure_ascii=False, indent=2) + "\n"
14
+
15
+
16
+ class JsonLFormatter:
17
+ def format(self, items: Iterator[Record]) -> Iterator[str]:
18
+ for item in items:
19
+ yield json.dumps(item, ensure_ascii=False) + "\n"
20
+
21
+
22
+ class CsvFormatter:
23
+ def format(self, items: Iterator[Record]) -> Iterator[str]:
24
+ rows = list(items)
25
+
26
+ if not rows:
27
+ return
28
+
29
+ buf = io.StringIO()
30
+ writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
31
+ writer.writeheader()
32
+ writer.writerows(rows)
33
+
34
+ yield buf.getvalue()
35
+
36
+
37
+ class MarkdownFormatter:
38
+ def format(self, items: Iterator[Record]) -> Iterator[str]:
39
+ rows = list(items)
40
+
41
+ if not rows:
42
+ return
43
+
44
+ headers = list(rows[0].keys())
45
+ sep = " | ".join("---" for _ in headers)
46
+
47
+ yield "| " + " | ".join(headers) + " |\n"
48
+ yield "| " + sep + " |\n"
49
+
50
+ for row in rows:
51
+ cells = " | ".join(str(row.get(h, "")) for h in headers)
52
+ yield "| " + cells + " |\n"
53
+
54
+
55
+ class TableFormatter:
56
+ def format(self, items: Iterator[Record]) -> Iterator[str]:
57
+ try:
58
+ from rich.console import Console
59
+ from rich.table import Table
60
+ except ImportError:
61
+ raise SystemExit("Install rich for table output: uv add rich")
62
+
63
+ rows = list(items)
64
+
65
+ if not rows:
66
+ return
67
+
68
+ table = Table()
69
+
70
+ for col in rows[0].keys():
71
+ table.add_column(col)
72
+
73
+ for row in rows:
74
+ table.add_row(*[str(v) for v in row.values()])
75
+
76
+ buf = io.StringIO()
77
+ Console(file=buf, highlight=False).print(table)
78
+
79
+ yield buf.getvalue()
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from prawler.output.base import Formatter
4
+ from .formatters import CsvFormatter, JsonFormatter, JsonLFormatter, MarkdownFormatter, TableFormatter
5
+
6
+ FORMATTERS: dict[str, type[Formatter]] = {
7
+ "json": JsonFormatter,
8
+ "jsonl": JsonLFormatter,
9
+ "csv": CsvFormatter,
10
+ "table": TableFormatter,
11
+ "markdown": MarkdownFormatter,
12
+ }
13
+
14
+
15
+ def get_formatter(name: str) -> Formatter:
16
+ cls = FORMATTERS.get(name)
17
+
18
+ if cls is None:
19
+ valid = ", ".join(FORMATTERS)
20
+ raise SystemExit(f"Unknown format '{name}'. Valid options: {valid}")
21
+
22
+ return cls()
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Iterator
6
+
7
+ from prawler.output.base import Sink
8
+
9
+
10
+ class StdoutSink:
11
+ def write(self, chunks: Iterator[str]) -> None:
12
+ for chunk in chunks:
13
+ sys.stdout.write(chunk)
14
+
15
+
16
+ class FileSink:
17
+ def __init__(self, path: Path) -> None:
18
+ self._path = path
19
+
20
+ def write(self, chunks: Iterator[str]) -> None:
21
+ with self._path.open("w", encoding="utf-8") as fh:
22
+ for chunk in chunks:
23
+ fh.write(chunk)
24
+
25
+
26
+ class MultiSink:
27
+ def __init__(self, sinks: list[Sink]) -> None:
28
+ self._sinks = sinks
29
+
30
+ def write(self, chunks: Iterator[str]) -> None:
31
+ collected = list(chunks)
32
+
33
+ for sink in self._sinks:
34
+ sink.write(iter(collected))
35
+
36
+
37
+ def make_sink(output: str) -> StdoutSink | FileSink:
38
+ if output == "-":
39
+ return StdoutSink()
40
+
41
+ return FileSink(Path(output))
@@ -0,0 +1,10 @@
1
+ from .filters import make_filter_stage
2
+ from .stage import PipelineStage, build_pipeline
3
+ from .transforms import make_field_select_stage
4
+
5
+ __all__ = [
6
+ "PipelineStage",
7
+ "build_pipeline",
8
+ "make_field_select_stage",
9
+ "make_filter_stage",
10
+ ]
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable, Iterator
5
+
6
+ from prawler.pipeline.stage import PipelineStage, Record
7
+
8
+ OPERATORS = [
9
+ " starts_with ",
10
+ " ends_with ",
11
+ " has_all ",
12
+ " len>= ",
13
+ " len<= ",
14
+ " len> ",
15
+ " len< ",
16
+ " len= ",
17
+ " has ",
18
+ " in ",
19
+ "~=",
20
+ "!=",
21
+ ">=",
22
+ "<=",
23
+ ">",
24
+ "<",
25
+ "=",
26
+ ]
27
+
28
+ Predicate = Callable[[Record], bool]
29
+
30
+
31
+ def _parse(expr: str) -> tuple[str, str, str]:
32
+ for op in OPERATORS:
33
+ idx = expr.find(op)
34
+
35
+ if idx != -1:
36
+ field = expr[:idx].strip()
37
+ value = expr[idx + len(op) :].strip()
38
+
39
+ return field, op.strip(), value
40
+
41
+ raise ValueError(f"Invalid filter expression: {expr}")
42
+
43
+
44
+ def _coerce(value: str) -> bool | int | float | str:
45
+ if value.lower() in ("true", "false"):
46
+ return value.lower() == "true"
47
+
48
+ try:
49
+ return int(value)
50
+ except ValueError:
51
+ pass
52
+
53
+ try:
54
+ return float(value)
55
+ except ValueError:
56
+ pass
57
+
58
+ return value
59
+
60
+
61
+ def _make_predicate(field: str, op: str, raw_value: str) -> Predicate:
62
+ match op:
63
+ case "=":
64
+ target = _coerce(raw_value)
65
+ return lambda r: _coerce(str(r.get(field, ""))) == target
66
+
67
+ case "!=":
68
+ target = _coerce(raw_value)
69
+ return lambda r: _coerce(str(r.get(field, ""))) != target
70
+
71
+ case ">=" | "<=" | ">" | "<":
72
+ target = _coerce(raw_value)
73
+
74
+ ops_float = {">=": float.__ge__, "<=": float.__le__, ">": float.__gt__, "<": float.__lt__}
75
+ ops_str = {">=": str.__ge__, "<=": str.__le__, ">": str.__gt__, "<": str.__lt__}
76
+
77
+ def _cmp(val: object, tgt: object) -> bool:
78
+ if isinstance(tgt, (int, float)):
79
+ try:
80
+ return ops_float[op](float(str(val)), float(tgt))
81
+ except (ValueError, TypeError):
82
+ pass
83
+
84
+ return ops_str[op](str(val), str(tgt))
85
+
86
+ return lambda r: _cmp(r.get(field, ""), target)
87
+
88
+ case "len>=" | "len<=" | "len>" | "len<" | "len=":
89
+ target_len = int(raw_value)
90
+
91
+ ops_len = {"len>=": int.__ge__, "len<=": int.__le__, "len>": int.__gt__, "len<": int.__lt__, "len=": int.__eq__}
92
+ cmp_len = ops_len[op]
93
+
94
+ return lambda r: cmp_len(len(str(r.get(field, ""))), target_len)
95
+
96
+ case "starts_with":
97
+ return lambda r: str(r.get(field, "")).startswith(raw_value)
98
+
99
+ case "ends_with":
100
+ return lambda r: str(r.get(field, "")).endswith(raw_value)
101
+
102
+ case "~=":
103
+ pattern = re.compile(raw_value, re.IGNORECASE)
104
+
105
+ return lambda r: bool(pattern.search(str(r.get(field, ""))))
106
+
107
+ case "in":
108
+ values = {v.strip() for v in raw_value.split(",")}
109
+
110
+ return lambda r: str(r.get(field, "")) in values
111
+
112
+ case "has":
113
+ keywords_any = [v.strip().lower() for v in raw_value.split(",")]
114
+
115
+ return lambda r: any(k in str(r.get(field, "")).lower() for k in keywords_any)
116
+
117
+ case "has_all":
118
+ keywords_all = [v.strip().lower() for v in raw_value.split(",")]
119
+
120
+ return lambda r: all(k in str(r.get(field, "")).lower() for k in keywords_all)
121
+
122
+ case _:
123
+ raise ValueError(f"Unknown operator: {op}")
124
+
125
+
126
+ def make_filter_stage(expr: str) -> PipelineStage:
127
+ field, op, value = _parse(expr)
128
+ predicate = _make_predicate(field, op, value)
129
+
130
+ def stage(stream: Iterator[Record]) -> Iterator[Record]:
131
+ return (record for record in stream if predicate(record))
132
+
133
+ return stage
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterator
4
+ from functools import reduce
5
+
6
+ Record = dict[str, object]
7
+ PipelineStage = Callable[[Iterator[Record]], Iterator[Record]]
8
+
9
+
10
+ def build_pipeline(*stages: PipelineStage) -> PipelineStage:
11
+ if not stages:
12
+ return lambda stream: stream
13
+
14
+ return reduce(lambda f, g: lambda stream: g(f(stream)), stages)