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/client.py
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
"""User-facing collection API for public X data."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Iterator
|
|
10
|
+
|
|
11
|
+
from .auth import load_x_cookies
|
|
12
|
+
from .errors import APIError, RateLimitError
|
|
13
|
+
from .layout import child_dir, migrate_collection, source_path
|
|
14
|
+
from .models import CollectionResult, Post, Profile
|
|
15
|
+
from .parse import bottom_cursor, timeline_primary_posts
|
|
16
|
+
from .search import MirrorSearch
|
|
17
|
+
from .storage import PostStore, _atomic_json
|
|
18
|
+
from .transport import TWEET_FEATURES, XTransport, now_utc
|
|
19
|
+
|
|
20
|
+
_HANDLE = re.compile(r"^[A-Za-z0-9_]{1,15}$")
|
|
21
|
+
_POST_ID = re.compile(r"(?:/status/)?(\d{10,25})(?:\D.*)?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _handle(value: str) -> str:
|
|
25
|
+
handle = value.removeprefix("@").strip()
|
|
26
|
+
if not _HANDLE.fullmatch(handle):
|
|
27
|
+
raise ValueError("X handle must contain 1-15 letters, numbers, or underscores")
|
|
28
|
+
return handle
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _date(value: str | None) -> str | None:
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
return date.fromisoformat(value).isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _dates(since: str | None, until: str | None) -> tuple[str | None, str | None]:
|
|
38
|
+
since, until = _date(since), _date(until)
|
|
39
|
+
if since and until and since >= until:
|
|
40
|
+
raise ValueError("since must be earlier than until; until is exclusive")
|
|
41
|
+
return since, until
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _post_id(value: str) -> str:
|
|
45
|
+
match = _POST_ID.search(value.strip())
|
|
46
|
+
if not match:
|
|
47
|
+
raise ValueError("Expected a numeric post ID or an X post URL")
|
|
48
|
+
return match.group(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _page_options(max_pages: int | None, limit: int | None) -> None:
|
|
52
|
+
for name, value in (("max_pages", max_pages), ("limit", limit)):
|
|
53
|
+
if value is not None and (
|
|
54
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 1
|
|
55
|
+
):
|
|
56
|
+
raise ValueError(f"{name} must be a positive integer or None")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _within(post: Post, since: str | None, until: str | None) -> bool:
|
|
60
|
+
day = post.created_at_utc[:10]
|
|
61
|
+
return (since is None or day >= since) and (until is None or day < until)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class XClient:
|
|
65
|
+
"""Read public X data using a manually logged-in Chrome or Edge session.
|
|
66
|
+
|
|
67
|
+
No browser is launched or controlled. Cookies are read into memory and sent
|
|
68
|
+
only to x.com. A search mirror is used only when explicitly configured.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
browser: str = "chrome",
|
|
75
|
+
profile: str | None = None,
|
|
76
|
+
cookie_db: str | Path | None = None,
|
|
77
|
+
proxy: str | None = None,
|
|
78
|
+
mirror_base: str | None = None,
|
|
79
|
+
delay: float = 1.0,
|
|
80
|
+
timeout: float = 30,
|
|
81
|
+
) -> None:
|
|
82
|
+
cookies = load_x_cookies(browser, profile=profile, cookie_db=cookie_db)
|
|
83
|
+
self._x = XTransport(cookies, proxy=proxy, timeout=timeout)
|
|
84
|
+
self._mirror = (
|
|
85
|
+
MirrorSearch(base_url=mirror_base, proxy=proxy, timeout=timeout)
|
|
86
|
+
if mirror_base
|
|
87
|
+
else None
|
|
88
|
+
)
|
|
89
|
+
self._profile_cache: dict[str, Profile] = {}
|
|
90
|
+
self.delay = max(0.0, delay)
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
self._x.close()
|
|
94
|
+
if self._mirror is not None:
|
|
95
|
+
self._mirror.close()
|
|
96
|
+
|
|
97
|
+
def __enter__(self) -> "XClient":
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def __exit__(self, *_) -> None:
|
|
101
|
+
self.close()
|
|
102
|
+
|
|
103
|
+
def get_user(self, handle: str) -> Profile:
|
|
104
|
+
normalized = _handle(handle)
|
|
105
|
+
key = normalized.lower()
|
|
106
|
+
if key not in self._profile_cache:
|
|
107
|
+
self._profile_cache[key] = self._x.get_profile(normalized)
|
|
108
|
+
return self._profile_cache[key]
|
|
109
|
+
|
|
110
|
+
def get_post(self, post_id_or_url: str) -> Post:
|
|
111
|
+
post = self._x.get_post(_post_id(post_id_or_url))
|
|
112
|
+
return replace(post, discovery_source="x_detail", discovery_url=post.url)
|
|
113
|
+
|
|
114
|
+
def get_posts(self, post_ids: list[str]) -> list[Post]:
|
|
115
|
+
posts: list[Post] = []
|
|
116
|
+
for offset in range(0, len(post_ids), 50):
|
|
117
|
+
ids = [_post_id(value) for value in post_ids[offset : offset + 50]]
|
|
118
|
+
posts.extend(self._x.get_posts(ids))
|
|
119
|
+
if offset + 50 < len(post_ids):
|
|
120
|
+
time.sleep(self.delay)
|
|
121
|
+
return posts
|
|
122
|
+
|
|
123
|
+
def get_raw_post(self, post_id_or_url: str) -> dict:
|
|
124
|
+
"""Return the current raw public X object for a single post."""
|
|
125
|
+
return self._x.graphql(
|
|
126
|
+
"TweetResultByRestId",
|
|
127
|
+
{
|
|
128
|
+
"tweetId": _post_id(post_id_or_url),
|
|
129
|
+
"withCommunity": False,
|
|
130
|
+
"includePromotedContent": False,
|
|
131
|
+
"withVoice": False,
|
|
132
|
+
},
|
|
133
|
+
features=TWEET_FEATURES,
|
|
134
|
+
field_toggles={},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def get_raw_user_timeline(
|
|
138
|
+
self, handle: str, *, timeline: str = "posts", cursor: str | None = None
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""Return the current raw X timeline page for diagnosis or custom fields."""
|
|
141
|
+
profile = self.get_user(handle)
|
|
142
|
+
return self._x.user_timeline_page(profile.id, timeline=timeline, cursor=cursor)
|
|
143
|
+
|
|
144
|
+
def iter_user_posts(
|
|
145
|
+
self,
|
|
146
|
+
handle: str,
|
|
147
|
+
*,
|
|
148
|
+
timeline: str = "posts",
|
|
149
|
+
since: str | None = None,
|
|
150
|
+
until: str | None = None,
|
|
151
|
+
max_pages: int | None = None,
|
|
152
|
+
limit: int | None = None,
|
|
153
|
+
) -> Iterator[Post]:
|
|
154
|
+
_page_options(max_pages, limit)
|
|
155
|
+
if timeline not in {"posts", "replies"}:
|
|
156
|
+
raise ValueError("timeline must be posts or replies")
|
|
157
|
+
since, until = _dates(since, until)
|
|
158
|
+
profile = self.get_user(handle)
|
|
159
|
+
cursor = None
|
|
160
|
+
seen_ids: set[str] = set()
|
|
161
|
+
seen_cursors: set[str] = set()
|
|
162
|
+
pages = 0
|
|
163
|
+
yielded = 0
|
|
164
|
+
old_pages = 0
|
|
165
|
+
empty_pages = 0
|
|
166
|
+
while True:
|
|
167
|
+
if cursor and cursor in seen_cursors:
|
|
168
|
+
return
|
|
169
|
+
if cursor:
|
|
170
|
+
seen_cursors.add(cursor)
|
|
171
|
+
payload = self._x.user_timeline_page(
|
|
172
|
+
profile.id, timeline=timeline, cursor=cursor
|
|
173
|
+
)
|
|
174
|
+
authored = [
|
|
175
|
+
post
|
|
176
|
+
for post in timeline_primary_posts(payload, captured_at_utc=now_utc())
|
|
177
|
+
if post.author_id == profile.id
|
|
178
|
+
]
|
|
179
|
+
for post in authored:
|
|
180
|
+
if post.post_role != ("comment" if timeline == "replies" else "main"):
|
|
181
|
+
continue
|
|
182
|
+
if post.id in seen_ids or not _within(post, since, until):
|
|
183
|
+
continue
|
|
184
|
+
seen_ids.add(post.id)
|
|
185
|
+
yield replace(
|
|
186
|
+
post,
|
|
187
|
+
discovery_source="x_timeline",
|
|
188
|
+
discovery_url=f"https://x.com/{profile.handle}",
|
|
189
|
+
)
|
|
190
|
+
yielded += 1
|
|
191
|
+
if limit and yielded >= limit:
|
|
192
|
+
return
|
|
193
|
+
pages += 1
|
|
194
|
+
if (
|
|
195
|
+
since
|
|
196
|
+
and authored
|
|
197
|
+
and max(post.created_at_utc[:10] for post in authored) < since
|
|
198
|
+
):
|
|
199
|
+
old_pages += 1
|
|
200
|
+
else:
|
|
201
|
+
old_pages = 0
|
|
202
|
+
empty_pages = empty_pages + 1 if not authored else 0
|
|
203
|
+
cursor = bottom_cursor(payload)
|
|
204
|
+
if (
|
|
205
|
+
not cursor
|
|
206
|
+
or old_pages >= 2
|
|
207
|
+
or empty_pages >= 2
|
|
208
|
+
or (max_pages and pages >= max_pages)
|
|
209
|
+
):
|
|
210
|
+
return
|
|
211
|
+
time.sleep(self.delay)
|
|
212
|
+
|
|
213
|
+
def get_user_posts(
|
|
214
|
+
self,
|
|
215
|
+
handle: str,
|
|
216
|
+
*,
|
|
217
|
+
timeline: str = "posts",
|
|
218
|
+
since: str | None = None,
|
|
219
|
+
until: str | None = None,
|
|
220
|
+
max_pages: int | None = None,
|
|
221
|
+
limit: int | None = None,
|
|
222
|
+
) -> list[Post]:
|
|
223
|
+
"""Return authored timeline records; prefer get_user_replies for replies."""
|
|
224
|
+
return list(
|
|
225
|
+
self.iter_user_posts(
|
|
226
|
+
handle,
|
|
227
|
+
timeline=timeline,
|
|
228
|
+
since=since,
|
|
229
|
+
until=until,
|
|
230
|
+
max_pages=max_pages,
|
|
231
|
+
limit=limit,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def save_user_posts(
|
|
236
|
+
self,
|
|
237
|
+
handle: str,
|
|
238
|
+
output_dir: str | Path,
|
|
239
|
+
*,
|
|
240
|
+
timeline: str = "posts",
|
|
241
|
+
since: str | None = None,
|
|
242
|
+
until: str | None = None,
|
|
243
|
+
max_pages: int | None = None,
|
|
244
|
+
limit: int | None = None,
|
|
245
|
+
) -> CollectionResult:
|
|
246
|
+
_page_options(max_pages, limit)
|
|
247
|
+
if timeline not in {"posts", "replies"}:
|
|
248
|
+
raise ValueError("timeline must be posts or replies")
|
|
249
|
+
since, until = _dates(since, until)
|
|
250
|
+
profile = self.get_user(handle)
|
|
251
|
+
query = {
|
|
252
|
+
"kind": "user_timeline",
|
|
253
|
+
"handle": profile.handle,
|
|
254
|
+
"timeline": timeline,
|
|
255
|
+
"since": since,
|
|
256
|
+
"until": until,
|
|
257
|
+
}
|
|
258
|
+
store = PostStore(output_dir, query=query)
|
|
259
|
+
store.retain_role("comment" if timeline == "replies" else "main")
|
|
260
|
+
if query["kind"] == "user_timeline":
|
|
261
|
+
_atomic_json(
|
|
262
|
+
source_path(output_dir, "profiles.json"),
|
|
263
|
+
{profile.handle: profile.to_dict()},
|
|
264
|
+
)
|
|
265
|
+
if store.complete:
|
|
266
|
+
return store.finish(complete=True, reason=store.state["reason"])
|
|
267
|
+
cursor = store.cursor
|
|
268
|
+
seen_cursors: set[str] = set()
|
|
269
|
+
pages_this_run = 0
|
|
270
|
+
old_pages = 0
|
|
271
|
+
empty_pages = 0
|
|
272
|
+
while True:
|
|
273
|
+
if cursor and cursor in seen_cursors:
|
|
274
|
+
return store.finish(complete=False, reason="repeated_cursor")
|
|
275
|
+
if cursor:
|
|
276
|
+
seen_cursors.add(cursor)
|
|
277
|
+
try:
|
|
278
|
+
payload = self._x.user_timeline_page(
|
|
279
|
+
profile.id, timeline=timeline, cursor=cursor
|
|
280
|
+
)
|
|
281
|
+
except RateLimitError as exc:
|
|
282
|
+
store.state["rate_reset_at"] = exc.reset_at
|
|
283
|
+
return store.finish(complete=False, reason="rate_limited")
|
|
284
|
+
authored = [
|
|
285
|
+
post
|
|
286
|
+
for post in timeline_primary_posts(payload, captured_at_utc=now_utc())
|
|
287
|
+
if post.author_id == profile.id
|
|
288
|
+
]
|
|
289
|
+
filtered = [
|
|
290
|
+
replace(
|
|
291
|
+
post,
|
|
292
|
+
discovery_source="x_timeline",
|
|
293
|
+
discovery_url=f"https://x.com/{profile.handle}",
|
|
294
|
+
)
|
|
295
|
+
for post in authored
|
|
296
|
+
if _within(post, since, until)
|
|
297
|
+
and post.post_role == ("comment" if timeline == "replies" else "main")
|
|
298
|
+
]
|
|
299
|
+
next_cursor = bottom_cursor(payload)
|
|
300
|
+
store.state.pop("rate_reset_at", None)
|
|
301
|
+
store.append_page(filtered, next_cursor)
|
|
302
|
+
pages_this_run += 1
|
|
303
|
+
if (
|
|
304
|
+
since
|
|
305
|
+
and authored
|
|
306
|
+
and max(post.created_at_utc[:10] for post in authored) < since
|
|
307
|
+
):
|
|
308
|
+
old_pages += 1
|
|
309
|
+
else:
|
|
310
|
+
old_pages = 0
|
|
311
|
+
empty_pages = empty_pages + 1 if not authored else 0
|
|
312
|
+
if not next_cursor:
|
|
313
|
+
return store.finish(complete=True, reason="source_end")
|
|
314
|
+
if old_pages >= 2:
|
|
315
|
+
return store.finish(complete=True, reason="passed_since")
|
|
316
|
+
if empty_pages >= 2:
|
|
317
|
+
return store.finish(complete=True, reason="empty_timeline_end")
|
|
318
|
+
if limit and store.count >= limit:
|
|
319
|
+
return store.finish(complete=False, reason="item_limit")
|
|
320
|
+
if max_pages and pages_this_run >= max_pages:
|
|
321
|
+
return store.finish(complete=False, reason="page_limit")
|
|
322
|
+
cursor = next_cursor
|
|
323
|
+
time.sleep(self.delay)
|
|
324
|
+
|
|
325
|
+
def save_user_activity(
|
|
326
|
+
self,
|
|
327
|
+
handle: str,
|
|
328
|
+
output_dir: str | Path,
|
|
329
|
+
*,
|
|
330
|
+
since: str | None = None,
|
|
331
|
+
until: str | None = None,
|
|
332
|
+
max_pages: int | None = None,
|
|
333
|
+
) -> CollectionResult:
|
|
334
|
+
"""Collect originals and authored replies into one deduplicated table."""
|
|
335
|
+
since, until = _dates(since, until)
|
|
336
|
+
output = Path(output_dir)
|
|
337
|
+
migrate_collection(output)
|
|
338
|
+
original = self.save_user_posts(
|
|
339
|
+
handle,
|
|
340
|
+
child_dir(output, "originals"),
|
|
341
|
+
timeline="posts",
|
|
342
|
+
since=since,
|
|
343
|
+
until=until,
|
|
344
|
+
max_pages=max_pages,
|
|
345
|
+
)
|
|
346
|
+
replies = self.save_user_posts(
|
|
347
|
+
handle,
|
|
348
|
+
child_dir(output, "replies"),
|
|
349
|
+
timeline="replies",
|
|
350
|
+
since=since,
|
|
351
|
+
until=until,
|
|
352
|
+
max_pages=max_pages,
|
|
353
|
+
)
|
|
354
|
+
store = PostStore(
|
|
355
|
+
output,
|
|
356
|
+
query={
|
|
357
|
+
"kind": "user_activity",
|
|
358
|
+
"handle": _handle(handle),
|
|
359
|
+
"since": since,
|
|
360
|
+
"until": until,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
profile = self.get_user(handle)
|
|
364
|
+
_atomic_json(
|
|
365
|
+
source_path(output, "profiles.json"), {profile.handle: profile.to_dict()}
|
|
366
|
+
)
|
|
367
|
+
if store.complete and original.complete and replies.complete:
|
|
368
|
+
return store.finish(complete=True, reason="both_timelines_complete")
|
|
369
|
+
combined: dict[str, Post] = {}
|
|
370
|
+
for child in (child_dir(output, "originals"), child_dir(output, "replies")):
|
|
371
|
+
for line in (
|
|
372
|
+
source_path(child, "posts.jsonl")
|
|
373
|
+
.read_text(encoding="utf-8")
|
|
374
|
+
.split("\n")
|
|
375
|
+
):
|
|
376
|
+
if line.strip():
|
|
377
|
+
post = Post(**json.loads(line))
|
|
378
|
+
combined[post.id] = post
|
|
379
|
+
store.append_page(list(combined.values()), None)
|
|
380
|
+
store.state["pages_fetched"] = original.pages_fetched + replies.pages_fetched
|
|
381
|
+
return store.finish(
|
|
382
|
+
complete=original.complete and replies.complete,
|
|
383
|
+
reason="both_timelines_complete"
|
|
384
|
+
if original.complete and replies.complete
|
|
385
|
+
else "partial_timelines",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def iter_search(
|
|
389
|
+
self,
|
|
390
|
+
keyword: str,
|
|
391
|
+
*,
|
|
392
|
+
since: str | None = None,
|
|
393
|
+
until: str | None = None,
|
|
394
|
+
user: str | None = None,
|
|
395
|
+
max_pages: int | None = None,
|
|
396
|
+
limit: int | None = None,
|
|
397
|
+
) -> Iterator[Post]:
|
|
398
|
+
_page_options(max_pages, limit)
|
|
399
|
+
since, until = _dates(since, until)
|
|
400
|
+
user = _handle(user) if user else None
|
|
401
|
+
if not keyword.strip():
|
|
402
|
+
raise ValueError("keyword cannot be empty")
|
|
403
|
+
if self._mirror is None:
|
|
404
|
+
if user is None:
|
|
405
|
+
raise APIError(
|
|
406
|
+
"Cross-user keyword search requires an explicit mirror_base; "
|
|
407
|
+
"search within one user with user=... to stay on X"
|
|
408
|
+
)
|
|
409
|
+
seen_ids: set[str] = set()
|
|
410
|
+
yielded = 0
|
|
411
|
+
needle = keyword.casefold()
|
|
412
|
+
for timeline in ("posts", "replies"):
|
|
413
|
+
for post in self.iter_user_posts(
|
|
414
|
+
user,
|
|
415
|
+
timeline=timeline,
|
|
416
|
+
since=since,
|
|
417
|
+
until=until,
|
|
418
|
+
max_pages=max_pages,
|
|
419
|
+
):
|
|
420
|
+
if post.id in seen_ids or needle not in post.text.casefold():
|
|
421
|
+
continue
|
|
422
|
+
seen_ids.add(post.id)
|
|
423
|
+
yield post
|
|
424
|
+
yielded += 1
|
|
425
|
+
if limit and yielded >= limit:
|
|
426
|
+
return
|
|
427
|
+
return
|
|
428
|
+
mirror = self._mirror
|
|
429
|
+
cursor: str | None = mirror.initial_url(
|
|
430
|
+
keyword, since=since, until=until, user=user
|
|
431
|
+
)
|
|
432
|
+
seen_cursors: set[str] = set()
|
|
433
|
+
mirror_seen_ids: set[str] = set()
|
|
434
|
+
pages = 0
|
|
435
|
+
yielded = 0
|
|
436
|
+
while cursor and cursor not in seen_cursors:
|
|
437
|
+
seen_cursors.add(cursor)
|
|
438
|
+
page = mirror.page(cursor)
|
|
439
|
+
for offset in range(0, len(page.post_ids), 50):
|
|
440
|
+
posts = self._x.get_posts(page.post_ids[offset : offset + 50])
|
|
441
|
+
for post in posts:
|
|
442
|
+
if post.id in mirror_seen_ids or not _within(post, since, until):
|
|
443
|
+
continue
|
|
444
|
+
if user and post.author_handle.lower() != user.lower():
|
|
445
|
+
continue
|
|
446
|
+
mirror_seen_ids.add(post.id)
|
|
447
|
+
yield replace(
|
|
448
|
+
post,
|
|
449
|
+
discovery_source="nitter_search",
|
|
450
|
+
discovery_url=page.source_url,
|
|
451
|
+
)
|
|
452
|
+
yielded += 1
|
|
453
|
+
if limit and yielded >= limit:
|
|
454
|
+
return
|
|
455
|
+
pages += 1
|
|
456
|
+
cursor = page.next_url
|
|
457
|
+
if max_pages and pages >= max_pages:
|
|
458
|
+
return
|
|
459
|
+
time.sleep(self.delay)
|
|
460
|
+
|
|
461
|
+
def get_search(
|
|
462
|
+
self,
|
|
463
|
+
keyword: str,
|
|
464
|
+
*,
|
|
465
|
+
since: str | None = None,
|
|
466
|
+
until: str | None = None,
|
|
467
|
+
user: str | None = None,
|
|
468
|
+
max_pages: int | None = None,
|
|
469
|
+
limit: int | None = None,
|
|
470
|
+
) -> list[Post]:
|
|
471
|
+
"""Compatibility name for get_search_posts; user is the legacy handle option."""
|
|
472
|
+
return list(
|
|
473
|
+
self.iter_search(
|
|
474
|
+
keyword,
|
|
475
|
+
since=since,
|
|
476
|
+
until=until,
|
|
477
|
+
user=user,
|
|
478
|
+
max_pages=max_pages,
|
|
479
|
+
limit=limit,
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def save_search(
|
|
484
|
+
self,
|
|
485
|
+
keyword: str,
|
|
486
|
+
output_dir: str | Path,
|
|
487
|
+
*,
|
|
488
|
+
since: str | None = None,
|
|
489
|
+
until: str | None = None,
|
|
490
|
+
user: str | None = None,
|
|
491
|
+
max_pages: int | None = None,
|
|
492
|
+
limit: int | None = None,
|
|
493
|
+
) -> CollectionResult:
|
|
494
|
+
_page_options(max_pages, limit)
|
|
495
|
+
since, until = _dates(since, until)
|
|
496
|
+
user = _handle(user) if user else None
|
|
497
|
+
if not keyword.strip():
|
|
498
|
+
raise ValueError("keyword cannot be empty")
|
|
499
|
+
if self._mirror is None:
|
|
500
|
+
if user is None:
|
|
501
|
+
raise APIError(
|
|
502
|
+
"Cross-user keyword search requires an explicit --mirror-base; "
|
|
503
|
+
"use --user for direct X search"
|
|
504
|
+
)
|
|
505
|
+
return self._save_direct_user_search(
|
|
506
|
+
keyword,
|
|
507
|
+
user,
|
|
508
|
+
output_dir,
|
|
509
|
+
since=since,
|
|
510
|
+
until=until,
|
|
511
|
+
max_pages=max_pages,
|
|
512
|
+
limit=limit,
|
|
513
|
+
)
|
|
514
|
+
mirror = self._mirror
|
|
515
|
+
query = {
|
|
516
|
+
"kind": "search_mirror",
|
|
517
|
+
"keyword": keyword,
|
|
518
|
+
"since": since,
|
|
519
|
+
"until": until,
|
|
520
|
+
"user": user,
|
|
521
|
+
"mirror": mirror.base_url,
|
|
522
|
+
}
|
|
523
|
+
store = PostStore(output_dir, query=query)
|
|
524
|
+
if store.complete:
|
|
525
|
+
return store.finish(complete=True, reason=store.state["reason"])
|
|
526
|
+
cursor = store.cursor or mirror.initial_url(
|
|
527
|
+
keyword, since=since, until=until, user=user
|
|
528
|
+
)
|
|
529
|
+
seen_cursors: set[str] = set()
|
|
530
|
+
pages_this_run = 0
|
|
531
|
+
while cursor:
|
|
532
|
+
if cursor in seen_cursors:
|
|
533
|
+
return store.finish(complete=False, reason="repeated_cursor")
|
|
534
|
+
seen_cursors.add(cursor)
|
|
535
|
+
try:
|
|
536
|
+
page = mirror.page(cursor)
|
|
537
|
+
posts = self._x.get_posts(page.post_ids)
|
|
538
|
+
except RateLimitError:
|
|
539
|
+
return store.finish(complete=False, reason="rate_limited")
|
|
540
|
+
missing = set(page.post_ids) - {post.id for post in posts}
|
|
541
|
+
if missing:
|
|
542
|
+
store.add_missing_ids(missing)
|
|
543
|
+
filtered = [
|
|
544
|
+
replace(
|
|
545
|
+
post,
|
|
546
|
+
discovery_source="nitter_search",
|
|
547
|
+
discovery_url=page.source_url,
|
|
548
|
+
)
|
|
549
|
+
for post in posts
|
|
550
|
+
if _within(post, since, until)
|
|
551
|
+
and (not user or post.author_handle.lower() == user.lower())
|
|
552
|
+
]
|
|
553
|
+
store.append_page(filtered, page.next_url)
|
|
554
|
+
pages_this_run += 1
|
|
555
|
+
if not page.next_url:
|
|
556
|
+
return store.finish(complete=True, reason="source_end")
|
|
557
|
+
if limit and store.count >= limit:
|
|
558
|
+
return store.finish(complete=False, reason="item_limit")
|
|
559
|
+
if max_pages and pages_this_run >= max_pages:
|
|
560
|
+
return store.finish(complete=False, reason="page_limit")
|
|
561
|
+
cursor = page.next_url
|
|
562
|
+
time.sleep(self.delay)
|
|
563
|
+
return store.finish(complete=True, reason="source_end")
|
|
564
|
+
|
|
565
|
+
def _save_direct_user_search(
|
|
566
|
+
self,
|
|
567
|
+
keyword: str,
|
|
568
|
+
user: str,
|
|
569
|
+
output_dir: str | Path,
|
|
570
|
+
*,
|
|
571
|
+
since: str | None,
|
|
572
|
+
until: str | None,
|
|
573
|
+
max_pages: int | None,
|
|
574
|
+
limit: int | None,
|
|
575
|
+
) -> CollectionResult:
|
|
576
|
+
output = Path(output_dir)
|
|
577
|
+
migrate_collection(output)
|
|
578
|
+
activity = self.save_user_activity(
|
|
579
|
+
user,
|
|
580
|
+
child_dir(output, "source"),
|
|
581
|
+
since=since,
|
|
582
|
+
until=until,
|
|
583
|
+
max_pages=max_pages,
|
|
584
|
+
)
|
|
585
|
+
store = PostStore(
|
|
586
|
+
output,
|
|
587
|
+
query={
|
|
588
|
+
"kind": "search_direct_user",
|
|
589
|
+
"keyword": keyword,
|
|
590
|
+
"user": user,
|
|
591
|
+
"since": since,
|
|
592
|
+
"until": until,
|
|
593
|
+
},
|
|
594
|
+
)
|
|
595
|
+
profile = self.get_user(user)
|
|
596
|
+
_atomic_json(
|
|
597
|
+
source_path(output, "profiles.json"), {profile.handle: profile.to_dict()}
|
|
598
|
+
)
|
|
599
|
+
if store.complete and activity.complete:
|
|
600
|
+
return store.finish(complete=True, reason="both_timelines_complete")
|
|
601
|
+
matches: list[Post] = []
|
|
602
|
+
needle = keyword.casefold()
|
|
603
|
+
for line in (
|
|
604
|
+
source_path(child_dir(output, "source"), "posts.jsonl")
|
|
605
|
+
.read_text(encoding="utf-8")
|
|
606
|
+
.split("\n")
|
|
607
|
+
):
|
|
608
|
+
if line.strip():
|
|
609
|
+
post = Post(**json.loads(line))
|
|
610
|
+
if needle in post.text.casefold():
|
|
611
|
+
matches.append(post)
|
|
612
|
+
if limit is not None:
|
|
613
|
+
matches = matches[: max(0, limit)]
|
|
614
|
+
store.append_page(matches, None)
|
|
615
|
+
store.state["pages_fetched"] = activity.pages_fetched
|
|
616
|
+
capped = limit is not None and len(matches) >= limit
|
|
617
|
+
return store.finish(
|
|
618
|
+
complete=activity.complete and not capped,
|
|
619
|
+
reason="item_limit" if capped else activity.reason,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def save_accounts(
|
|
623
|
+
self,
|
|
624
|
+
handles: list[str],
|
|
625
|
+
output_dir: str | Path,
|
|
626
|
+
*,
|
|
627
|
+
since: str,
|
|
628
|
+
until: str,
|
|
629
|
+
pages_per_round: int = 5,
|
|
630
|
+
rounds: int = 1,
|
|
631
|
+
wait_on_rate_limit: bool = False,
|
|
632
|
+
progress: Callable[[dict], None] | None = None,
|
|
633
|
+
) -> CollectionResult:
|
|
634
|
+
"""Collect several verified X accounts with resumable round-robin paging."""
|
|
635
|
+
from .batch import collect_accounts
|
|
636
|
+
|
|
637
|
+
normalized_since, normalized_until = _dates(since, until)
|
|
638
|
+
if normalized_since is None or normalized_until is None:
|
|
639
|
+
raise ValueError("Batch collection requires since and until dates")
|
|
640
|
+
canonical = [_handle(handle) for handle in handles]
|
|
641
|
+
return collect_accounts(
|
|
642
|
+
self,
|
|
643
|
+
canonical,
|
|
644
|
+
output_dir,
|
|
645
|
+
since=normalized_since,
|
|
646
|
+
until=normalized_until,
|
|
647
|
+
pages_per_round=pages_per_round,
|
|
648
|
+
rounds=rounds,
|
|
649
|
+
wait_on_rate_limit=wait_on_rate_limit,
|
|
650
|
+
progress=progress,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def iter_user_replies(
|
|
654
|
+
self,
|
|
655
|
+
handle: str,
|
|
656
|
+
*,
|
|
657
|
+
since: str | None = None,
|
|
658
|
+
until: str | None = None,
|
|
659
|
+
max_pages: int | None = None,
|
|
660
|
+
limit: int | None = None,
|
|
661
|
+
) -> Iterator[Post]:
|
|
662
|
+
"""Stream replies authored by this account, not replies received by it."""
|
|
663
|
+
return self.iter_user_posts(
|
|
664
|
+
handle,
|
|
665
|
+
timeline="replies",
|
|
666
|
+
since=since,
|
|
667
|
+
until=until,
|
|
668
|
+
max_pages=max_pages,
|
|
669
|
+
limit=limit,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
def get_user_replies(
|
|
673
|
+
self,
|
|
674
|
+
handle: str,
|
|
675
|
+
*,
|
|
676
|
+
since: str | None = None,
|
|
677
|
+
until: str | None = None,
|
|
678
|
+
max_pages: int | None = None,
|
|
679
|
+
limit: int | None = None,
|
|
680
|
+
) -> list[Post]:
|
|
681
|
+
"""Return replies authored by this account in memory."""
|
|
682
|
+
return list(
|
|
683
|
+
self.iter_user_replies(
|
|
684
|
+
handle, since=since, until=until, max_pages=max_pages, limit=limit
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def save_user_replies(
|
|
689
|
+
self,
|
|
690
|
+
handle: str,
|
|
691
|
+
output_dir: str | Path,
|
|
692
|
+
*,
|
|
693
|
+
since: str | None = None,
|
|
694
|
+
until: str | None = None,
|
|
695
|
+
max_pages: int | None = None,
|
|
696
|
+
limit: int | None = None,
|
|
697
|
+
) -> CollectionResult:
|
|
698
|
+
"""Save this account's reply timeline to the standard dataset layout."""
|
|
699
|
+
return self.save_user_posts(
|
|
700
|
+
handle,
|
|
701
|
+
output_dir,
|
|
702
|
+
timeline="replies",
|
|
703
|
+
since=since,
|
|
704
|
+
until=until,
|
|
705
|
+
max_pages=max_pages,
|
|
706
|
+
limit=limit,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
def iter_search_posts(
|
|
710
|
+
self,
|
|
711
|
+
keyword: str,
|
|
712
|
+
*,
|
|
713
|
+
handle: str | None = None,
|
|
714
|
+
since: str | None = None,
|
|
715
|
+
until: str | None = None,
|
|
716
|
+
max_pages: int | None = None,
|
|
717
|
+
limit: int | None = None,
|
|
718
|
+
) -> Iterator[Post]:
|
|
719
|
+
"""Stream matched posts/replies; handle selects direct account search."""
|
|
720
|
+
return self.iter_search(
|
|
721
|
+
keyword,
|
|
722
|
+
user=handle,
|
|
723
|
+
since=since,
|
|
724
|
+
until=until,
|
|
725
|
+
max_pages=max_pages,
|
|
726
|
+
limit=limit,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
def get_search_posts(
|
|
730
|
+
self,
|
|
731
|
+
keyword: str,
|
|
732
|
+
*,
|
|
733
|
+
handle: str | None = None,
|
|
734
|
+
since: str | None = None,
|
|
735
|
+
until: str | None = None,
|
|
736
|
+
max_pages: int | None = None,
|
|
737
|
+
limit: int | None = None,
|
|
738
|
+
) -> list[Post]:
|
|
739
|
+
"""Return search matches in memory using the same options as iter/save."""
|
|
740
|
+
return list(
|
|
741
|
+
self.iter_search_posts(
|
|
742
|
+
keyword,
|
|
743
|
+
handle=handle,
|
|
744
|
+
since=since,
|
|
745
|
+
until=until,
|
|
746
|
+
max_pages=max_pages,
|
|
747
|
+
limit=limit,
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
def save_search_posts(
|
|
752
|
+
self,
|
|
753
|
+
keyword: str,
|
|
754
|
+
output_dir: str | Path,
|
|
755
|
+
*,
|
|
756
|
+
handle: str | None = None,
|
|
757
|
+
since: str | None = None,
|
|
758
|
+
until: str | None = None,
|
|
759
|
+
max_pages: int | None = None,
|
|
760
|
+
limit: int | None = None,
|
|
761
|
+
) -> CollectionResult:
|
|
762
|
+
"""Save search matches and resumable source streams."""
|
|
763
|
+
return self.save_search(
|
|
764
|
+
keyword,
|
|
765
|
+
output_dir,
|
|
766
|
+
user=handle,
|
|
767
|
+
since=since,
|
|
768
|
+
until=until,
|
|
769
|
+
max_pages=max_pages,
|
|
770
|
+
limit=limit,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
def save_users_activity(
|
|
774
|
+
self,
|
|
775
|
+
handles: list[str],
|
|
776
|
+
output_dir: str | Path,
|
|
777
|
+
*,
|
|
778
|
+
since: str,
|
|
779
|
+
until: str,
|
|
780
|
+
pages_per_round: int = 5,
|
|
781
|
+
rounds: int = 1,
|
|
782
|
+
wait_on_rate_limit: bool = False,
|
|
783
|
+
progress: Callable[[dict], None] | None = None,
|
|
784
|
+
) -> CollectionResult:
|
|
785
|
+
"""Save multiple accounts' authored main posts and replies with checkpoints."""
|
|
786
|
+
return self.save_accounts(
|
|
787
|
+
handles,
|
|
788
|
+
output_dir,
|
|
789
|
+
since=since,
|
|
790
|
+
until=until,
|
|
791
|
+
pages_per_round=pages_per_round,
|
|
792
|
+
rounds=rounds,
|
|
793
|
+
wait_on_rate_limit=wait_on_rate_limit,
|
|
794
|
+
progress=progress,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def iter_post_comments(
|
|
798
|
+
self,
|
|
799
|
+
post_id_or_url: str,
|
|
800
|
+
*,
|
|
801
|
+
max_depth: int = 2,
|
|
802
|
+
max_comments: int | None = 100,
|
|
803
|
+
max_pages: int | None = 20,
|
|
804
|
+
) -> Iterator[Post]:
|
|
805
|
+
"""Stream other-user and self replies, bounded by depth and total comments."""
|
|
806
|
+
from .comments import CommentTraversal
|
|
807
|
+
|
|
808
|
+
traversal = CommentTraversal(
|
|
809
|
+
self,
|
|
810
|
+
_post_id(post_id_or_url),
|
|
811
|
+
max_depth=max_depth,
|
|
812
|
+
max_comments=max_comments,
|
|
813
|
+
max_pages=max_pages,
|
|
814
|
+
)
|
|
815
|
+
for page in traversal.pages():
|
|
816
|
+
yield from (post for post in page if post.in_reply_to_id)
|
|
817
|
+
if traversal.reason == "rate_limited":
|
|
818
|
+
raise RateLimitError(
|
|
819
|
+
"X rate-limited comment collection",
|
|
820
|
+
traversal.state.get("rate_reset_at"),
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
def get_post_comments(
|
|
824
|
+
self,
|
|
825
|
+
post_id_or_url: str,
|
|
826
|
+
*,
|
|
827
|
+
max_depth: int = 2,
|
|
828
|
+
max_comments: int | None = 100,
|
|
829
|
+
max_pages: int | None = 20,
|
|
830
|
+
) -> list[Post]:
|
|
831
|
+
"""Return observed replies to one main post, including multiple levels."""
|
|
832
|
+
return list(
|
|
833
|
+
self.iter_post_comments(
|
|
834
|
+
post_id_or_url,
|
|
835
|
+
max_depth=max_depth,
|
|
836
|
+
max_comments=max_comments,
|
|
837
|
+
max_pages=max_pages,
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
def save_post_comments(
|
|
842
|
+
self,
|
|
843
|
+
post_id_or_url: str,
|
|
844
|
+
output_dir: str | Path,
|
|
845
|
+
*,
|
|
846
|
+
max_depth: int = 2,
|
|
847
|
+
max_comments: int | None = 100,
|
|
848
|
+
max_pages: int | None = 20,
|
|
849
|
+
) -> CollectionResult:
|
|
850
|
+
"""Save a root post, its reply tree and observed author profiles; resume safely."""
|
|
851
|
+
from .comments import CommentTraversal
|
|
852
|
+
|
|
853
|
+
root_id = _post_id(post_id_or_url)
|
|
854
|
+
# Validate bounds before creating or migrating output.
|
|
855
|
+
CommentTraversal(
|
|
856
|
+
self,
|
|
857
|
+
root_id,
|
|
858
|
+
max_depth=max_depth,
|
|
859
|
+
max_comments=max_comments,
|
|
860
|
+
max_pages=max_pages,
|
|
861
|
+
)
|
|
862
|
+
store = PostStore(
|
|
863
|
+
output_dir,
|
|
864
|
+
query={
|
|
865
|
+
"kind": "post_comments",
|
|
866
|
+
"root_post_id": root_id,
|
|
867
|
+
"max_depth": max_depth,
|
|
868
|
+
},
|
|
869
|
+
)
|
|
870
|
+
if store.complete:
|
|
871
|
+
return store.finish(complete=True, reason=store.state["reason"])
|
|
872
|
+
traversal = CommentTraversal(
|
|
873
|
+
self,
|
|
874
|
+
root_id,
|
|
875
|
+
max_depth=max_depth,
|
|
876
|
+
max_comments=max_comments,
|
|
877
|
+
max_pages=max_pages,
|
|
878
|
+
records=store._posts,
|
|
879
|
+
state=store.state.setdefault("conversation", {}),
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
def save_profiles():
|
|
883
|
+
author_ids = {post.author_id for post in traversal.records.values()}
|
|
884
|
+
profiles = {
|
|
885
|
+
handle: profile
|
|
886
|
+
for handle, profile in traversal.state["profiles"].items()
|
|
887
|
+
if profile["id"] in author_ids
|
|
888
|
+
}
|
|
889
|
+
_atomic_json(source_path(output_dir, "profiles.json"), profiles)
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
for page in traversal.pages():
|
|
893
|
+
save_profiles()
|
|
894
|
+
store.append_page(page, None, count_page=traversal.page_was_fetched)
|
|
895
|
+
except Exception:
|
|
896
|
+
save_profiles()
|
|
897
|
+
store.finish(complete=False, reason="interrupted_or_error")
|
|
898
|
+
raise
|
|
899
|
+
save_profiles()
|
|
900
|
+
return store.finish(complete=traversal.complete, reason=traversal.reason)
|