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
prawler/crawler/post.py
ADDED
|
@@ -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
|
prawler/model/comment.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 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()
|
prawler/output/sinks.py
ADDED
|
@@ -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,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)
|