pyXcom 1.2.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (113) hide show
  1. {pyxcom-1.2.0 → pyxcom-1.3.0}/CHANGELOG.md +24 -0
  2. {pyxcom-1.2.0 → pyxcom-1.3.0}/CITATION.cff +2 -2
  3. {pyxcom-1.2.0 → pyxcom-1.3.0}/PKG-INFO +20 -2
  4. {pyxcom-1.2.0 → pyxcom-1.3.0}/README.md +19 -1
  5. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/api.md +37 -1
  6. pyxcom-1.3.0/docs/comment-ancestry.md +94 -0
  7. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/pool.md +1 -1
  8. {pyxcom-1.2.0 → pyxcom-1.3.0}/pyproject.toml +1 -1
  9. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/__init__.py +1 -1
  10. pyxcom-1.3.0/src/pyxcom/ancestry.py +286 -0
  11. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/cli.py +2 -0
  12. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/client.py +24 -1
  13. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/comments.py +106 -2
  14. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/pool.py +1 -0
  15. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/tables.py +6 -0
  16. pyxcom-1.3.0/tests/test_comment_ancestry.py +294 -0
  17. pyxcom-1.3.0/tests/test_comment_stalls.py +258 -0
  18. {pyxcom-1.2.0 → pyxcom-1.3.0}/.gitignore +0 -0
  19. {pyxcom-1.2.0 → pyxcom-1.3.0}/LICENSE +0 -0
  20. {pyxcom-1.2.0 → pyxcom-1.3.0}/NOTICE +0 -0
  21. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/LIVE_RESULTS.md +0 -0
  22. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/README.md +0 -0
  23. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/REPLAY_RESULTS.md +0 -0
  24. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/STORAGE_RESULTS.md +0 -0
  25. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/common.py +0 -0
  26. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/environment-lock.txt +0 -0
  27. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/live_arm.py +0 -0
  28. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/live_compare.py +0 -0
  29. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/live_pool.py +0 -0
  30. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/metric-batch-results.json +0 -0
  31. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/metric_batch.py +0 -0
  32. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/normalize.py +0 -0
  33. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/pool_summary.py +0 -0
  34. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/replay.py +0 -0
  35. {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/requirements.txt +0 -0
  36. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/datasets.md +0 -0
  37. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/discovery-content.md +0 -0
  38. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/profile-observations.md +0 -0
  39. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/publishing.md +0 -0
  40. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/recovery.md +0 -0
  41. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/release-1.0-plan.md +0 -0
  42. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/release-1.0-validation.md +0 -0
  43. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/stability-plan.md +0 -0
  44. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/stability.md +0 -0
  45. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/timeline-pagination-plan.md +0 -0
  46. {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/timeline-pagination.md +0 -0
  47. {pyxcom-1.2.0 → pyxcom-1.3.0}/examples/basic.py +0 -0
  48. {pyxcom-1.2.0 → pyxcom-1.3.0}/examples/research_networks.py +0 -0
  49. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/_persistence.py +0 -0
  50. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/_pool_locks.py +0 -0
  51. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/assets.py +0 -0
  52. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/auth.py +0 -0
  53. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/batch.py +0 -0
  54. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/datasets.py +0 -0
  55. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/discovery.py +0 -0
  56. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/errors.py +0 -0
  57. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/integrity.py +0 -0
  58. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/layout.py +0 -0
  59. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/models.py +0 -0
  60. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/networks.py +0 -0
  61. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/parse.py +0 -0
  62. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/profiles.py +0 -0
  63. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/provenance.py +0 -0
  64. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/recovery.py +0 -0
  65. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/schema.py +0 -0
  66. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/search.py +0 -0
  67. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/storage.py +0 -0
  68. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/transport.py +0 -0
  69. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/validate.py +0 -0
  70. {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/validate_tables.py +0 -0
  71. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_api_interruption.py +0 -0
  72. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_assets.py +0 -0
  73. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_auth.py +0 -0
  74. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_batch_report.py +0 -0
  75. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_benchmark_harness.py +0 -0
  76. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_benchmark_replay.py +0 -0
  77. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_capability_cli.py +0 -0
  78. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_capability_transport.py +0 -0
  79. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_comment_transport.py +0 -0
  80. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_comments.py +0 -0
  81. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_datasets.py +0 -0
  82. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_direct_search.py +0 -0
  83. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_discovery.py +0 -0
  84. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_discovery_content.py +0 -0
  85. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_discovery_pagination.py +0 -0
  86. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_integrity.py +0 -0
  87. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_layout.py +0 -0
  88. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_metric_batch.py +0 -0
  89. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_networks.py +0 -0
  90. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_observations.py +0 -0
  91. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_parsing.py +0 -0
  92. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_persistence.py +0 -0
  93. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool.py +0 -0
  94. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool_cli.py +0 -0
  95. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool_locks.py +0 -0
  96. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool_provenance.py +0 -0
  97. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_profile_batch.py +0 -0
  98. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_profile_history.py +0 -0
  99. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_project_metadata.py +0 -0
  100. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_public_api.py +0 -0
  101. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_recovery.py +0 -0
  102. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_recovery_cli.py +0 -0
  103. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_relational_integration.py +0 -0
  104. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_request_control.py +0 -0
  105. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_schema.py +0 -0
  106. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_search_scope.py +0 -0
  107. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_search_storage.py +0 -0
  108. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_session_identity.py +0 -0
  109. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_tables.py +0 -0
  110. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_timeline_pagination.py +0 -0
  111. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_transport.py +0 -0
  112. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_validate_date_scope.py +0 -0
  113. {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_validation_shapes.py +0 -0
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 — 2026-10-13
4
+
5
+ ### Added
6
+
7
+ - `XClient.save_comment_ancestry()` verifies missing comment parents through bounded,
8
+ deduplicated exact-ID lookups, using the source observer when bound.
9
+ - Write a separate ancestry supplement with verified seed-relative/platform depths,
10
+ explicit unresolved statuses, context-only parents and source/output hashes.
11
+ - Preserve original sampling roles, files, cursors and partial status; stop further
12
+ lookups on rate limits or request errors without rotating accounts.
13
+
14
+ No new runtime dependencies or changes to existing comment collection defaults.
15
+
16
+ ## 1.2.1 — 2026-10-13
17
+
18
+ ### Fixed
19
+
20
+ - Retain comment cursors paused by the empty-page guard and support explicit
21
+ `retry_stalled=True` through the saver, CLI and pool resume.
22
+ - Recover older discarded cursors only from hash-verified archives bound to the
23
+ last committed focal/cursor request, including after an ordinary resume.
24
+ - Keep historical pagination warnings while reporting still-stalled and
25
+ unrecoverable branches separately; bounded retries may remain partial.
26
+
3
27
  ## 1.2.0 — 2026-10-12
4
28
 
5
29
  ### Added
@@ -6,8 +6,8 @@ authors:
6
6
  - family-names: Wang
7
7
  given-names: Haocheng
8
8
  affiliation: Communication University of China
9
- version: 1.2.0
10
- date-released: 2026-10-12
9
+ version: 1.3.0
10
+ date-released: 2026-10-13
11
11
  license: Apache-2.0
12
12
  repository-code: "https://github.com/haochengw372-hash/pyXcom"
13
13
  url: "https://github.com/haochengw372-hash/pyXcom"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyXcom
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Collect public X posts, search results, conversations, reposts, and observed follow networks with your browser session
5
5
  Project-URL: Homepage, https://github.com/haochengw372-hash/pyXcom
6
6
  Project-URL: Repository, https://github.com/haochengw372-hash/pyXcom
@@ -193,6 +193,24 @@ Repeat the same seed, depth, dates and output directory to resume. Raise the com
193
193
 
194
194
  For a main-post seed, the root enters `posts.csv` and descendants enter `comments.csv`. A reply seed remains in `comments.csv`. `seed_post_id` and `seed_relative_depth` describe the requested branch; platform root, parent and depth fields describe the retained ancestry. Unknown ancestry stays unresolved. `get_post_comments()` and `iter_post_comments()` return descendants without writing a dataset.
195
195
 
196
+ When a saved tree contains pending replies with missing parents, you can verify
197
+ their exact parent chains in a separate supplement:
198
+
199
+ ```python
200
+ with XClient(profile="Default") as client:
201
+ report = client.save_comment_ancestry(
202
+ "data/conversation", "data/conversation_ancestry", lookup_limit=20,
203
+ )
204
+ ```
205
+
206
+ This reads a verified `post_comments` collection and looks up missing parent IDs
207
+ using the same observer when the source has a pool identity binding. It writes
208
+ `resolution.csv`, context-only `ancestor_posts.jsonl`, the original pending
209
+ records, raw responses and a hash manifest into a new directory. Source files,
210
+ sampling roles and completeness remain unchanged. The limit counts lookup calls;
211
+ identity checks and transport retries are additional HTTP attempts. See
212
+ [comment ancestry](docs/comment-ancestry.md) for field definitions and unresolved cases.
213
+
196
214
  ### Collect several accounts
197
215
 
198
216
  One client can collect several target accounts using the same authenticated browser session:
@@ -411,7 +429,7 @@ These are offline development checks. They do not collect new X data.
411
429
 
412
430
  Created by **Haocheng Wang, Communication University of China**.
413
431
 
414
- [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples. pyXcom is an independent implementation and does not depend on PykTok or twikit.
432
+ [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples.
415
433
 
416
434
  ## Citation
417
435
 
@@ -174,6 +174,24 @@ Repeat the same seed, depth, dates and output directory to resume. Raise the com
174
174
 
175
175
  For a main-post seed, the root enters `posts.csv` and descendants enter `comments.csv`. A reply seed remains in `comments.csv`. `seed_post_id` and `seed_relative_depth` describe the requested branch; platform root, parent and depth fields describe the retained ancestry. Unknown ancestry stays unresolved. `get_post_comments()` and `iter_post_comments()` return descendants without writing a dataset.
176
176
 
177
+ When a saved tree contains pending replies with missing parents, you can verify
178
+ their exact parent chains in a separate supplement:
179
+
180
+ ```python
181
+ with XClient(profile="Default") as client:
182
+ report = client.save_comment_ancestry(
183
+ "data/conversation", "data/conversation_ancestry", lookup_limit=20,
184
+ )
185
+ ```
186
+
187
+ This reads a verified `post_comments` collection and looks up missing parent IDs
188
+ using the same observer when the source has a pool identity binding. It writes
189
+ `resolution.csv`, context-only `ancestor_posts.jsonl`, the original pending
190
+ records, raw responses and a hash manifest into a new directory. Source files,
191
+ sampling roles and completeness remain unchanged. The limit counts lookup calls;
192
+ identity checks and transport retries are additional HTTP attempts. See
193
+ [comment ancestry](docs/comment-ancestry.md) for field definitions and unresolved cases.
194
+
177
195
  ### Collect several accounts
178
196
 
179
197
  One client can collect several target accounts using the same authenticated browser session:
@@ -392,7 +410,7 @@ These are offline development checks. They do not collect new X data.
392
410
 
393
411
  Created by **Haocheng Wang, Communication University of China**.
394
412
 
395
- [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples. pyXcom is an independent implementation and does not depend on PykTok or twikit.
413
+ [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples.
396
414
 
397
415
  ## Citation
398
416
 
@@ -1,4 +1,4 @@
1
- # Public API — pyXcom 1.2.0
1
+ # Public API — pyXcom 1.2.1
2
2
 
3
3
  The native discovery and network methods have passed offline tests and bounded authenticated live endpoint tests. These tests verify the exercised acquisition paths, not complete historical or network coverage.
4
4
 
@@ -81,6 +81,42 @@ Traversal depth is relative to the seed (direct replies=1), recorded as `seed_po
81
81
 
82
82
  For offline validation of `post_comments`, UTC date scope applies to analysis descendants, while the seed and explicitly marked context can lie outside it. These retained records still participate in identifier, relationship and artifact-integrity checks. The exemption does not apply to other collection types; a descendant missing `observation_role` is treated as analysis for this date check.
83
83
 
84
+ ### Retry a paused comment branch
85
+
86
+ `save_post_comments(..., retry_stalled=True)` explicitly requeues cursors saved
87
+ by the empty-page guard. Ordinary resume keeps these branches paused. Repeated
88
+ cursor warnings and unresolved ancestors are still reported as partial.
89
+
90
+ For older collections, an empty-page cursor is recovered only from a hash-checked
91
+ raw response whose focal ID and request cursor match the last committed request.
92
+ Missing or mismatched evidence remains unresolved; no cursor or tree depth is guessed.
93
+
94
+ ```python
95
+ result = client.save_post_comments(
96
+ post_id, output_dir, max_depth=3, max_comments=100,
97
+ max_pages=2, retry_stalled=True,
98
+ )
99
+ ```
100
+
101
+ ### Verify missing comment ancestors
102
+
103
+ ```python
104
+ save_comment_ancestry(source_dir: str | Path, output_dir: str | Path, *,
105
+ lookup_limit: int = 20) -> dict
106
+ ```
107
+
108
+ Reads a verified saved `post_comments` source and writes a new, separate ancestry
109
+ supplement. Source scopes and sampling roles are immutable. Exact-parent lookups
110
+ are bounded and deduplicated; known source observers must match the current login.
111
+ `lookup_limit=0` makes no network calls. A resolved chain establishes descent from
112
+ the requested seed; it does not establish exhaustive collection. See
113
+ [comment ancestry](comment-ancestry.md) for outputs, depth definitions and failure handling.
114
+
115
+ The manifest distinguishes queued requests, stalled requests, active warnings,
116
+ and historical warnings. A retry can still return a partial result when the
117
+ platform keeps returning empty pages. CLI: `pyxcom post-comments ID --output-dir
118
+ DIR --retry-stalled`. Pool: `resume_job(job_id, retry_stalled=True)`.
119
+
84
120
  ## Keyword search
85
121
 
86
122
  ```python
@@ -0,0 +1,94 @@
1
+ # Verifying missing comment ancestors
2
+
3
+ X can return a child reply while omitting its parent. A saved `post_comments`
4
+ collection keeps such records in its private pending state, rather than assigning
5
+ them a guessed depth. `XClient.save_comment_ancestry()` verifies those parent chains
6
+ with bounded single-post lookups and writes a separate supplement.
7
+
8
+ ```python
9
+ from pyxcom import XClient
10
+
11
+ # Sign in manually before running. Select the original collection's observer.
12
+ with XClient(profile="Default") as client:
13
+ report = client.save_comment_ancestry(
14
+ "data/conversation", "data/conversation_ancestry", lookup_limit=20,
15
+ )
16
+ print(report["resolved_records"], report["unresolved_records"])
17
+ ```
18
+
19
+ The source must pass retained-record and checkpoint integrity verification and
20
+ must be a `post_comments` collection. Both scopes are locked; overlapping paths,
21
+ existing destinations and symlink source contents are rejected. Source files,
22
+ cursors, roles, counts and partial status remain unchanged.
23
+
24
+ ## Lookup rules
25
+
26
+ Only a record's explicit `in_reply_to_id` is requested. Each missing ID is looked
27
+ up once per invocation, including failed lookups, and shared ancestors reuse the
28
+ result. The returned ID and conversation must agree with the source. A chain is
29
+ resolved only when it reaches the exact requested seed, without a cycle. Other
30
+ branches of a reply-seeded conversation are not admitted.
31
+
32
+ Before the first lookup, the client verifies its current identity. If the source
33
+ has a pool observer binding, that identity must match; a mismatch raises before
34
+ parent content is requested. Older unbound sources remain explicitly unbound in
35
+ the manifest. No observer is inferred retroactively and no account rotation occurs.
36
+
37
+ `lookup_limit` is a nonnegative integer limiting single-post lookup calls, not
38
+ HTTP attempts. Identity checks, route discovery and the existing transport's
39
+ retries can add HTTP attempts. Zero makes no network calls. Calls respect client
40
+ delay; a rate limit or request error stops further parent lookups in that run.
41
+
42
+ ## Outputs
43
+
44
+ | File | Contents |
45
+ | --- | --- |
46
+ | `resolution.csv` | One row per original pending record, verified links, depth and scope flag |
47
+ | `ancestor_posts.jsonl` | Newly fetched parents, all `observation_role="context"` |
48
+ | `pending_posts.jsonl` | Copy of the original pending records |
49
+ | `raw/<parent_id>.json` | Credential-free successful lookup payloads, including unavailable results |
50
+ | `manifest.json` | Source and supplement hashes, caps, counts, observer checks and source completeness |
51
+
52
+ `seed_post_id` is the requested collection seed; `root_post_id` is the platform
53
+ conversation root. `chain_ids` lists verified retained IDs from the pending record
54
+ back toward the seed; an unresolved chain does not include the unreturned parent.
55
+ `seed_relative_depth` counts links to the seed and is blank until that chain is
56
+ resolved. `depth` counts links to the platform root and also requires retained
57
+ evidence between a reply seed and that root; otherwise it stays blank. This method
58
+ does not make extra requests above a reply seed just to fill platform depth.
59
+
60
+ `within_requested_bounds` checks the pending record's verified seed-relative depth
61
+ and the original UTC date bounds (inclusive `since`, exclusive `until`). It is blank
62
+ for unresolved chains. A true flag is not automatic sample admission: supplement
63
+ records never alter the original `posts.csv` or `comments.csv`.
64
+
65
+ Statuses include `resolved`, `lookup_budget`, `parent_not_returned`, `rate_limited`,
66
+ `request_error`, `identity_conflict`, `conversation_conflict`, `outside_seed_branch`,
67
+ `cycle` and `protected_parent`. A successful response without a parseable parent
68
+ is `parent_not_returned`; that does not establish deletion, protection or absence
69
+ on the platform. Explicitly protected parent content is not retained or archived.
70
+
71
+ Each invocation uses a new output directory. There is no supplement resume or
72
+ automatic merging into the source. Output is published after traversal; a process
73
+ termination before publication can lose that run's in-memory supplement. Retain
74
+ the original source and compare dated supplements explicitly. The manifest's
75
+ `collection_complete` and `source_collection_reason` copy the original collection's
76
+ status, even if every pending chain has now been resolved.
77
+
78
+ ## Recorded validation
79
+
80
+ On October 13, 2026, two retained personal-agent discussion trees contained three
81
+ pending replies. With their original authenticated observers, three parent lookup
82
+ calls resolved two chains in the first tree; the second tree's parent did not
83
+ return a parseable record. Source file inventories and hashes were unchanged.
84
+
85
+ The resolved replies had depths two and four. The source depth bound was three,
86
+ so only the depth-two record passed the depth/date scope flag. This illustrates why
87
+ resolving ancestry and admitting a reply to a research sample are separate steps.
88
+ Both original collections remained partial. These are recorded results, not a
89
+ coverage benchmark; later requests may return different content.
90
+
91
+ Eighteen focused offline regression tests cover exact chains, shared parents,
92
+ reply-seed boundaries, cycles, budgets, source integrity, identity mismatch,
93
+ malformed responses and protected content. The complete suite contained 454
94
+ passing tests at this release.
@@ -83,7 +83,7 @@ X's quota implementation. Requests already in flight cannot be recalled.
83
83
 
84
84
  Reopen the same `state_dir` with the same login IDs and call `run` again. Re-adding
85
85
  an identical job is harmless; changing its query, scope, or observer is rejected.
86
- Use `resume_job(job_id)` for a paused job. Explicit discovery-stall retries use
86
+ Use `resume_job(job_id)` for a paused job. Explicit discovery or comment empty-page retries use
87
87
  `resume_job(job_id, retry_stalled=True)` where the underlying saver supports it.
88
88
 
89
89
  Adopting existing output requires both a pinned `login_id` and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyXcom"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Collect public X posts, search results, conversations, reposts, and observed follow networks with your browser session"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -50,4 +50,4 @@ __all__ = [
50
50
  "validate_collection",
51
51
  "validate_network_collection",
52
52
  ]
53
- __version__ = "1.2.0"
53
+ __version__ = "1.3.0"
@@ -0,0 +1,286 @@
1
+ """Copy-only, bounded verification of missing comment ancestors."""
2
+
3
+ import csv
4
+ import json
5
+ import time
6
+ from contextlib import ExitStack
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ._persistence import append_jsonl, atomic_json, read_jsonl
12
+ from ._pool_locks import ScopeLock
13
+ from .errors import IntegrityError, ParseError, PyXcomError, RateLimitError
14
+ from .integrity import assess_recovery
15
+ from .models import Post
16
+ from .parse import single_post
17
+ from .recovery import _files
18
+ from .transport import now_utc
19
+
20
+ if TYPE_CHECKING:
21
+ from .client import XClient
22
+
23
+
24
+ def _directory(value: str | Path) -> Path:
25
+ path = Path(value).expanduser()
26
+ if path.is_symlink():
27
+ raise ValueError("Ancestry directory must not be a symbolic link")
28
+ return path.resolve()
29
+
30
+
31
+ def _mapping(value) -> dict:
32
+ return value if isinstance(value, dict) else {}
33
+
34
+
35
+ def _protected(payload: dict) -> bool:
36
+ result = _mapping(
37
+ _mapping(_mapping(payload.get("data")).get("tweetResult")).get("result")
38
+ )
39
+ tweet = _mapping(result.get("tweet", result))
40
+ user = _mapping(
41
+ _mapping(_mapping(tweet.get("core")).get("user_results")).get("result")
42
+ )
43
+ return any(
44
+ _mapping(user.get(key)).get("protected") is True
45
+ for key in ("privacy", "legacy")
46
+ )
47
+
48
+
49
+ def save_comment_ancestry(
50
+ client: "XClient",
51
+ source_dir: str | Path,
52
+ output_dir: str | Path,
53
+ *,
54
+ lookup_limit: int = 20,
55
+ ) -> dict:
56
+ """Verify exact parent chains without admitting records to the original sample.
57
+
58
+ The cap counts single-post lookup calls, not transport retry attempts. A new
59
+ supplement is required for every run; source files are read-only throughout.
60
+ """
61
+ if (
62
+ isinstance(lookup_limit, bool)
63
+ or not isinstance(lookup_limit, int)
64
+ or lookup_limit < 0
65
+ ):
66
+ raise ValueError("lookup_limit must be a nonnegative integer")
67
+ source, target = _directory(source_dir), _directory(output_dir)
68
+ if source == target or source in target.parents or target in source.parents:
69
+ raise ValueError("Source and supplement scopes must not overlap")
70
+ with ExitStack() as stack:
71
+ for scope in sorted((source, target)):
72
+ stack.enter_context(ScopeLock(scope))
73
+ if target.exists():
74
+ raise FileExistsError("Ancestry supplement already exists")
75
+ assessment = assess_recovery(source)
76
+ if not assessment["allowed"] or assessment["record_integrity"] != "verified":
77
+ raise IntegrityError(
78
+ "Source collection did not pass integrity verification"
79
+ )
80
+ query = assessment["query"]
81
+ if query.get("kind") != "post_comments":
82
+ raise ValueError(
83
+ "Ancestry verification requires a post_comments collection"
84
+ )
85
+ state = json.loads((source / ".pyxcom/state.json").read_text(encoding="utf-8"))
86
+ conversation = state["conversation"]
87
+ pending = conversation.get("pending", {})
88
+ posts = {p["id"]: Post(**p) for p in read_jsonl(source / ".pyxcom/posts.jsonl")}
89
+ posts.update({key: Post(**value) for key, value in pending.items()})
90
+ root = str(query["root_post_id"])
91
+ conversation_id = str(conversation.get("conversation_id", root))
92
+ owner_path = source / ".pyxcom/pool-owner.json"
93
+ owner = (
94
+ json.loads(owner_path.read_text(encoding="utf-8"))
95
+ if owner_path.exists()
96
+ else {}
97
+ )
98
+ expected_observer = owner.get("observer_id")
99
+ verified_observer = None
100
+ fetched: dict[str, Post] = {}
101
+ failures: dict[str, str] = {}
102
+ raw_responses: dict[str, dict] = {}
103
+ calls = 0
104
+ stop_reason = None
105
+
106
+ def lookup(identifier: str) -> tuple[Post | None, str | None]:
107
+ nonlocal calls, stop_reason, verified_observer
108
+ if identifier in failures:
109
+ return None, failures[identifier]
110
+ if stop_reason:
111
+ return None, stop_reason
112
+ if calls >= lookup_limit:
113
+ return None, "lookup_budget"
114
+ if verified_observer is None:
115
+ verified_observer = str(client.check_login_identity())
116
+ if expected_observer is not None and verified_observer != str(
117
+ expected_observer
118
+ ):
119
+ raise ValueError(
120
+ "Ancestry lookup observer differs from source observer"
121
+ )
122
+ if calls:
123
+ time.sleep(client.delay)
124
+ calls += 1
125
+ payload = None
126
+ try:
127
+ payload = client.get_raw_post(identifier)
128
+ if _protected(payload):
129
+ failures[identifier] = "protected_parent"
130
+ return None, failures[identifier]
131
+ raw_responses[identifier] = payload
132
+ try:
133
+ post = single_post(payload, captured_at_utc=now_utc())
134
+ except (ParseError, ValueError, TypeError, KeyError, AttributeError):
135
+ failures[identifier] = "parent_not_returned"
136
+ return None, failures[identifier]
137
+ if post.id != identifier:
138
+ failures[identifier] = "identity_conflict"
139
+ elif post.conversation_id != conversation_id or post.reposted_post_id:
140
+ failures[identifier] = "conversation_conflict"
141
+ else:
142
+ post = replace(
143
+ post, observation_role="context", discovery_source="x_ancestor"
144
+ )
145
+ posts[identifier] = fetched[identifier] = post
146
+ return post, None
147
+ except ParseError:
148
+ stop_reason = failures[identifier] = "request_error"
149
+ except RateLimitError:
150
+ stop_reason = failures[identifier] = "rate_limited"
151
+ except PyXcomError:
152
+ stop_reason = failures[identifier] = "request_error"
153
+ return None, failures[identifier]
154
+
155
+ def platform_depth(seed_depth: int | None) -> int | None:
156
+ if seed_depth is None:
157
+ return None
158
+ current = posts[root]
159
+ seen = {root}
160
+ offset = 0
161
+ while current.id != conversation_id:
162
+ parent = posts.get(current.in_reply_to_id or "")
163
+ if (
164
+ parent is None
165
+ or parent.id in seen
166
+ or parent.conversation_id != conversation_id
167
+ ):
168
+ return None
169
+ seen.add(parent.id)
170
+ offset += 1
171
+ current = parent
172
+ return seed_depth + offset
173
+
174
+ rows = []
175
+ for identifier, value in pending.items():
176
+ post = posts[identifier]
177
+ chain = [identifier]
178
+ current = post
179
+ status = "resolved"
180
+ while current.id != root:
181
+ if (
182
+ current.conversation_id != conversation_id
183
+ or current.reposted_post_id
184
+ ):
185
+ status = "conversation_conflict"
186
+ break
187
+ parent_id = current.in_reply_to_id
188
+ if parent_id is None or (
189
+ parent_id == conversation_id and conversation_id != root
190
+ ):
191
+ status = "outside_seed_branch"
192
+ break
193
+ if parent_id in chain:
194
+ status = "cycle"
195
+ break
196
+ parent = posts.get(parent_id)
197
+ if parent is None:
198
+ parent, failure = lookup(parent_id)
199
+ if parent is None:
200
+ status = failure or "parent_not_returned"
201
+ break
202
+ if parent.conversation_id != conversation_id or parent.reposted_post_id:
203
+ status = "conversation_conflict"
204
+ break
205
+ chain.append(parent_id)
206
+ current = parent
207
+ seed_depth = len(chain) - 1 if status == "resolved" else None
208
+ within = None
209
+ if seed_depth is not None:
210
+ day = post.created_at_utc[:10]
211
+ within = (
212
+ seed_depth <= query["max_depth"]
213
+ and (not query.get("since") or day >= query["since"])
214
+ and (not query.get("until") or day < query["until"])
215
+ )
216
+ rows.append(
217
+ {
218
+ "post_id": identifier,
219
+ "root_post_id": conversation_id,
220
+ "seed_post_id": root,
221
+ "parent_post_id": value.get("in_reply_to_id"),
222
+ "ancestry_status": status,
223
+ "depth": platform_depth(seed_depth),
224
+ "seed_relative_depth": seed_depth,
225
+ "chain_ids": json.dumps(chain),
226
+ "within_requested_bounds": within,
227
+ }
228
+ )
229
+ if _files(source) != assessment["input_sha256"]:
230
+ raise IntegrityError("Source changed during ancestry verification")
231
+ target.mkdir(parents=True, exist_ok=False)
232
+ (target / "raw").mkdir()
233
+ for name, records in (
234
+ ("ancestor_posts.jsonl", (p.to_dict() for p in fetched.values())),
235
+ ("pending_posts.jsonl", pending.values()),
236
+ ):
237
+ append_jsonl(target / name, records)
238
+ with (target / "resolution.csv").open(
239
+ "w", encoding="utf-8-sig", newline=""
240
+ ) as stream:
241
+ writer = csv.DictWriter(
242
+ stream,
243
+ fieldnames=[
244
+ "post_id",
245
+ "root_post_id",
246
+ "seed_post_id",
247
+ "parent_post_id",
248
+ "ancestry_status",
249
+ "depth",
250
+ "seed_relative_depth",
251
+ "chain_ids",
252
+ "within_requested_bounds",
253
+ ],
254
+ )
255
+ writer.writeheader()
256
+ writer.writerows(rows)
257
+ for identifier, payload in raw_responses.items():
258
+ atomic_json(target / "raw" / f"{identifier}.json", payload)
259
+ counts = {
260
+ status: sum(r["ancestry_status"] == status for r in rows)
261
+ for status in sorted({r["ancestry_status"] for r in rows})
262
+ }
263
+ from . import __version__
264
+
265
+ report = {
266
+ "schema": "pyxcom.comment-ancestry/1",
267
+ "package_version": __version__,
268
+ "captured_at_utc": now_utc(),
269
+ "query": query,
270
+ "source_sha256": assessment["input_sha256"],
271
+ "lookup_limit": lookup_limit,
272
+ "lookup_calls": calls,
273
+ "stop_reason": stop_reason,
274
+ "identity_verified": verified_observer is not None,
275
+ "source_observer_bound": expected_observer is not None
276
+ and verified_observer is not None,
277
+ "status_counts": counts,
278
+ "resolved_records": counts.get("resolved", 0),
279
+ "unresolved_records": len(rows) - counts.get("resolved", 0),
280
+ "context_records": len(fetched),
281
+ "collection_complete": assessment["complete"],
282
+ "source_collection_reason": assessment["reason"],
283
+ "files_sha256": _files(target),
284
+ }
285
+ atomic_json(target / "manifest.json", report)
286
+ return report
@@ -251,6 +251,7 @@ def build_parser() -> argparse.ArgumentParser:
251
251
  help="Collect replies below a post or reply, including nested replies",
252
252
  )
253
253
  comments.add_argument("post_id_or_url")
254
+ comments.add_argument("--retry-stalled", action="store_true", help="Explicitly retry saved comment cursors paused by empty-page guard")
254
255
  comments.add_argument("--max-depth", type=_depth, default=2)
255
256
  comments.add_argument("--max-comments", type=_budget, default=100)
256
257
  comments.add_argument("--max-pages", type=_budget, default=20)
@@ -576,6 +577,7 @@ def main(argv: list[str] | None = None) -> int:
576
577
  max_pages=args.max_pages,
577
578
  since=args.since,
578
579
  until=args.until,
580
+ **({"retry_stalled": True} if args.retry_stalled else {}),
579
581
  )
580
582
  print(json.dumps(result.to_dict(), ensure_ascii=False))
581
583
  elif args.command in ("search-query", "post-quotes", "user-reposts"):
@@ -957,6 +957,24 @@ class XClient(DiscoveryMixin, NetworkMixin):
957
957
  )
958
958
  )
959
959
 
960
+ def save_comment_ancestry(
961
+ self,
962
+ source_dir: str | Path,
963
+ output_dir: str | Path,
964
+ *,
965
+ lookup_limit: int = 20,
966
+ ) -> dict:
967
+ """Save a separate, bounded supplement verifying missing comment parents.
968
+
969
+ Original collection files and sampling roles are preserved. The cap counts
970
+ single-post lookup calls; transport retries can make more HTTP attempts.
971
+ """
972
+ from .ancestry import save_comment_ancestry
973
+
974
+ return save_comment_ancestry(
975
+ self, source_dir, output_dir, lookup_limit=lookup_limit
976
+ )
977
+
960
978
  @guarded_collection
961
979
  def save_post_comments(
962
980
  self,
@@ -968,9 +986,10 @@ class XClient(DiscoveryMixin, NetworkMixin):
968
986
  max_pages: int | None = 20,
969
987
  since: str | None = None,
970
988
  until: str | None = None,
989
+ retry_stalled: bool = False,
971
990
  ) -> CollectionResult:
972
991
  """Save a root post, its reply tree and observed author profiles; resume safely."""
973
- from .comments import CommentTraversal
992
+ from .comments import CommentTraversal, restore_archived_stalls
974
993
 
975
994
  root_id = _post_id(post_id_or_url)
976
995
  # Validate bounds before creating or migrating output.
@@ -982,6 +1001,7 @@ class XClient(DiscoveryMixin, NetworkMixin):
982
1001
  max_pages=max_pages,
983
1002
  since=since,
984
1003
  until=until,
1004
+ retry_stalled=retry_stalled,
985
1005
  )
986
1006
  store = PostStore(
987
1007
  output_dir,
@@ -994,6 +1014,8 @@ class XClient(DiscoveryMixin, NetworkMixin):
994
1014
  )
995
1015
  if store.complete:
996
1016
  return store.finish(complete=True, reason=store.state["reason"])
1017
+ if retry_stalled:
1018
+ restore_archived_stalls(store)
997
1019
  traversal = CommentTraversal(
998
1020
  self,
999
1021
  root_id,
@@ -1004,6 +1026,7 @@ class XClient(DiscoveryMixin, NetworkMixin):
1004
1026
  until=until,
1005
1027
  records=store._posts,
1006
1028
  state=store.state.setdefault("conversation", {}),
1029
+ retry_stalled=retry_stalled,
1007
1030
  )
1008
1031
 
1009
1032
  def save_profiles():