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/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)