pyXcom 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.
- pyxcom/__init__.py +29 -0
- pyxcom/auth.py +66 -0
- pyxcom/batch.py +260 -0
- pyxcom/cli.py +300 -0
- pyxcom/client.py +900 -0
- pyxcom/comments.py +235 -0
- pyxcom/errors.py +29 -0
- pyxcom/layout.py +124 -0
- pyxcom/models.py +96 -0
- pyxcom/parse.py +217 -0
- pyxcom/schema.py +276 -0
- pyxcom/search.py +94 -0
- pyxcom/storage.py +152 -0
- pyxcom/tables.py +308 -0
- pyxcom/transport.py +238 -0
- pyxcom/validate.py +112 -0
- pyxcom/validate_tables.py +98 -0
- pyxcom-0.6.0.dist-info/METADATA +253 -0
- pyxcom-0.6.0.dist-info/RECORD +22 -0
- pyxcom-0.6.0.dist-info/WHEEL +4 -0
- pyxcom-0.6.0.dist-info/entry_points.txt +2 -0
- pyxcom-0.6.0.dist-info/licenses/LICENSE +21 -0
pyxcom/comments.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Bounded, resumable traversal of public replies to a root X post."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import TYPE_CHECKING, Iterator
|
|
6
|
+
|
|
7
|
+
from .errors import ParseError, RateLimitError
|
|
8
|
+
from .models import Post
|
|
9
|
+
from .parse import _walk, parse_post, parse_profile
|
|
10
|
+
from .transport import now_utc
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .client import XClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_conversation(data: dict) -> tuple[list[Post], list[str], dict]:
|
|
17
|
+
"""Read timeline content only, excluding embedded quotes and recommendations."""
|
|
18
|
+
instructions = next(
|
|
19
|
+
(
|
|
20
|
+
n["instructions"]
|
|
21
|
+
for n in _walk(data.get("data", {}))
|
|
22
|
+
if isinstance(n.get("instructions"), list)
|
|
23
|
+
),
|
|
24
|
+
None,
|
|
25
|
+
)
|
|
26
|
+
if instructions is None:
|
|
27
|
+
raise ParseError("X conversation response contains no timeline instructions")
|
|
28
|
+
posts: dict[str, Post] = {}
|
|
29
|
+
cursors: list[str] = []
|
|
30
|
+
profiles: dict[str, dict] = {}
|
|
31
|
+
captured = now_utc()
|
|
32
|
+
|
|
33
|
+
def visit(node):
|
|
34
|
+
if isinstance(node, list):
|
|
35
|
+
for item in node:
|
|
36
|
+
visit(item)
|
|
37
|
+
elif isinstance(node, dict):
|
|
38
|
+
if node.get("promotedMetadata"):
|
|
39
|
+
return
|
|
40
|
+
if "tweet_results" in node:
|
|
41
|
+
result = (node.get("tweet_results") or {}).get("result")
|
|
42
|
+
if isinstance(result, dict):
|
|
43
|
+
post = parse_post(result, captured_at_utc=captured)
|
|
44
|
+
if post:
|
|
45
|
+
posts[post.id] = post
|
|
46
|
+
tweet = result.get("tweet", result)
|
|
47
|
+
user = (
|
|
48
|
+
tweet.get("core", {})
|
|
49
|
+
.get("user_results", {})
|
|
50
|
+
.get("result", {})
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
profile = parse_profile(user, captured_at_utc=captured)
|
|
54
|
+
profiles[profile.handle] = profile.to_dict()
|
|
55
|
+
except ParseError:
|
|
56
|
+
pass
|
|
57
|
+
return # Never recurse into a quoted tweet or an author's other data.
|
|
58
|
+
kind = node.get("cursorType")
|
|
59
|
+
if kind in (
|
|
60
|
+
"Bottom",
|
|
61
|
+
"ShowMore",
|
|
62
|
+
"ShowMoreThreads",
|
|
63
|
+
"ShowMoreReplies",
|
|
64
|
+
) and node.get("value"):
|
|
65
|
+
cursors.append(node["value"])
|
|
66
|
+
for key in (
|
|
67
|
+
"entries",
|
|
68
|
+
"entry",
|
|
69
|
+
"content",
|
|
70
|
+
"items",
|
|
71
|
+
"item",
|
|
72
|
+
"itemContent",
|
|
73
|
+
"moduleItems",
|
|
74
|
+
):
|
|
75
|
+
if key in node:
|
|
76
|
+
visit(node[key])
|
|
77
|
+
|
|
78
|
+
visit(instructions)
|
|
79
|
+
return list(posts.values()), list(dict.fromkeys(cursors)), profiles
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CommentTraversal:
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
client: "XClient",
|
|
86
|
+
root_id: str,
|
|
87
|
+
*,
|
|
88
|
+
max_depth: int,
|
|
89
|
+
max_comments: int | None,
|
|
90
|
+
max_pages: int | None,
|
|
91
|
+
records: dict[str, Post] | None = None,
|
|
92
|
+
state: dict | None = None,
|
|
93
|
+
):
|
|
94
|
+
if max_depth is None:
|
|
95
|
+
raise ValueError("max_depth must be a positive integer")
|
|
96
|
+
for name, value in (
|
|
97
|
+
("max_depth", max_depth),
|
|
98
|
+
("max_comments", max_comments),
|
|
99
|
+
("max_pages", max_pages),
|
|
100
|
+
):
|
|
101
|
+
if value is not None and (
|
|
102
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 1
|
|
103
|
+
):
|
|
104
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
105
|
+
self.client = client
|
|
106
|
+
self.root_id = root_id
|
|
107
|
+
self.max_depth, self.max_comments, self.max_pages = (
|
|
108
|
+
max_depth,
|
|
109
|
+
max_comments,
|
|
110
|
+
max_pages,
|
|
111
|
+
)
|
|
112
|
+
self.records = dict(records or {})
|
|
113
|
+
self.state = state if state is not None else {}
|
|
114
|
+
self.state.setdefault("queue", [[root_id, None]])
|
|
115
|
+
self.state.setdefault("visited", [])
|
|
116
|
+
self.state.setdefault("expanded", [root_id])
|
|
117
|
+
self.state.setdefault("pending", {})
|
|
118
|
+
self.state.setdefault("depths", {root_id: 0})
|
|
119
|
+
self.state.setdefault("profiles", {})
|
|
120
|
+
self.state.setdefault("pagination_warnings", [])
|
|
121
|
+
self.state.setdefault("empty_pages", {})
|
|
122
|
+
self.complete = False
|
|
123
|
+
self.reason = "not_started"
|
|
124
|
+
self.pages_fetched = 0
|
|
125
|
+
self.page_was_fetched = False
|
|
126
|
+
|
|
127
|
+
def _accept_pending(self) -> list[Post]:
|
|
128
|
+
added: list[Post] = []
|
|
129
|
+
pending = self.state["pending"]
|
|
130
|
+
while True:
|
|
131
|
+
progressed = False
|
|
132
|
+
for key, value in list(pending.items()):
|
|
133
|
+
post = Post(**value)
|
|
134
|
+
parent_depth = self.state["depths"].get(post.in_reply_to_id)
|
|
135
|
+
if parent_depth is None:
|
|
136
|
+
continue
|
|
137
|
+
depth = parent_depth + 1
|
|
138
|
+
if depth > self.max_depth or key in self.records:
|
|
139
|
+
self.state["depths"][key] = depth
|
|
140
|
+
del pending[key]
|
|
141
|
+
progressed = True
|
|
142
|
+
continue
|
|
143
|
+
if (
|
|
144
|
+
self.max_comments is not None
|
|
145
|
+
and len(self.records) - 1 >= self.max_comments
|
|
146
|
+
):
|
|
147
|
+
return added
|
|
148
|
+
self.records[key] = post
|
|
149
|
+
self.state["depths"][key] = depth
|
|
150
|
+
del pending[key]
|
|
151
|
+
added.append(post)
|
|
152
|
+
progressed = True
|
|
153
|
+
if (
|
|
154
|
+
depth < self.max_depth
|
|
155
|
+
and post.reply_count != 0
|
|
156
|
+
and key not in self.state["expanded"]
|
|
157
|
+
):
|
|
158
|
+
self.state["queue"].append([key, None])
|
|
159
|
+
self.state["expanded"].append(key)
|
|
160
|
+
if not progressed:
|
|
161
|
+
return added
|
|
162
|
+
|
|
163
|
+
def pages(self) -> Iterator[list[Post]]:
|
|
164
|
+
if self.root_id not in self.records:
|
|
165
|
+
root = self.client.get_post(self.root_id)
|
|
166
|
+
if root.in_reply_to_id or root.reposted_post_id:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
"post_id_or_url must identify a main post, not a reply or repost"
|
|
169
|
+
)
|
|
170
|
+
self.records[root.id] = root
|
|
171
|
+
self.page_was_fetched = False
|
|
172
|
+
yield [root]
|
|
173
|
+
while True:
|
|
174
|
+
self.page_was_fetched = False
|
|
175
|
+
ready = self._accept_pending()
|
|
176
|
+
if ready:
|
|
177
|
+
yield ready
|
|
178
|
+
if (
|
|
179
|
+
self.max_comments is not None
|
|
180
|
+
and len(self.records) - 1 >= self.max_comments
|
|
181
|
+
):
|
|
182
|
+
self.reason = "comment_limit"
|
|
183
|
+
return
|
|
184
|
+
if not self.state["queue"]:
|
|
185
|
+
unresolved = len(self.state["pending"])
|
|
186
|
+
self.state["unresolved_count"] = unresolved
|
|
187
|
+
self.complete = not unresolved and not self.state["pagination_warnings"]
|
|
188
|
+
self.reason = (
|
|
189
|
+
"visible_source_end" if self.complete else "partial_conversation"
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
if self.max_pages is not None and self.pages_fetched >= self.max_pages:
|
|
193
|
+
self.reason = "page_limit"
|
|
194
|
+
return
|
|
195
|
+
task = self.state["queue"][0]
|
|
196
|
+
focal, cursor = task
|
|
197
|
+
try:
|
|
198
|
+
payload = self.client._x.conversation_page(focal, cursor=cursor)
|
|
199
|
+
except RateLimitError as exc:
|
|
200
|
+
self.state["rate_reset_at"] = exc.reset_at
|
|
201
|
+
self.reason = "rate_limited"
|
|
202
|
+
return
|
|
203
|
+
posts, cursors, profiles = parse_conversation(payload)
|
|
204
|
+
self.state.pop("rate_reset_at", None)
|
|
205
|
+
self.state["queue"].pop(0)
|
|
206
|
+
self.state["visited"].append(task)
|
|
207
|
+
self.state["profiles"].update(profiles)
|
|
208
|
+
relevant = [
|
|
209
|
+
p
|
|
210
|
+
for p in posts
|
|
211
|
+
if p.conversation_id == self.root_id
|
|
212
|
+
and p.in_reply_to_id
|
|
213
|
+
and p.id not in self.records
|
|
214
|
+
]
|
|
215
|
+
for post in relevant:
|
|
216
|
+
self.state["pending"][post.id] = replace(
|
|
217
|
+
post,
|
|
218
|
+
discovery_source="x_conversation",
|
|
219
|
+
discovery_url=f"https://x.com/i/status/{focal}",
|
|
220
|
+
).to_dict()
|
|
221
|
+
empty = self.state["empty_pages"].get(focal, 0)
|
|
222
|
+
self.state["empty_pages"][focal] = empty + 1 if not relevant else 0
|
|
223
|
+
for next_cursor in cursors:
|
|
224
|
+
follow = [focal, next_cursor]
|
|
225
|
+
if follow in self.state["visited"]:
|
|
226
|
+
self.state["pagination_warnings"].append("repeated_cursor")
|
|
227
|
+
elif self.state["empty_pages"][focal] >= 2:
|
|
228
|
+
self.state["pagination_warnings"].append("empty_page_limit")
|
|
229
|
+
elif follow not in self.state["queue"]:
|
|
230
|
+
self.state["queue"].append(follow)
|
|
231
|
+
self.pages_fetched += 1
|
|
232
|
+
self.page_was_fetched = True
|
|
233
|
+
yield self._accept_pending()
|
|
234
|
+
if self.client.delay:
|
|
235
|
+
time.sleep(self.client.delay)
|
pyxcom/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Typed failures surfaced by pyXcom."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PyXcomError(Exception):
|
|
5
|
+
"""Base package exception."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(PyXcomError):
|
|
9
|
+
"""No usable browser login was found."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CaptureError(PyXcomError):
|
|
13
|
+
"""The browser could not capture the requested X read endpoint."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(PyXcomError):
|
|
17
|
+
"""X returned an unusable response."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitError(APIError):
|
|
21
|
+
"""X limited the requested endpoint."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, reset_at: int | None = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.reset_at = reset_at
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ParseError(PyXcomError):
|
|
29
|
+
"""The response shape changed or omitted expected data."""
|
pyxcom/layout.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Stable public tables and private, resumable collection artifacts."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_INTERNAL_FILES = (
|
|
9
|
+
"posts.jsonl",
|
|
10
|
+
"state.json",
|
|
11
|
+
"profiles.json",
|
|
12
|
+
"account_manifest.json",
|
|
13
|
+
"report.md",
|
|
14
|
+
"schema.json",
|
|
15
|
+
"schema_report.md",
|
|
16
|
+
"posts.jsonl.before-role-schema.gz",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def internal_dir(output: str | Path) -> Path:
|
|
21
|
+
return Path(output).expanduser() / ".pyxcom"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def source_path(output: str | Path, name: str) -> Path:
|
|
25
|
+
private = internal_dir(output) / name
|
|
26
|
+
legacy = Path(output).expanduser() / name
|
|
27
|
+
return private if private.exists() or not legacy.exists() else legacy
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def child_dir(output: str | Path, name: str) -> Path:
|
|
31
|
+
return internal_dir(output) / name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _same_contents(left: Path, right: Path) -> bool:
|
|
35
|
+
"""Compare every directory entry and file byte before reusing a backup."""
|
|
36
|
+
if left.is_symlink() or right.is_symlink():
|
|
37
|
+
return False
|
|
38
|
+
if left.is_dir() and right.is_dir():
|
|
39
|
+
left_entries = {entry.name: entry for entry in left.iterdir()}
|
|
40
|
+
right_entries = {entry.name: entry for entry in right.iterdir()}
|
|
41
|
+
return left_entries.keys() == right_entries.keys() and all(
|
|
42
|
+
_same_contents(entry, right_entries[name])
|
|
43
|
+
for name, entry in left_entries.items()
|
|
44
|
+
)
|
|
45
|
+
return (
|
|
46
|
+
left.is_file() and right.is_file() and left.read_bytes() == right.read_bytes()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _move_preserving(source: Path, target: Path, backup: Path) -> None:
|
|
51
|
+
if not source.exists():
|
|
52
|
+
return
|
|
53
|
+
if target.exists():
|
|
54
|
+
if (
|
|
55
|
+
source.is_dir()
|
|
56
|
+
or target.is_dir()
|
|
57
|
+
or source.read_bytes() != target.read_bytes()
|
|
58
|
+
):
|
|
59
|
+
raise ValueError(f"Conflicting migration paths: {source} and {target}")
|
|
60
|
+
if backup.exists():
|
|
61
|
+
if not _same_contents(source, backup):
|
|
62
|
+
raise ValueError(f"Conflicting legacy backup: {backup}")
|
|
63
|
+
else:
|
|
64
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
if source.is_dir():
|
|
66
|
+
shutil.copytree(source, backup)
|
|
67
|
+
else:
|
|
68
|
+
shutil.copy2(source, backup)
|
|
69
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
if target.exists():
|
|
71
|
+
source.unlink()
|
|
72
|
+
else:
|
|
73
|
+
source.rename(target)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def migrate_collection(output: str | Path) -> Path:
|
|
77
|
+
"""Move recognized legacy artifacts, preserving backups and unrelated files.
|
|
78
|
+
|
|
79
|
+
Repeated calls are safe. Conflicting destinations raise rather than overwrite.
|
|
80
|
+
Normalized root tables are never mistaken for legacy mixed observations.
|
|
81
|
+
"""
|
|
82
|
+
directory = Path(output).expanduser()
|
|
83
|
+
private = internal_dir(directory)
|
|
84
|
+
private.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
backup = private / "legacy"
|
|
86
|
+
for name in _INTERNAL_FILES:
|
|
87
|
+
_move_preserving(directory / name, private / name, backup / name)
|
|
88
|
+
root_manifest = directory / "manifest.json"
|
|
89
|
+
if root_manifest.exists():
|
|
90
|
+
data = json.loads(root_manifest.read_text(encoding="utf-8"))
|
|
91
|
+
if "post_count" in data and "counts" not in data:
|
|
92
|
+
_move_preserving(
|
|
93
|
+
root_manifest, private / "manifest.json", backup / "manifest.json"
|
|
94
|
+
)
|
|
95
|
+
root_csv = directory / "posts.csv"
|
|
96
|
+
if root_csv.exists():
|
|
97
|
+
with root_csv.open(encoding="utf-8-sig", newline="") as stream:
|
|
98
|
+
header = next(csv.reader(stream), [])
|
|
99
|
+
if "id" in header and ("post_role" in header or "author_handle" in header):
|
|
100
|
+
_move_preserving(root_csv, private / "posts.csv", backup / "posts.csv")
|
|
101
|
+
# Only folders with a collection checkpoint are owned collection scopes.
|
|
102
|
+
for folder in list(directory.iterdir()):
|
|
103
|
+
if (
|
|
104
|
+
folder.is_dir()
|
|
105
|
+
and folder.name not in {".pyxcom", "tables"}
|
|
106
|
+
and (folder / "state.json").exists()
|
|
107
|
+
):
|
|
108
|
+
_move_preserving(folder, private / folder.name, backup / folder.name)
|
|
109
|
+
old_tables = directory / "tables"
|
|
110
|
+
if (old_tables / "manifest.json").exists():
|
|
111
|
+
data = json.loads((old_tables / "manifest.json").read_text(encoding="utf-8"))
|
|
112
|
+
if data.get("source_kind") == "saved_post_observations":
|
|
113
|
+
_move_preserving(old_tables, private / "previous_tables", backup / "tables")
|
|
114
|
+
for folder in list(private.iterdir()):
|
|
115
|
+
if (
|
|
116
|
+
folder.is_dir()
|
|
117
|
+
and folder.name not in {"legacy", "previous_tables"}
|
|
118
|
+
and (
|
|
119
|
+
(folder / "state.json").exists()
|
|
120
|
+
or (internal_dir(folder) / "state.json").exists()
|
|
121
|
+
)
|
|
122
|
+
):
|
|
123
|
+
migrate_collection(folder)
|
|
124
|
+
return directory
|
pyxcom/models.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Portable public-data records returned by pyXcom."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def classify_post(
|
|
8
|
+
in_reply_to_id: str | None,
|
|
9
|
+
quoted_post_id: str | None,
|
|
10
|
+
reposted_post_id: str | None,
|
|
11
|
+
) -> tuple[str, str]:
|
|
12
|
+
"""Return (role, type) from X's explicit post relationships."""
|
|
13
|
+
if reposted_post_id:
|
|
14
|
+
return "repost", "repost"
|
|
15
|
+
if in_reply_to_id:
|
|
16
|
+
return "comment", "reply"
|
|
17
|
+
if quoted_post_id:
|
|
18
|
+
return "main", "quote"
|
|
19
|
+
return "main", "original"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Profile:
|
|
24
|
+
id: str
|
|
25
|
+
handle: str
|
|
26
|
+
name: str
|
|
27
|
+
description: str | None = None
|
|
28
|
+
created_at_utc: str | None = None
|
|
29
|
+
followers_count: int | None = None
|
|
30
|
+
following_count: int | None = None
|
|
31
|
+
posts_count: int | None = None
|
|
32
|
+
media_count: int | None = None
|
|
33
|
+
verified: bool | None = None
|
|
34
|
+
url: str | None = None
|
|
35
|
+
captured_at_utc: str | None = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return asdict(self)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Post:
|
|
43
|
+
id: str
|
|
44
|
+
author_id: str
|
|
45
|
+
author_handle: str
|
|
46
|
+
created_at_utc: str
|
|
47
|
+
text: str
|
|
48
|
+
url: str
|
|
49
|
+
post_role: str = ""
|
|
50
|
+
post_type: str = ""
|
|
51
|
+
language: str | None = None
|
|
52
|
+
conversation_id: str | None = None
|
|
53
|
+
in_reply_to_id: str | None = None
|
|
54
|
+
quoted_post_id: str | None = None
|
|
55
|
+
reposted_post_id: str | None = None
|
|
56
|
+
reply_count: int | None = None
|
|
57
|
+
repost_count: int | None = None
|
|
58
|
+
like_count: int | None = None
|
|
59
|
+
view_count: int | None = None
|
|
60
|
+
quote_count: int | None = None
|
|
61
|
+
bookmark_count: int | None = None
|
|
62
|
+
media_urls: list[str] = field(default_factory=list)
|
|
63
|
+
outbound_urls: list[str] = field(default_factory=list)
|
|
64
|
+
hashtags: list[str] = field(default_factory=list)
|
|
65
|
+
mentions: list[str] = field(default_factory=list)
|
|
66
|
+
discovery_source: str | None = None
|
|
67
|
+
discovery_url: str | None = None
|
|
68
|
+
captured_at_utc: str | None = None
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
role, kind = classify_post(
|
|
72
|
+
self.in_reply_to_id, self.quoted_post_id, self.reposted_post_id
|
|
73
|
+
)
|
|
74
|
+
object.__setattr__(self, "post_role", role)
|
|
75
|
+
object.__setattr__(self, "post_type", kind)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class CollectionResult:
|
|
83
|
+
output_dir: Path
|
|
84
|
+
post_count: int
|
|
85
|
+
pages_fetched: int
|
|
86
|
+
complete: bool
|
|
87
|
+
reason: str
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"output_dir": str(self.output_dir),
|
|
92
|
+
"post_count": self.post_count,
|
|
93
|
+
"pages_fetched": self.pages_fetched,
|
|
94
|
+
"complete": self.complete,
|
|
95
|
+
"reason": self.reason,
|
|
96
|
+
}
|