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.
- {pyxcom-1.2.0 → pyxcom-1.3.0}/CHANGELOG.md +24 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/CITATION.cff +2 -2
- {pyxcom-1.2.0 → pyxcom-1.3.0}/PKG-INFO +20 -2
- {pyxcom-1.2.0 → pyxcom-1.3.0}/README.md +19 -1
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/api.md +37 -1
- pyxcom-1.3.0/docs/comment-ancestry.md +94 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/pool.md +1 -1
- {pyxcom-1.2.0 → pyxcom-1.3.0}/pyproject.toml +1 -1
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/__init__.py +1 -1
- pyxcom-1.3.0/src/pyxcom/ancestry.py +286 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/cli.py +2 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/client.py +24 -1
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/comments.py +106 -2
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/pool.py +1 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/tables.py +6 -0
- pyxcom-1.3.0/tests/test_comment_ancestry.py +294 -0
- pyxcom-1.3.0/tests/test_comment_stalls.py +258 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/.gitignore +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/LICENSE +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/NOTICE +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/LIVE_RESULTS.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/README.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/REPLAY_RESULTS.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/STORAGE_RESULTS.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/common.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/environment-lock.txt +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/live_arm.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/live_compare.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/live_pool.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/metric-batch-results.json +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/metric_batch.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/normalize.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/pool_summary.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/replay.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/benchmarks/requirements.txt +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/datasets.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/discovery-content.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/profile-observations.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/publishing.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/recovery.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/release-1.0-plan.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/release-1.0-validation.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/stability-plan.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/stability.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/timeline-pagination-plan.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/docs/timeline-pagination.md +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/examples/basic.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/examples/research_networks.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/_persistence.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/_pool_locks.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/assets.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/auth.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/batch.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/datasets.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/discovery.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/errors.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/integrity.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/layout.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/models.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/networks.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/parse.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/profiles.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/provenance.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/recovery.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/schema.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/search.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/storage.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/transport.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/validate.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/src/pyxcom/validate_tables.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_api_interruption.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_assets.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_auth.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_batch_report.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_benchmark_harness.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_benchmark_replay.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_capability_cli.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_capability_transport.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_comment_transport.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_comments.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_datasets.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_direct_search.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_discovery.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_discovery_content.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_discovery_pagination.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_integrity.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_layout.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_metric_batch.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_networks.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_observations.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_parsing.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_persistence.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool_cli.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool_locks.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_pool_provenance.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_profile_batch.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_profile_history.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_project_metadata.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_public_api.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_recovery.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_recovery_cli.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_relational_integration.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_request_control.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_schema.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_search_scope.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_search_storage.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_session_identity.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_tables.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_timeline_pagination.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_transport.py +0 -0
- {pyxcom-1.2.0 → pyxcom-1.3.0}/tests/test_validate_date_scope.py +0 -0
- {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.
|
|
10
|
-
date-released: 2026-10-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
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.
|
|
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"
|
|
@@ -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():
|