pyXcom 1.2.1__tar.gz → 1.3.1__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.1 → pyxcom-1.3.1}/CHANGELOG.md +31 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/CITATION.cff +1 -1
- {pyxcom-1.2.1 → pyxcom-1.3.1}/PKG-INFO +19 -1
- {pyxcom-1.2.1 → pyxcom-1.3.1}/README.md +18 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/api.md +14 -0
- pyxcom-1.3.1/docs/comment-ancestry.md +112 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/pyproject.toml +1 -1
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/__init__.py +1 -1
- pyxcom-1.3.1/src/pyxcom/ancestry.py +333 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/client.py +18 -0
- pyxcom-1.3.1/tests/test_comment_ancestry.py +420 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/.gitignore +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/LICENSE +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/NOTICE +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/LIVE_RESULTS.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/README.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/REPLAY_RESULTS.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/STORAGE_RESULTS.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/common.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/environment-lock.txt +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/live_arm.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/live_compare.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/live_pool.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/metric-batch-results.json +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/metric_batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/normalize.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/pool_summary.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/replay.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/benchmarks/requirements.txt +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/datasets.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/discovery-content.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/pool.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/profile-observations.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/publishing.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/recovery.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/release-1.0-plan.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/release-1.0-validation.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/stability-plan.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/stability.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/timeline-pagination-plan.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/docs/timeline-pagination.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/examples/basic.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/examples/research_networks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/_persistence.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/_pool_locks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/assets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/auth.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/comments.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/datasets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/discovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/errors.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/integrity.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/layout.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/models.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/networks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/parse.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/pool.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/profiles.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/provenance.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/recovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/schema.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/search.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/storage.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/tables.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/validate.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/src/pyxcom/validate_tables.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_api_interruption.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_assets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_auth.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_batch_report.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_benchmark_harness.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_benchmark_replay.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_capability_cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_capability_transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_comment_stalls.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_comment_transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_comments.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_datasets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_direct_search.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_discovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_discovery_content.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_discovery_pagination.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_integrity.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_layout.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_metric_batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_networks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_observations.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_parsing.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_persistence.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_pool.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_pool_cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_pool_locks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_pool_provenance.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_profile_batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_profile_history.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_project_metadata.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_public_api.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_recovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_recovery_cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_relational_integration.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_request_control.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_schema.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_search_scope.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_search_storage.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_session_identity.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_tables.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_timeline_pagination.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_validate_date_scope.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.1}/tests/test_validation_shapes.py +0 -0
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.1 — 2026-10-13
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Distinguish malformed or unparseable parent responses (`response_unparsed`)
|
|
8
|
+
from recognized empty results and tombstones (`parent_not_returned`).
|
|
9
|
+
- Honor inherited source observer bindings and reject malformed markers by reusing
|
|
10
|
+
the package's ownership reader; check ownership again before publication.
|
|
11
|
+
- Reject supplement destinations owned by another pool before network requests,
|
|
12
|
+
including inherited ownership, and check again before publication.
|
|
13
|
+
- Publish ancestry output as one staged generation. Ordinary write failures leave
|
|
14
|
+
no partial final directory blocking retries.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `blocked_post_id` identifies the actual point of failure along a parent chain,
|
|
19
|
+
while preserving the original `parent_post_id`.
|
|
20
|
+
|
|
21
|
+
## 1.3.0 — 2026-10-13
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `XClient.save_comment_ancestry()` verifies missing comment parents through bounded,
|
|
26
|
+
deduplicated exact-ID lookups, using the source observer when bound.
|
|
27
|
+
- Write a separate ancestry supplement with verified seed-relative/platform depths,
|
|
28
|
+
explicit unresolved statuses, context-only parents and source/output hashes.
|
|
29
|
+
- Preserve original sampling roles, files, cursors and partial status; stop further
|
|
30
|
+
lookups on rate limits or request errors without rotating accounts.
|
|
31
|
+
|
|
32
|
+
No new runtime dependencies or changes to existing comment collection defaults.
|
|
33
|
+
|
|
3
34
|
## 1.2.1 — 2026-10-13
|
|
4
35
|
|
|
5
36
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyXcom
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.1
|
|
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:
|
|
@@ -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:
|
|
@@ -98,6 +98,20 @@ result = client.save_post_comments(
|
|
|
98
98
|
)
|
|
99
99
|
```
|
|
100
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
|
+
|
|
101
115
|
The manifest distinguishes queued requests, stalled requests, active warnings,
|
|
102
116
|
and historical warnings. A retry can still return a partial result when the
|
|
103
117
|
platform keeps returning empty pages. CLI: `pyxcom post-comments ID --output-dir
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
Destinations inside another pool's owned scope are rejected before network requests
|
|
24
|
+
and checked again before publication; an idle pool still owns its persisted scope.
|
|
25
|
+
|
|
26
|
+
## Lookup rules
|
|
27
|
+
|
|
28
|
+
Only a record's explicit `in_reply_to_id` is requested. Each missing ID is looked
|
|
29
|
+
up once per invocation, including failed lookups, and shared ancestors reuse the
|
|
30
|
+
result. The returned ID and conversation must agree with the source. A chain is
|
|
31
|
+
resolved only when it reaches the exact requested seed, without a cycle. Other
|
|
32
|
+
branches of a reply-seeded conversation are not admitted.
|
|
33
|
+
|
|
34
|
+
Before the first lookup, the client verifies its current identity. If the source
|
|
35
|
+
has a pool observer binding, including one inherited from an ancestor directory,
|
|
36
|
+
that identity must match; a mismatch raises before parent content is requested.
|
|
37
|
+
Malformed ownership markers are rejected before identity or parent requests, and
|
|
38
|
+
the marker is checked again before publication. Older unbound sources remain explicitly unbound in
|
|
39
|
+
the manifest. No observer is inferred retroactively and no account rotation occurs.
|
|
40
|
+
|
|
41
|
+
`lookup_limit` is a nonnegative integer limiting single-post lookup calls, not
|
|
42
|
+
HTTP attempts. Identity checks, route discovery and the existing transport's
|
|
43
|
+
retries can add HTTP attempts. Zero makes no network calls. Calls respect client
|
|
44
|
+
delay; a rate limit or request error stops further parent lookups in that run.
|
|
45
|
+
|
|
46
|
+
## Outputs
|
|
47
|
+
|
|
48
|
+
| File | Contents |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `resolution.csv` | One row per original pending record, verified links, depth and scope flag |
|
|
51
|
+
| `ancestor_posts.jsonl` | Newly fetched parents, all `observation_role="context"` |
|
|
52
|
+
| `pending_posts.jsonl` | Copy of the original pending records |
|
|
53
|
+
| `raw/<parent_id>.json` | Credential-free successful lookup payloads, including unavailable results |
|
|
54
|
+
| `manifest.json` | Source and supplement hashes, caps, counts, observer checks and source completeness |
|
|
55
|
+
|
|
56
|
+
`seed_post_id` is the requested collection seed; `root_post_id` is the platform
|
|
57
|
+
conversation root. `chain_ids` lists verified retained IDs from the pending record
|
|
58
|
+
back toward the seed; an unresolved chain does not include the unreturned parent.
|
|
59
|
+
`blocked_post_id` identifies the first post where that chain could not be verified,
|
|
60
|
+
including a deeper missing parent after the original parent was fetched. It is
|
|
61
|
+
blank for resolved chains; `parent_post_id` always preserves the original reply link.
|
|
62
|
+
`seed_relative_depth` counts links to the seed and is blank until that chain is
|
|
63
|
+
resolved. `depth` counts links to the platform root and also requires retained
|
|
64
|
+
evidence between a reply seed and that root; otherwise it stays blank. This method
|
|
65
|
+
does not make extra requests above a reply seed just to fill platform depth.
|
|
66
|
+
|
|
67
|
+
`within_requested_bounds` checks the pending record's verified seed-relative depth
|
|
68
|
+
and the original UTC date bounds (inclusive `since`, exclusive `until`). It is blank
|
|
69
|
+
for unresolved chains. A true flag is not automatic sample admission: supplement
|
|
70
|
+
records never alter the original `posts.csv` or `comments.csv`.
|
|
71
|
+
|
|
72
|
+
Statuses include `resolved`, `lookup_budget`, `parent_not_returned`, `rate_limited`,
|
|
73
|
+
`request_error`, `identity_conflict`, `conversation_conflict`, `outside_seed_branch`,
|
|
74
|
+
`cycle`, `response_unparsed` and `protected_parent`. A recognized empty result or
|
|
75
|
+
tombstone is `parent_not_returned`; that does not establish deletion, protection
|
|
76
|
+
or absence on the platform. A changed response shape or invalid returned post is
|
|
77
|
+
`response_unparsed`, with raw evidence retained for diagnosis. Explicitly protected
|
|
78
|
+
parent content is not retained or archived.
|
|
79
|
+
|
|
80
|
+
Each invocation uses a new output directory. There is no supplement resume or
|
|
81
|
+
automatic merging into the source. After traversal, all files are written in a
|
|
82
|
+
temporary sibling directory, then renamed to the requested destination together.
|
|
83
|
+
Ordinary write failures remove staging and leave the destination free for retry.
|
|
84
|
+
A process termination before publication can lose that run's in-memory supplement;
|
|
85
|
+
an abrupt kill can also leave an unpublished hidden staging directory. Retain
|
|
86
|
+
the original source and compare dated supplements explicitly. The manifest's
|
|
87
|
+
`collection_complete` and `source_collection_reason` copy the original collection's
|
|
88
|
+
status, even if every pending chain has now been resolved.
|
|
89
|
+
|
|
90
|
+
## Recorded validation
|
|
91
|
+
|
|
92
|
+
On October 13, 2026, two retained personal-agent discussion trees contained three
|
|
93
|
+
pending replies. With their original authenticated observers, three parent lookup
|
|
94
|
+
calls resolved two chains in the first tree; the second tree's parent did not
|
|
95
|
+
return a parseable record. Source file inventories and hashes were unchanged.
|
|
96
|
+
|
|
97
|
+
The resolved replies had depths two and four. The source depth bound was three,
|
|
98
|
+
so only the depth-two record passed the depth/date scope flag. This illustrates why
|
|
99
|
+
resolving ancestry and admitting a reply to a research sample are separate steps.
|
|
100
|
+
Both original collections remained partial. These are recorded results, not a
|
|
101
|
+
coverage benchmark; later requests may return different content.
|
|
102
|
+
|
|
103
|
+
Eighteen focused offline regression tests cover exact chains, shared parents,
|
|
104
|
+
reply-seed boundaries, cycles, budgets, source integrity, identity mismatch,
|
|
105
|
+
malformed responses and protected content. The complete suite contained 454
|
|
106
|
+
passing tests at this release.
|
|
107
|
+
|
|
108
|
+
The 1.3.1 review expanded this to 25 focused tests and 461 complete-suite tests.
|
|
109
|
+
It also replayed the three previously archived lookup responses offline, preserving
|
|
110
|
+
the two resolved chains and one empty parent result. No new network collection
|
|
111
|
+
was used for that replay. Injected JSONL, raw-response and manifest write failures
|
|
112
|
+
each left no final directory and allowed a retry to the same destination.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyXcom"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.1"
|
|
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,333 @@
|
|
|
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 tempfile import TemporaryDirectory
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from ._persistence import append_jsonl, atomic_json, atomic_text, read_jsonl
|
|
13
|
+
from ._pool_locks import ScopeLock
|
|
14
|
+
from .errors import IntegrityError, ParseError, PyXcomError, RateLimitError
|
|
15
|
+
from .integrity import assess_recovery
|
|
16
|
+
from .models import Post
|
|
17
|
+
from .parse import single_post
|
|
18
|
+
from .provenance import _owner, assert_collection_owner
|
|
19
|
+
from .recovery import _files
|
|
20
|
+
from .transport import now_utc
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .client import XClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _directory(value: str | Path) -> Path:
|
|
27
|
+
path = Path(value).expanduser()
|
|
28
|
+
if path.is_symlink():
|
|
29
|
+
raise ValueError("Ancestry directory must not be a symbolic link")
|
|
30
|
+
return path.resolve()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mapping(value) -> dict:
|
|
34
|
+
return value if isinstance(value, dict) else {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _protected(payload: dict) -> bool:
|
|
38
|
+
result = _mapping(
|
|
39
|
+
_mapping(_mapping(payload.get("data")).get("tweetResult")).get("result")
|
|
40
|
+
)
|
|
41
|
+
tweet = _mapping(result.get("tweet", result))
|
|
42
|
+
user = _mapping(
|
|
43
|
+
_mapping(_mapping(tweet.get("core")).get("user_results")).get("result")
|
|
44
|
+
)
|
|
45
|
+
return any(
|
|
46
|
+
_mapping(user.get(key)).get("protected") is True
|
|
47
|
+
for key in ("privacy", "legacy")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parent_not_returned(payload: dict) -> bool:
|
|
52
|
+
"""Recognize explicit empty slots/tombstones, not unknown response shapes."""
|
|
53
|
+
data = payload.get("data")
|
|
54
|
+
if payload.get("errors") or not isinstance(data, dict) or "tweetResult" not in data:
|
|
55
|
+
return False
|
|
56
|
+
slot = data["tweetResult"]
|
|
57
|
+
if slot is None:
|
|
58
|
+
return True
|
|
59
|
+
if not isinstance(slot, dict):
|
|
60
|
+
return False
|
|
61
|
+
result = slot.get("result")
|
|
62
|
+
return (
|
|
63
|
+
result is None
|
|
64
|
+
or result == {}
|
|
65
|
+
or (isinstance(result, dict) and result.get("__typename") == "TweetTombstone")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _publish_supplement(
|
|
70
|
+
target: Path,
|
|
71
|
+
pending: dict,
|
|
72
|
+
fetched: dict[str, Post],
|
|
73
|
+
rows: list[dict],
|
|
74
|
+
raw_responses: dict[str, dict],
|
|
75
|
+
report: dict,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Publish a complete generation; ordinary write failures leave no final target."""
|
|
78
|
+
assert_collection_owner(target)
|
|
79
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
with TemporaryDirectory(prefix=".pyxcom-ancestry-", dir=target.parent) as temporary:
|
|
81
|
+
staged = Path(temporary)
|
|
82
|
+
(staged / "raw").mkdir()
|
|
83
|
+
for name, records in (
|
|
84
|
+
("ancestor_posts.jsonl", (p.to_dict() for p in fetched.values())),
|
|
85
|
+
("pending_posts.jsonl", pending.values()),
|
|
86
|
+
):
|
|
87
|
+
append_jsonl(staged / name, records)
|
|
88
|
+
with atomic_text(
|
|
89
|
+
staged / "resolution.csv", encoding="utf-8-sig", newline=""
|
|
90
|
+
) as stream:
|
|
91
|
+
writer = csv.DictWriter(
|
|
92
|
+
stream,
|
|
93
|
+
fieldnames=[
|
|
94
|
+
"post_id",
|
|
95
|
+
"root_post_id",
|
|
96
|
+
"seed_post_id",
|
|
97
|
+
"parent_post_id",
|
|
98
|
+
"ancestry_status",
|
|
99
|
+
"depth",
|
|
100
|
+
"seed_relative_depth",
|
|
101
|
+
"chain_ids",
|
|
102
|
+
"within_requested_bounds",
|
|
103
|
+
"blocked_post_id",
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
writer.writeheader()
|
|
107
|
+
writer.writerows(rows)
|
|
108
|
+
for identifier, payload in raw_responses.items():
|
|
109
|
+
atomic_json(staged / "raw" / f"{identifier}.json", payload)
|
|
110
|
+
report["files_sha256"] = _files(staged)
|
|
111
|
+
atomic_json(staged / "manifest.json", report)
|
|
112
|
+
if target.exists():
|
|
113
|
+
raise FileExistsError("Ancestry supplement already exists")
|
|
114
|
+
assert_collection_owner(target)
|
|
115
|
+
staged.rename(target)
|
|
116
|
+
return report
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save_comment_ancestry(
|
|
120
|
+
client: "XClient",
|
|
121
|
+
source_dir: str | Path,
|
|
122
|
+
output_dir: str | Path,
|
|
123
|
+
*,
|
|
124
|
+
lookup_limit: int = 20,
|
|
125
|
+
) -> dict:
|
|
126
|
+
"""Verify exact parent chains without admitting records to the original sample.
|
|
127
|
+
|
|
128
|
+
The cap counts single-post lookup calls, not transport retry attempts. A new
|
|
129
|
+
supplement is required for every run; source files are read-only throughout.
|
|
130
|
+
"""
|
|
131
|
+
if (
|
|
132
|
+
isinstance(lookup_limit, bool)
|
|
133
|
+
or not isinstance(lookup_limit, int)
|
|
134
|
+
or lookup_limit < 0
|
|
135
|
+
):
|
|
136
|
+
raise ValueError("lookup_limit must be a nonnegative integer")
|
|
137
|
+
source, target = _directory(source_dir), _directory(output_dir)
|
|
138
|
+
if source == target or source in target.parents or target in source.parents:
|
|
139
|
+
raise ValueError("Source and supplement scopes must not overlap")
|
|
140
|
+
with ExitStack() as stack:
|
|
141
|
+
for scope in sorted((source, target)):
|
|
142
|
+
stack.enter_context(ScopeLock(scope))
|
|
143
|
+
if target.exists():
|
|
144
|
+
raise FileExistsError("Ancestry supplement already exists")
|
|
145
|
+
assert_collection_owner(target)
|
|
146
|
+
assessment = assess_recovery(source)
|
|
147
|
+
if not assessment["allowed"] or assessment["record_integrity"] != "verified":
|
|
148
|
+
raise IntegrityError(
|
|
149
|
+
"Source collection did not pass integrity verification"
|
|
150
|
+
)
|
|
151
|
+
query = assessment["query"]
|
|
152
|
+
if query.get("kind") != "post_comments":
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"Ancestry verification requires a post_comments collection"
|
|
155
|
+
)
|
|
156
|
+
state = json.loads((source / ".pyxcom/state.json").read_text(encoding="utf-8"))
|
|
157
|
+
conversation = state["conversation"]
|
|
158
|
+
pending = conversation.get("pending", {})
|
|
159
|
+
posts = {p["id"]: Post(**p) for p in read_jsonl(source / ".pyxcom/posts.jsonl")}
|
|
160
|
+
posts.update({key: Post(**value) for key, value in pending.items()})
|
|
161
|
+
root = str(query["root_post_id"])
|
|
162
|
+
conversation_id = str(conversation.get("conversation_id", root))
|
|
163
|
+
owner = _owner(source)
|
|
164
|
+
expected_observer = (owner or {}).get("observer_id")
|
|
165
|
+
verified_observer = None
|
|
166
|
+
fetched: dict[str, Post] = {}
|
|
167
|
+
failures: dict[str, str] = {}
|
|
168
|
+
raw_responses: dict[str, dict] = {}
|
|
169
|
+
calls = 0
|
|
170
|
+
stop_reason = None
|
|
171
|
+
|
|
172
|
+
def lookup(identifier: str) -> tuple[Post | None, str | None]:
|
|
173
|
+
nonlocal calls, stop_reason, verified_observer
|
|
174
|
+
if identifier in failures:
|
|
175
|
+
return None, failures[identifier]
|
|
176
|
+
if stop_reason:
|
|
177
|
+
return None, stop_reason
|
|
178
|
+
if calls >= lookup_limit:
|
|
179
|
+
return None, "lookup_budget"
|
|
180
|
+
if verified_observer is None:
|
|
181
|
+
verified_observer = str(client.check_login_identity())
|
|
182
|
+
if expected_observer is not None and verified_observer != str(
|
|
183
|
+
expected_observer
|
|
184
|
+
):
|
|
185
|
+
raise ValueError(
|
|
186
|
+
"Ancestry lookup observer differs from source observer"
|
|
187
|
+
)
|
|
188
|
+
if calls:
|
|
189
|
+
time.sleep(client.delay)
|
|
190
|
+
calls += 1
|
|
191
|
+
payload = None
|
|
192
|
+
try:
|
|
193
|
+
payload = client.get_raw_post(identifier)
|
|
194
|
+
if _protected(payload):
|
|
195
|
+
failures[identifier] = "protected_parent"
|
|
196
|
+
return None, failures[identifier]
|
|
197
|
+
raw_responses[identifier] = payload
|
|
198
|
+
try:
|
|
199
|
+
post = single_post(payload, captured_at_utc=now_utc())
|
|
200
|
+
except (ParseError, ValueError, TypeError, KeyError, AttributeError):
|
|
201
|
+
failures[identifier] = (
|
|
202
|
+
"parent_not_returned"
|
|
203
|
+
if _parent_not_returned(payload)
|
|
204
|
+
else "response_unparsed"
|
|
205
|
+
)
|
|
206
|
+
return None, failures[identifier]
|
|
207
|
+
if post.id != identifier:
|
|
208
|
+
failures[identifier] = "identity_conflict"
|
|
209
|
+
elif post.conversation_id != conversation_id or post.reposted_post_id:
|
|
210
|
+
failures[identifier] = "conversation_conflict"
|
|
211
|
+
else:
|
|
212
|
+
post = replace(
|
|
213
|
+
post, observation_role="context", discovery_source="x_ancestor"
|
|
214
|
+
)
|
|
215
|
+
posts[identifier] = fetched[identifier] = post
|
|
216
|
+
return post, None
|
|
217
|
+
except ParseError:
|
|
218
|
+
stop_reason = failures[identifier] = "request_error"
|
|
219
|
+
except RateLimitError:
|
|
220
|
+
stop_reason = failures[identifier] = "rate_limited"
|
|
221
|
+
except PyXcomError:
|
|
222
|
+
stop_reason = failures[identifier] = "request_error"
|
|
223
|
+
return None, failures[identifier]
|
|
224
|
+
|
|
225
|
+
def platform_depth(seed_depth: int | None) -> int | None:
|
|
226
|
+
if seed_depth is None:
|
|
227
|
+
return None
|
|
228
|
+
current = posts[root]
|
|
229
|
+
seen = {root}
|
|
230
|
+
offset = 0
|
|
231
|
+
while current.id != conversation_id:
|
|
232
|
+
parent = posts.get(current.in_reply_to_id or "")
|
|
233
|
+
if (
|
|
234
|
+
parent is None
|
|
235
|
+
or parent.id in seen
|
|
236
|
+
or parent.conversation_id != conversation_id
|
|
237
|
+
):
|
|
238
|
+
return None
|
|
239
|
+
seen.add(parent.id)
|
|
240
|
+
offset += 1
|
|
241
|
+
current = parent
|
|
242
|
+
return seed_depth + offset
|
|
243
|
+
|
|
244
|
+
rows = []
|
|
245
|
+
for identifier, value in pending.items():
|
|
246
|
+
post = posts[identifier]
|
|
247
|
+
chain = [identifier]
|
|
248
|
+
current = post
|
|
249
|
+
status = "resolved"
|
|
250
|
+
blocked_id = None
|
|
251
|
+
while current.id != root:
|
|
252
|
+
blocked_id = current.id
|
|
253
|
+
if (
|
|
254
|
+
current.conversation_id != conversation_id
|
|
255
|
+
or current.reposted_post_id
|
|
256
|
+
):
|
|
257
|
+
status = "conversation_conflict"
|
|
258
|
+
break
|
|
259
|
+
parent_id = current.in_reply_to_id
|
|
260
|
+
blocked_id = parent_id or current.id
|
|
261
|
+
if parent_id is None or (
|
|
262
|
+
parent_id == conversation_id and conversation_id != root
|
|
263
|
+
):
|
|
264
|
+
status = "outside_seed_branch"
|
|
265
|
+
break
|
|
266
|
+
if parent_id in chain:
|
|
267
|
+
status = "cycle"
|
|
268
|
+
break
|
|
269
|
+
parent = posts.get(parent_id)
|
|
270
|
+
if parent is None:
|
|
271
|
+
parent, failure = lookup(parent_id)
|
|
272
|
+
if parent is None:
|
|
273
|
+
status = failure or "parent_not_returned"
|
|
274
|
+
break
|
|
275
|
+
if parent.conversation_id != conversation_id or parent.reposted_post_id:
|
|
276
|
+
status = "conversation_conflict"
|
|
277
|
+
break
|
|
278
|
+
chain.append(parent_id)
|
|
279
|
+
current = parent
|
|
280
|
+
blocked_id = None
|
|
281
|
+
seed_depth = len(chain) - 1 if status == "resolved" else None
|
|
282
|
+
within = None
|
|
283
|
+
if seed_depth is not None:
|
|
284
|
+
day = post.created_at_utc[:10]
|
|
285
|
+
within = (
|
|
286
|
+
seed_depth <= query["max_depth"]
|
|
287
|
+
and (not query.get("since") or day >= query["since"])
|
|
288
|
+
and (not query.get("until") or day < query["until"])
|
|
289
|
+
)
|
|
290
|
+
rows.append(
|
|
291
|
+
{
|
|
292
|
+
"post_id": identifier,
|
|
293
|
+
"root_post_id": conversation_id,
|
|
294
|
+
"seed_post_id": root,
|
|
295
|
+
"parent_post_id": value.get("in_reply_to_id"),
|
|
296
|
+
"ancestry_status": status,
|
|
297
|
+
"depth": platform_depth(seed_depth),
|
|
298
|
+
"seed_relative_depth": seed_depth,
|
|
299
|
+
"chain_ids": json.dumps(chain),
|
|
300
|
+
"within_requested_bounds": within,
|
|
301
|
+
"blocked_post_id": blocked_id,
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
if _files(source) != assessment["input_sha256"] or _owner(source) != owner:
|
|
305
|
+
raise IntegrityError("Source changed during ancestry verification")
|
|
306
|
+
counts = {
|
|
307
|
+
status: sum(r["ancestry_status"] == status for r in rows)
|
|
308
|
+
for status in sorted({r["ancestry_status"] for r in rows})
|
|
309
|
+
}
|
|
310
|
+
from . import __version__
|
|
311
|
+
|
|
312
|
+
report = {
|
|
313
|
+
"schema": "pyxcom.comment-ancestry/1",
|
|
314
|
+
"package_version": __version__,
|
|
315
|
+
"captured_at_utc": now_utc(),
|
|
316
|
+
"query": query,
|
|
317
|
+
"source_sha256": assessment["input_sha256"],
|
|
318
|
+
"lookup_limit": lookup_limit,
|
|
319
|
+
"lookup_calls": calls,
|
|
320
|
+
"stop_reason": stop_reason,
|
|
321
|
+
"identity_verified": verified_observer is not None,
|
|
322
|
+
"source_observer_bound": expected_observer is not None
|
|
323
|
+
and verified_observer is not None,
|
|
324
|
+
"status_counts": counts,
|
|
325
|
+
"resolved_records": counts.get("resolved", 0),
|
|
326
|
+
"unresolved_records": len(rows) - counts.get("resolved", 0),
|
|
327
|
+
"context_records": len(fetched),
|
|
328
|
+
"collection_complete": assessment["complete"],
|
|
329
|
+
"source_collection_reason": assessment["reason"],
|
|
330
|
+
}
|
|
331
|
+
return _publish_supplement(
|
|
332
|
+
target, pending, fetched, rows, raw_responses, report
|
|
333
|
+
)
|
|
@@ -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,
|