xthread-agent 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,982 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ xthread-agent — X/Twitter thread content & media harvester for AI agents
4
+ =========================================================================
5
+ A deterministic, self-contained agent that, given any public X status URL:
6
+
7
+ 1. Normalizes & validates the input URL/ID (t.co shortlinks are resolved
8
+ through one network hop first)
9
+ 2. Resolves thread candidates via walker slots — UnrollNow primary,
10
+ ThreadReaderApp fallback (root always kept; degraded to root-only
11
+ when every slot fails)
12
+ 3. Fetches each tweet's metadata (text, author, stats, media) via FixTweet,
13
+ with vxtwitter as an automatic fallback decoder slot
14
+ 4. Reconstructs the true self-reply chain from `replying_to_status`
15
+ (walking up to the thread start and down through replies), excluding
16
+ unrelated same-author recommendations
17
+ 5. Downloads every video (best-quality mp4, streaming, atomic writes),
18
+ poster thumbnails and photos
19
+ 6. Emits `thread_manifest.json` — an enveloped, machine-readable result
20
+ (schema_version 3.0) with posts, errors, and extraction metadata
21
+
22
+ Pipeline (no login, no API keys, no browser):
23
+ Tier 1 UnrollNow — thread candidate walk (ordered candidate IDs)
24
+ (ThreadReaderApp — fallback walker slot, used when the primary
25
+ fallback) slot fails or yields no candidates
26
+ Tier 2 FixTweet API — per-tweet metadata decode incl. multi-video
27
+ "amplify" media with direct twimg CDN URLs
28
+ (vxtwitter fallback — used only when FixTweet fails network-side)
29
+ Tier 3 video/pbs.twimg.com — CDN fetch, no auth needed once URL is known
30
+
31
+ Usage:
32
+ python3 xthread-agent.py <status_url_or_id> [--out DIR] [--no-download]
33
+ [--json] [--quiet] [--version]
34
+
35
+ Exit codes: 0 = at least one post harvested, 1 = nothing harvested / error,
36
+ 2 = usage error (argparse).
37
+
38
+ v3 note: the manifest changed from a bare tweet array (v2) to an enveloped
39
+ document. See RELEASE_NOTES.md for the migration note.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import os
45
+ import re
46
+ import sys
47
+ import time
48
+ import argparse
49
+ import urllib.error
50
+ import urllib.parse
51
+ import urllib.request
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+
55
+ __version__ = "3.2.0"
56
+ SCHEMA_VERSION = "3.0"
57
+
58
+ UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
59
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
60
+
61
+ LOG_QUIET = False
62
+
63
+ # Politeness / safety bounds
64
+ DECODE_SLEEP = 0.6 # seconds between decoder calls
65
+ MAX_CANDIDATES = 50 # hard cap on walker candidates decoded per run
66
+ MAX_ANCESTORS = 25 # hard cap on ancestor walk-up fetches
67
+ DOWNLOAD_TIMEOUT = 180 # seconds per media transfer attempt
68
+ DECODE_TIMEOUT = 30 # seconds per decoder attempt
69
+ WALK_TIMEOUT = 40 # seconds for the thread walk
70
+
71
+ # Error codes (stable, machine-readable)
72
+ E_INVALID_INPUT = "E_INVALID_INPUT"
73
+ E_ROOT_UNAVAILABLE = "E_ROOT_UNAVAILABLE"
74
+ E_WALKER_UNAVAILABLE = "E_WALKER_UNAVAILABLE"
75
+ E_WALKER_EMPTY = "E_WALKER_EMPTY"
76
+ E_DECODE_FAILED = "E_DECODE_FAILED"
77
+ E_DOWNLOAD_FAILED = "E_DOWNLOAD_FAILED"
78
+ E_MANIFEST_WRITE_FAILED = "E_MANIFEST_WRITE_FAILED"
79
+
80
+ # Response body caps (a malicious/broken source must not exhaust memory)
81
+ WALK_MAX_BYTES = 20 * (1 << 20) # 20 MB — UnrollNow pages are ~1 MB today
82
+ DECODE_MAX_BYTES = 5 * (1 << 20) # 5 MB — FixTweet payloads are a few hundred KB
83
+
84
+ # Media downloads are restricted to X's media CDN hosts (defense in depth:
85
+ # media URLs come from third-party decoder payloads and must never be able to
86
+ # make this tool fetch local files or internal network resources).
87
+ MEDIA_HOST_SUFFIX = ".twimg.com"
88
+
89
+ # Shared error sink: stages append here; the orchestrator snapshots into
90
+ # the envelope's `errors` array. Cleared at the start of each harvest.
91
+ ERRORS: list[dict] = []
92
+
93
+ SUPPORTED_HOSTS = {"x.com", "twitter.com", "mobile.x.com", "mobile.twitter.com"}
94
+ TCO_HOSTS = {"t.co", "www.t.co"}
95
+
96
+ # Walker slots, in priority order. Each is a replaceable implementation of
97
+ # the discovery contract: given a root ID, return a page whose status/<id>
98
+ # occurrences are candidate IDs (slot names appear in the envelope's
99
+ # thread.walker_slot so callers know who served the walk).
100
+ WALKER_SLOTS = (
101
+ ("unrollnow", "https://unrollnow.com/status/{root}"),
102
+ ("threadreaderapp", "https://threadreaderapp.com/thread/{root}"),
103
+ )
104
+
105
+
106
+ def log(msg: str) -> None:
107
+ if not LOG_QUIET:
108
+ print(msg, file=sys.stderr)
109
+
110
+
111
+ def record_error(stage: str, code: str, message: str, subject: str | None = None) -> None:
112
+ """Append a structured error to the shared sink (snapshot into envelope)."""
113
+ ERRORS.append({"stage": stage, "code": code, "message": message, "subject": subject})
114
+
115
+
116
+ # ── Input normalization & validation ─────────────────────────────────────────
117
+ class InputError(Exception):
118
+ """Invalid or unsupported input. Carries a stable error code."""
119
+
120
+ def __init__(self, message: str, code: str = E_INVALID_INPUT):
121
+ super().__init__(message)
122
+ self.code = code
123
+
124
+
125
+ def normalize_input(inp: str) -> dict:
126
+ """Parse a status URL or bare status ID into {status_id, canonical_url}.
127
+
128
+ Accepts:
129
+ - https://x.com/<user>/status/<id> (also /statuses/)
130
+ - https://twitter.com/<user>/status/<id> (and mobile./www. forms)
131
+ - trailing /photo/<n>, /video/<n>, /media suffixes and query strings
132
+ - https://x.com/i/web/status/<id>
133
+ - a bare numeric status ID (1-25 digits)
134
+
135
+ Rejects non-status URLs (profiles, hashtags, search, other hosts) with a
136
+ stable error code so callers can branch on failure deterministically.
137
+ """
138
+ text = (inp or "").strip()
139
+ if not text:
140
+ raise InputError("empty input — provide an X/Twitter status URL or status ID")
141
+ if re.fullmatch(r"\d{1,25}", text):
142
+ sid = text
143
+ else:
144
+ raw = text if "://" in text else "https://" + text
145
+ parsed = urllib.parse.urlparse(raw)
146
+ host = (parsed.hostname or "").lower()
147
+ host = host[4:] if host.startswith("www.") else host
148
+ if host not in SUPPORTED_HOSTS:
149
+ raise InputError(
150
+ f"unsupported host '{host or '(none)'}' — expected x.com or twitter.com status URL")
151
+ parts = [p for p in parsed.path.split("/") if p]
152
+ sid = None
153
+ for i, p in enumerate(parts):
154
+ if p in ("status", "statuses"):
155
+ if i + 1 >= len(parts) or not parts[i + 1].isdigit() or len(parts[i + 1]) > 25:
156
+ raise InputError("URL contains /status/ but no valid numeric status ID")
157
+ sid = parts[i + 1]
158
+ break
159
+ if sid is None:
160
+ raise InputError(
161
+ "not a status URL — expected https://x.com/<user>/status/<id> or a bare status ID")
162
+ return {
163
+ "status_id": sid,
164
+ "canonical_url": f"https://x.com/i/web/status/{sid}",
165
+ "input": text,
166
+ }
167
+
168
+
169
+ def is_tco(inp: str) -> bool:
170
+ """True when the input is a t.co shortlink (any scheme-less form too)."""
171
+ text = (inp or "").strip()
172
+ if not text or "://" not in text:
173
+ text = "https://" + text
174
+ try:
175
+ host = (urllib.parse.urlparse(text).hostname or "").lower()
176
+ except ValueError:
177
+ return False
178
+ return host in TCO_HOSTS
179
+
180
+
181
+ def _interstitial_dest(body: str) -> str | None:
182
+ """Extract the destination from a t.co HTML interstitial (t.co serves a
183
+ 200 page embedding the target instead of an HTTP redirect for some
184
+ clients). Handles both shapes it emits: <noscript> meta-refresh and
185
+ location.replace(...). Returns None when neither is present."""
186
+ m = re.search(r"http-equiv=[\"']?refresh[\"']?[^>]*?"
187
+ r"content=[\"'][^\"']*?url=([^\"'>]+)", body, re.I | re.S)
188
+ if m:
189
+ return m.group(1).strip()
190
+ m = re.search(r"location\.replace\(\s*[\"']((?:[^\"'\\]|\\.)*)[\"']\s*\)", body)
191
+ if m:
192
+ return m.group(1).replace("\\/", "/").strip()
193
+ return None
194
+
195
+
196
+ def expand_tco(inp: str, timeout: int = DECODE_TIMEOUT) -> str:
197
+ """Resolve a t.co shortlink to its destination URL (one network hop).
198
+
199
+ t.co is X's canonical URL wrapper; a status link's destination is an
200
+ x.com/twitter.com status URL. Two resolution paths, both honest:
201
+
202
+ 1. HTTP redirect — urllib follows it; the final URL is used.
203
+ 2. HTML interstitial — for some clients t.co answers 200 with a page
204
+ embedding the target in a <noscript> meta-refresh or a
205
+ location.replace() call; the embedded URL is parsed from the body
206
+ (never executed).
207
+
208
+ Raises InputError on failure: network error, an unparseable interstitial,
209
+ or a destination that does not point at a supported X/Twitter host.
210
+ Callers re-normalize the returned URL, so 't.co → non-status page' fails
211
+ with the normal not-a-status-URL error instead of a silent wrong result.
212
+ """
213
+ text = (inp or "").strip()
214
+ if not text:
215
+ raise InputError("empty input — provide an X/Twitter status URL or status ID")
216
+ raw = text if "://" in text else "https://" + text
217
+ try:
218
+ host0 = (urllib.parse.urlparse(raw).hostname or "").lower()
219
+ except ValueError:
220
+ raise InputError(f"not a t.co shortlink: '{inp[:60]}'")
221
+ if host0 not in TCO_HOSTS:
222
+ raise InputError(f"not a t.co shortlink: '{host0 or '(none)'}'")
223
+ try:
224
+ req = urllib.request.Request(raw, headers={"User-Agent": UA, "Accept": "text/html,*/*"})
225
+ with urllib.request.urlopen(req, timeout=timeout) as r:
226
+ body = r.read(1 << 14).decode("utf-8", "replace") # interstitials are tiny
227
+ dest = r.geturl()
228
+ except Exception as e:
229
+ raise InputError(f"t.co resolution failed: {e}")
230
+ host = (urllib.parse.urlparse(dest).hostname or "").lower()
231
+ host = host[4:] if host.startswith("www.") else host
232
+ if host in TCO_HOSTS:
233
+ # final URL is still t.co: dead link, or a non-HTTP-redirect
234
+ # interstitial — parse the embedded target out of the page body
235
+ embedded = _interstitial_dest(body)
236
+ if embedded:
237
+ dest = embedded
238
+ host = (urllib.parse.urlparse(dest).hostname or "").lower()
239
+ host = host[4:] if host.startswith("www.") else host
240
+ else:
241
+ raise InputError("t.co did not redirect to a status URL "
242
+ "(dead link or unparseable interstitial)")
243
+ if host not in SUPPORTED_HOSTS:
244
+ raise InputError(
245
+ f"t.co link does not point at an X/Twitter status URL "
246
+ f"(resolved host '{host or '(none)'}')")
247
+ return dest
248
+
249
+
250
+ def http_get(url: str, timeout: int = DECODE_TIMEOUT, max_bytes: int | None = None) -> bytes:
251
+ req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
252
+ with urllib.request.urlopen(req, timeout=timeout) as r:
253
+ if max_bytes is None:
254
+ return r.read()
255
+ return r.read(max_bytes)
256
+
257
+
258
+ def _media_url_allowed(url: str) -> bool:
259
+ """Only https URLs on X's media CDN may be fetched into the output dir."""
260
+ try:
261
+ parsed = urllib.parse.urlparse(url)
262
+ except ValueError:
263
+ return False
264
+ return (parsed.scheme == "https"
265
+ and bool(parsed.hostname)
266
+ and parsed.hostname.lower().endswith(MEDIA_HOST_SUFFIX))
267
+
268
+
269
+ def _safe_id(value) -> str | None:
270
+ """Remote tweet IDs are used in filenames — only plain digits survive."""
271
+ s = str(value) if value is not None else ""
272
+ return s if re.fullmatch(r"\d{1,25}", s) else None
273
+
274
+
275
+ def _url_is_mp4(url: str | None) -> bool:
276
+ """True when the URL's path ends in .mp4 (query strings excluded)."""
277
+ if not url:
278
+ return False
279
+ try:
280
+ return urllib.parse.urlparse(url).path.lower().endswith(".mp4")
281
+ except ValueError:
282
+ return False
283
+
284
+
285
+ # ── Tier 1: Thread Walker ────────────────────────────────────────────────────
286
+ def _extract_candidate_ids(raw: str, root_id: str) -> list[str]:
287
+ """Extract ordered, deduped candidate IDs from a walker page, with the
288
+ root always kept and the list capped at MAX_CANDIDATES (root never
289
+ dropped by the cap). Shared by every walker slot."""
290
+ ids = re.findall(r"status/(\d{15,25})", raw)
291
+ ids += re.findall(r"\b(21\d{17,22})\b", raw) # bare snowflake-ish ids
292
+ seen, ordered = set(), []
293
+ for i in ids:
294
+ if i not in seen:
295
+ seen.add(i)
296
+ ordered.append(i)
297
+ if root_id in seen:
298
+ # Walker order is the candidate spine; keep it, root at its seen spot.
299
+ candidates = ordered
300
+ else:
301
+ candidates = [root_id] + ordered # root guarantee (e.g. short legacy IDs)
302
+ if len(candidates) > MAX_CANDIDATES:
303
+ # Cap without ever dropping the root.
304
+ rest = [c for c in candidates if c != root_id]
305
+ keep_rest = rest[:MAX_CANDIDATES - 1]
306
+ log(f"[warn] {len(candidates)} candidates capped to {1 + len(keep_rest)}")
307
+ candidates = ([root_id] if root_id not in seen else []) + keep_rest
308
+ if root_id not in candidates:
309
+ candidates = [root_id] + candidates[:MAX_CANDIDATES - 1]
310
+ return candidates
311
+
312
+
313
+ def _walk_slot(slot: str, url: str, root_id: str) -> list[str] | None:
314
+ """Query one walker slot. Returns its candidate list, or None when the
315
+ slot failed (network) or yielded no conversation candidates (page-shape
316
+ drift) — both are 'slot down' signals for the caller's fallback logic.
317
+ The root guarantee is enforced by _extract_candidate_ids either way."""
318
+ try:
319
+ raw = http_get(url, timeout=WALK_TIMEOUT,
320
+ max_bytes=WALK_MAX_BYTES).decode("utf-8", "replace")
321
+ except Exception as e:
322
+ log(f"[warn] walker slot '{slot}' failed ({e})")
323
+ record_error("walker", E_WALKER_UNAVAILABLE,
324
+ f"walker slot '{slot}' unavailable: {e}", subject=root_id)
325
+ return None
326
+ candidates = _extract_candidate_ids(raw, root_id)
327
+ if len(candidates) <= 1:
328
+ log(f"[warn] walker slot '{slot}' returned no conversation candidates "
329
+ "(page layout change?)")
330
+ record_error("walker", E_WALKER_EMPTY,
331
+ f"walker slot '{slot}' returned no conversation candidates",
332
+ subject=root_id)
333
+ return None
334
+ log(f"[ok ] walker slot '{slot}': {len(candidates)} candidate ids")
335
+ return candidates
336
+
337
+
338
+ def resolve_thread_ids(root_id: str) -> tuple[list[str], str]:
339
+ """Walk the thread via the walker slots, in order; root only when all fail.
340
+
341
+ Slot 1 is UnrollNow, slot 2 is ThreadReaderApp. Both embed the raw page
342
+ conversation plus noise (media (amplify) IDs, same-author recommendations,
343
+ unrelated shares) — their output is strictly *candidates*. A later
344
+ FixTweet 404 on an ID is the signal that the ID was a media ID
345
+ (404 = filter, not failure), and chain reconstruction (downstream)
346
+ excludes recommendations.
347
+
348
+ The root ID is ALWAYS present in the returned list (prepended if the
349
+ slots' pages missed it — e.g. short legacy IDs the regex cannot see).
350
+
351
+ Returns (candidates, walker_slot) where walker_slot names the slot that
352
+ served the walk, or "none" when every slot failed and the walk degraded
353
+ to root-only. Politeness: at most one request per slot per run.
354
+ """
355
+ for name, template in WALKER_SLOTS:
356
+ candidates = _walk_slot(name, template.format(root=root_id), root_id)
357
+ if candidates:
358
+ return candidates, name
359
+ log("[warn] all walker slots failed — degrading to root only")
360
+ return [root_id], "none"
361
+
362
+
363
+ # ── Tier 2: Metadata Decoder ─────────────────────────────────────────────────
364
+ def _fetch_fxtweet(tid: str, tries: int) -> tuple[dict | None, str]:
365
+ """FixTweet slot. Returns (tweet_payload | None, outcome) where outcome is
366
+ 'ok' | 'unavailable' (clean 404 — filter signal, never retried) | 'failed'."""
367
+ url = f"https://api.fxtwitter.com/status/{tid}"
368
+ for attempt in range(1, tries + 1):
369
+ try:
370
+ body = http_get(url, timeout=DECODE_TIMEOUT, max_bytes=DECODE_MAX_BYTES)
371
+ data = json.loads(body.decode("utf-8"))
372
+ if data.get("code") == 200 and isinstance(data.get("tweet"), dict):
373
+ return data["tweet"], "ok"
374
+ if data.get("code") == 404:
375
+ return None, "unavailable" # media id or unavailable — filter silently
376
+ log(f"[..] {tid}: code={data.get('code')} (try {attempt})")
377
+ except urllib.error.HTTPError as e:
378
+ # Unavailability can arrive as a REAL HTTP 404/451 status, not just
379
+ # a 200-body — both are filter signals, never retry-worthy.
380
+ if e.code in (404, 451):
381
+ if e.code != 404:
382
+ log(f"[skip] {tid}: HTTP {e.code} (unavailable)")
383
+ return None, "unavailable"
384
+ log(f"[..] {tid}: HTTP {e.code} (try {attempt})")
385
+ except Exception as e:
386
+ log(f"[..] {tid}: {e} (try {attempt})")
387
+ if attempt < tries:
388
+ time.sleep(2 * attempt)
389
+ return None, "failed"
390
+
391
+
392
+ def _normalize_vxtweet(vx: dict, depth: int = 0) -> dict | None:
393
+ """Normalize a vxtwitter payload into the FixTweet-shaped subset we consume.
394
+ Fields the fallback does not provide become explicit nulls (honest subset)."""
395
+ if not isinstance(vx, dict) or not vx.get("tweetID"):
396
+ return None
397
+ media = {"photos": [], "videos": []}
398
+ for m in vx.get("media_extended") or []:
399
+ if not isinstance(m, dict):
400
+ continue
401
+ if m.get("type") == "photo":
402
+ media["photos"].append({"url": m.get("url"), "alt_text": None,
403
+ "width": m.get("width"), "height": m.get("height")})
404
+ elif m.get("type") == "video":
405
+ media["videos"].append({"url": m.get("url"),
406
+ "thumbnail_url": m.get("thumbnail_url"),
407
+ "duration": m.get("duration"),
408
+ "width": m.get("width"), "height": m.get("height")})
409
+ norm = {
410
+ "id": str(vx.get("tweetID")),
411
+ "url": vx.get("tweetURL"),
412
+ "text": vx.get("text"),
413
+ "created_at": vx.get("date"),
414
+ "created_timestamp": vx.get("date_epoch"),
415
+ "lang": vx.get("lang"),
416
+ "author": {"screen_name": vx.get("user_screen_name"),
417
+ "name": vx.get("user_name"),
418
+ "avatar_url": vx.get("user_avatar_url")},
419
+ "media": media,
420
+ "likes": vx.get("likes"), "retweets": vx.get("retweets"),
421
+ "replies": vx.get("replies"), "views": None,
422
+ "quotes": None, "bookmarks": None,
423
+ "replying_to": vx.get("replyingTo"),
424
+ "replying_to_status": vx.get("replyingToID"),
425
+ "quote": None,
426
+ "_extraction_source": "vxtwitter",
427
+ }
428
+ if depth < 1 and isinstance(vx.get("qrt"), dict):
429
+ norm["quote"] = _normalize_vxtweet(vx["qrt"], depth + 1)
430
+ return norm
431
+
432
+
433
+ def _fetch_vxtweet(tid: str, tries: int = 2) -> dict | None:
434
+ """Fallback decoder slot (vxtwitter). Used ONLY when the primary decoder
435
+ fails network-side — a clean FixTweet 404 is trusted as a filter signal."""
436
+ url = f"https://api.vxtwitter.com/status/{tid}"
437
+ for attempt in range(1, tries + 1):
438
+ try:
439
+ body = http_get(url, timeout=DECODE_TIMEOUT, max_bytes=DECODE_MAX_BYTES)
440
+ data = json.loads(body.decode("utf-8"))
441
+ norm = _normalize_vxtweet(data)
442
+ if norm is not None:
443
+ log(f"[ok ] {tid}: decoded via fallback decoder")
444
+ return norm
445
+ log(f"[..] {tid}: fallback returned unusable payload (try {attempt})")
446
+ except urllib.error.HTTPError as e:
447
+ if e.code in (404, 451):
448
+ return None # genuinely unavailable — do not retry
449
+ log(f"[..] {tid}: fallback HTTP {e.code} (try {attempt})")
450
+ except Exception as e:
451
+ log(f"[..] {tid}: fallback {e} (try {attempt})")
452
+ if attempt < tries:
453
+ time.sleep(2 * attempt)
454
+ return None
455
+
456
+
457
+ def fetch_tweet(tid: str, tries: int = 3) -> dict | None:
458
+ """Decode one tweet. Returns the payload dict, or None if the ID is not a
459
+ live tweet (filter) or both decoder slots failed (error recorded)."""
460
+ tw, outcome = _fetch_fxtweet(tid, tries)
461
+ if outcome == "ok":
462
+ if _safe_id(tw.get("id")) is None:
463
+ log(f"[warn] {tid}: decoder returned a bogus tweet id — rejected")
464
+ return None
465
+ tw["_extraction_source"] = "fxtwitter"
466
+ return tw
467
+ if outcome == "unavailable":
468
+ log(f"[skip] {tid}: not a tweet (media id or unavailable)")
469
+ return None
470
+ fallback = _fetch_vxtweet(tid)
471
+ if fallback is not None:
472
+ return fallback
473
+ log(f"[warn] {tid}: decode failed after {tries} attempts (both slots)")
474
+ record_error("decoder", E_DECODE_FAILED,
475
+ f"decode failed after {tries} attempts (primary + fallback)", subject=tid)
476
+ return None
477
+
478
+
479
+ # ── Thread reconstruction ────────────────────────────────────────────────────
480
+ def reconstruct_thread(root_id: str, decoded: dict, fetch,
481
+ max_ancestors: int = MAX_ANCESTORS,
482
+ inter_fetch_sleep: float = DECODE_SLEEP) -> tuple[list[dict], dict]:
483
+ """Rebuild the true self-reply chain from decoded payloads.
484
+
485
+ Chain membership is decided by `replying_to_status`, NOT by page order:
486
+ - walk UP from the requested root to the true thread start (fetching
487
+ missing ancestors through the decoder, same author only),
488
+ - walk DOWN through self-replies whose parent is already in the chain.
489
+
490
+ Decoded tweets that never chain (same-author recommendations, other
491
+ replies) are excluded. Returns (ordered_payloads, stats).
492
+ """
493
+ root_tw = decoded[root_id]
494
+ author = (root_tw.get("author") or {}).get("screen_name")
495
+
496
+ ancestors: list[dict] = []
497
+ seen = {root_id}
498
+ cur = root_tw
499
+ while len(ancestors) < max_ancestors:
500
+ raw_parent = cur.get("replying_to_status")
501
+ parent_id = str(raw_parent) if raw_parent else None
502
+ if not parent_id or parent_id in seen:
503
+ break
504
+ # Reuse an already-decoded ancestor before spending a network call.
505
+ parent = decoded.get(parent_id)
506
+ if parent is None:
507
+ if inter_fetch_sleep:
508
+ time.sleep(inter_fetch_sleep)
509
+ parent = fetch(parent_id)
510
+ if parent is None:
511
+ break # cannot verify ancestry — stop honestly
512
+ if ((parent.get("author") or {}).get("screen_name")) != author:
513
+ break # crossed out of the self-reply chain
514
+ ancestors.append(parent)
515
+ seen.add(parent_id)
516
+ decoded[parent_id] = parent # cache so stats stay honest
517
+ cur = parent
518
+
519
+ children: dict[str, list[dict]] = {}
520
+ for tid, tw in decoded.items():
521
+ if tid == root_id or tid in seen:
522
+ continue
523
+ rts = tw.get("replying_to_status")
524
+ if rts:
525
+ children.setdefault(str(rts), []).append(tw)
526
+
527
+ chain = list(reversed(ancestors)) + [root_tw]
528
+ cur = root_id
529
+ while True:
530
+ kids = [k for k in children.get(cur, [])
531
+ if (k.get("author") or {}).get("screen_name") == author
532
+ and k.get("id") not in seen]
533
+ if not kids:
534
+ break
535
+ nxt = kids[0] # first-seen candidate order; linear chain approximation
536
+ chain.append(nxt)
537
+ seen.add(nxt.get("id"))
538
+ cur = nxt.get("id")
539
+
540
+ stats = {"ancestors_fetched": len(ancestors), "chain_length": len(chain)}
541
+ return chain, stats
542
+
543
+
544
+ # ── Payload mapping (FixTweet/vxtwitter → stable schema) ─────────────────────
545
+ def _iso_from(unix_ts) -> str | None:
546
+ try:
547
+ if unix_ts is None:
548
+ return None
549
+ return datetime.fromtimestamp(int(unix_ts), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
550
+ except (ValueError, TypeError, OverflowError, OSError):
551
+ return None
552
+
553
+
554
+ def map_author(a: dict | None) -> dict:
555
+ a = a or {}
556
+ ver = a.get("verification") or {}
557
+ website = a.get("website") or {}
558
+ return {
559
+ "id": a.get("id"),
560
+ "screen_name": a.get("screen_name"),
561
+ "name": a.get("name"),
562
+ "description": a.get("description"),
563
+ "location": a.get("location") or None,
564
+ "avatar_url": a.get("avatar_url"),
565
+ "banner_url": a.get("banner_url"),
566
+ "followers": a.get("followers"),
567
+ "following": a.get("following"),
568
+ "media_count": a.get("media_count"),
569
+ "verified": bool(ver.get("verified", a.get("verified") or False)),
570
+ "protected": bool(a.get("protected", False)),
571
+ "joined": a.get("joined"),
572
+ "website": {"url": website.get("url"), "display_url": website.get("display_url")}
573
+ if website.get("url") else None,
574
+ }
575
+
576
+
577
+ def _best_mp4_variant(v: dict) -> dict | None:
578
+ """Pick the highest-bitrate mp4 variant from formats[]/variants[]."""
579
+ variants = []
580
+ for f in (v.get("formats") or []):
581
+ if isinstance(f, dict) and (f.get("container") == "mp4"
582
+ or _url_is_mp4(f.get("url"))):
583
+ variants.append({"url": f.get("url"), "bitrate": f.get("bitrate") or 0,
584
+ "codec": f.get("codec"), "container": "mp4"})
585
+ if not variants:
586
+ return None
587
+ variants.sort(key=lambda x: x["bitrate"] or 0, reverse=True)
588
+ return variants[0]
589
+
590
+
591
+ def map_video(v: dict) -> dict:
592
+ v = v or {}
593
+ url = v.get("url")
594
+ playlist_url = None
595
+ chosen = url
596
+ if url and ".m3u8" in url:
597
+ best = _best_mp4_variant(v)
598
+ if best and best.get("url"):
599
+ playlist_url, chosen = url, best["url"]
600
+ variants = []
601
+ for f in (v.get("formats") or [])[:10]:
602
+ if isinstance(f, dict) and f.get("url"):
603
+ variants.append({"url": f["url"], "container": f.get("container"),
604
+ "bitrate": f.get("bitrate"), "codec": f.get("codec")})
605
+ return {
606
+ "url": chosen,
607
+ "playlist_url": playlist_url,
608
+ "poster_url": v.get("thumbnail_url"),
609
+ "format": v.get("format") or ("video/mp4" if _url_is_mp4(chosen) else None),
610
+ "duration": v.get("duration"),
611
+ "width": v.get("width"), "height": v.get("height"),
612
+ "variants": variants,
613
+ "file": None, "poster_file": None, "downloaded": False,
614
+ "downloadable": _url_is_mp4(chosen),
615
+ "reason": None if _url_is_mp4(chosen)
616
+ else ("hls_only" if (playlist_url or (chosen and ".m3u8" in chosen))
617
+ else "no_mp4_variant"),
618
+ }
619
+
620
+
621
+ def map_photo(p: dict) -> dict:
622
+ p = p or {}
623
+ return {
624
+ "url": p.get("url"),
625
+ "alt_text": p.get("alt_text"),
626
+ "width": p.get("width"), "height": p.get("height"),
627
+ "file": None, "downloaded": False,
628
+ }
629
+
630
+
631
+ def map_tweet(tw: dict, position: int | None = None, depth: int = 0) -> dict:
632
+ tw = tw or {}
633
+ media = tw.get("media") or {}
634
+ photos, seen_urls = [], set()
635
+ for p in (media.get("photos") or []):
636
+ if isinstance(p, dict) and p.get("url") and p["url"] not in seen_urls:
637
+ seen_urls.add(p["url"])
638
+ photos.append(map_photo(p))
639
+ videos = []
640
+ for v in (media.get("videos") or []):
641
+ if isinstance(v, dict) and v.get("url"):
642
+ mv = map_video(v)
643
+ if mv["url"] not in seen_urls:
644
+ seen_urls.add(mv["url"])
645
+ videos.append(mv)
646
+ tweet_id = str(tw.get("id")) if tw.get("id") is not None else None
647
+ screen = ((tw.get("author") or {}).get("screen_name")) or None
648
+ out = {
649
+ "id": tweet_id,
650
+ "url": tw.get("url") or (f"https://x.com/{screen}/status/{tweet_id}"
651
+ if screen and tweet_id else None),
652
+ "text": tw.get("text"),
653
+ "lang": tw.get("lang"),
654
+ "source": tw.get("source"),
655
+ "created_at": tw.get("created_at"),
656
+ "created_at_iso": _iso_from(tw.get("created_timestamp")),
657
+ "thread_position": position,
658
+ "replying_to": tw.get("replying_to"),
659
+ "replying_to_status": (str(tw["replying_to_status"])
660
+ if tw.get("replying_to_status") else None),
661
+ "extraction_source": tw.get("_extraction_source"),
662
+ "metrics": {
663
+ "likes": tw.get("likes"), "retweets": tw.get("retweets"),
664
+ "replies": tw.get("replies"), "quotes": tw.get("quotes"),
665
+ "bookmarks": tw.get("bookmarks"), "views": tw.get("views"),
666
+ },
667
+ "author": map_author(tw.get("author")),
668
+ "media": {"photos": photos, "videos": videos},
669
+ "quoted_post": None,
670
+ }
671
+ if depth < 1 and isinstance(tw.get("quote"), dict) and tw["quote"].get("id"):
672
+ out["quoted_post"] = map_tweet(tw["quote"], position=None, depth=depth + 1)
673
+ return out
674
+
675
+
676
+ # ── Tier 3: Media Fetcher ────────────────────────────────────────────────────
677
+ def _ext_from_url(url: str, default: str) -> str:
678
+ """Derive a safe file extension from a CDN URL (handles twimg size
679
+ suffixes like '...jpg:large' and '?format=jpg' query forms)."""
680
+ parsed = urllib.parse.urlparse(url)
681
+ path = parsed.path.split(":")[0] # strip twimg ':large'-style size suffixes
682
+ suffix = Path(path).suffix.lower()
683
+ if suffix in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".mp4"):
684
+ return suffix
685
+ query = urllib.parse.parse_qs(parsed.query)
686
+ fmt = (query.get("format") or [None])[0]
687
+ if fmt and f".{fmt.lower()}" in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
688
+ return f".{fmt.lower()}"
689
+ return default
690
+
691
+
692
+ def download(url: str, dest: Path, tries: int = 3) -> bool:
693
+ """Resumable CDN fetch (skip-if-exists), streamed to a .part file and
694
+ atomically renamed — a file is either fully written or absent."""
695
+ if not _media_url_allowed(url):
696
+ log(f"[err] refusing non-CDN media URL: {url}")
697
+ record_error("fetcher", E_DOWNLOAD_FAILED,
698
+ "refusing non-CDN media URL (scheme/host not allowed)", subject=url)
699
+ return False
700
+ dest.parent.mkdir(parents=True, exist_ok=True)
701
+ if dest.exists() and dest.stat().st_size > 0:
702
+ log(f"[skip] {dest.name} exists")
703
+ return True
704
+ part = dest.with_name(dest.name + ".part")
705
+ for attempt in range(1, tries + 1):
706
+ try:
707
+ req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
708
+ started = time.time()
709
+ with urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT) as r, \
710
+ open(part, "wb") as f:
711
+ expected = r.headers.get("Content-Length")
712
+ expected = int(expected) if expected and expected.isdigit() else None
713
+ while True:
714
+ if time.time() - started > DOWNLOAD_TIMEOUT:
715
+ raise IOError("transfer deadline exceeded (slow drip)")
716
+ chunk = r.read(1 << 20)
717
+ if not chunk:
718
+ break
719
+ f.write(chunk)
720
+ size = part.stat().st_size
721
+ if size == 0:
722
+ raise IOError("empty response body")
723
+ if expected is not None and size != expected:
724
+ raise IOError(f"truncated transfer: got {size}, expected {expected} bytes")
725
+ os.replace(part, dest) # atomic: fully-written or absent
726
+ log(f"[dl ] {dest.name} {size / 1e6:.2f} MB")
727
+ return True
728
+ except Exception as e:
729
+ log(f"[err] {dest.name}: {e} (try {attempt})")
730
+ if attempt < tries:
731
+ time.sleep(2 * attempt)
732
+ try:
733
+ part.unlink()
734
+ except OSError:
735
+ pass
736
+ record_error("fetcher", E_DOWNLOAD_FAILED, f"download failed after {tries} attempts",
737
+ subject=url)
738
+ return False
739
+
740
+
741
+ # ── Orchestrator ─────────────────────────────────────────────────────────────
742
+ def _download_post_media(post: dict, out: Path, do_download: bool) -> None:
743
+ """Populate local file fields. `downloaded` is True only when the file is
744
+ actually on disk (with --no-download it stays False and `file` is null)."""
745
+ tid = _safe_id(post.get("id"))
746
+ if tid is None:
747
+ return # remote-controlled ids must never reach the filesystem
748
+ for i, v in enumerate(post["media"]["videos"], 1):
749
+ if not v["downloadable"]:
750
+ continue # reason already set by map_video
751
+ vname = f"{tid}_v{i}{_ext_from_url(v['url'], '.mp4')}"
752
+ poster_ext = _ext_from_url(v.get("poster_url") or "", ".jpg")
753
+ tname = f"{tid}_v{i}_poster{poster_ext}"
754
+ if do_download:
755
+ v["file"] = vname
756
+ v["downloaded"] = download(v["url"], out / vname)
757
+ if v.get("poster_url") and download(v["poster_url"], out / tname):
758
+ v["poster_file"] = tname
759
+ for i, p in enumerate(post["media"]["photos"], 1):
760
+ pname = f"{tid}_p{i}{_ext_from_url(p['url'], '.jpg')}"
761
+ if do_download:
762
+ p["file"] = pname
763
+ p["downloaded"] = download(p["url"], out / pname)
764
+
765
+
766
+ def harvest(root_id: str, out: Path, do_download: bool = True,
767
+ request_info: dict | None = None,
768
+ decode_sleep: float = DECODE_SLEEP) -> dict:
769
+ """Walk → decode → reconstruct → map → fetch → envelope.
770
+
771
+ Returns the enveloped manifest document and writes it to
772
+ `<out>/thread_manifest.json` (UTF-8).
773
+ """
774
+ ERRORS.clear()
775
+ out.mkdir(parents=True, exist_ok=True)
776
+ started = time.time()
777
+
778
+ root_tw = fetch_tweet(root_id)
779
+ if root_tw is None:
780
+ record_error("decoder", E_ROOT_UNAVAILABLE,
781
+ "root tweet unavailable (deleted, protected, or not a tweet)",
782
+ subject=root_id)
783
+ envelope = {
784
+ "schema_version": SCHEMA_VERSION,
785
+ "source": {"tool": "xthread-agent", "version": __version__,
786
+ "generated_at": _iso_from(time.time())},
787
+ "request": {"input": (request_info or {}).get("input"),
788
+ "status_id": root_id,
789
+ "canonical_url": (request_info or {}).get("canonical_url"),
790
+ "options": {"download_media": do_download}},
791
+ "status": "empty",
792
+ "thread": {"root_status_id": root_id, "tweet_count": 0,
793
+ "walker_candidates": 0, "decoded_tweets": 0,
794
+ "media_ids_filtered": 0, "decode_failed": 0,
795
+ "related_filtered": 0,
796
+ "ancestors_fetched": 0, "chain_reconstructed": False,
797
+ "degraded_to_root_only": False,
798
+ "walker_slot": "none"},
799
+ "posts": [],
800
+ "errors": [dict(e) for e in ERRORS],
801
+ "metadata": {"duration_sec": round(time.time() - started, 1),
802
+ "counts": {"posts": 0, "photos": 0, "videos": 0,
803
+ "downloaded_media": 0, "failed_downloads": 0}},
804
+ }
805
+ _write_manifest(out, envelope)
806
+ return envelope
807
+
808
+ candidates, walker_slot = resolve_thread_ids(root_id)
809
+ decoded = {root_id: root_tw}
810
+ media_ids_filtered = 0
811
+ decode_failed = 0
812
+ for tid in candidates:
813
+ if tid in decoded:
814
+ continue
815
+ if decode_sleep:
816
+ time.sleep(decode_sleep)
817
+ tw = fetch_tweet(tid)
818
+ if tw is None:
819
+ # distinguish honest filter signals (media ids, unavailable) from
820
+ # hard decode failures (already recorded in ERRORS)
821
+ if any(e["code"] == E_DECODE_FAILED and e.get("subject") == tid
822
+ for e in ERRORS):
823
+ decode_failed += 1
824
+ else:
825
+ media_ids_filtered += 1
826
+ continue
827
+ decoded[tid] = tw
828
+
829
+ chain, chain_stats = reconstruct_thread(
830
+ root_id, decoded, fetch_tweet, inter_fetch_sleep=decode_sleep)
831
+ chain_in_decoded = sum(1 for tw in chain if str(tw.get("id")) in decoded)
832
+ related_filtered = max(0, len(decoded) - chain_in_decoded)
833
+
834
+ posts = [map_tweet(tw, position=i) for i, tw in enumerate(chain)]
835
+ if do_download:
836
+ for post in posts:
837
+ _download_post_media(post, out, do_download=True)
838
+
839
+ counts = {
840
+ "posts": len(posts),
841
+ "photos": sum(len(p["media"]["photos"]) for p in posts),
842
+ "videos": sum(len(p["media"]["videos"]) for p in posts),
843
+ "downloaded_media": sum(
844
+ sum(1 for x in p["media"]["photos"] + p["media"]["videos"] if x.get("downloaded"))
845
+ for p in posts),
846
+ "failed_downloads": sum(
847
+ sum(1 for x in p["media"]["photos"] + p["media"]["videos"]
848
+ if x.get("file") is not None and not x.get("downloaded"))
849
+ for p in posts),
850
+ }
851
+ status = "ok" if posts and not ERRORS else ("partial" if posts else "empty")
852
+ envelope = {
853
+ "schema_version": SCHEMA_VERSION,
854
+ "source": {"tool": "xthread-agent", "version": __version__,
855
+ "generated_at": _iso_from(time.time())},
856
+ "request": {"input": (request_info or {}).get("input"),
857
+ "status_id": root_id,
858
+ "canonical_url": (request_info or {}).get("canonical_url"),
859
+ "options": {"download_media": do_download}},
860
+ "status": status,
861
+ "thread": {"root_status_id": root_id,
862
+ "tweet_count": len(posts),
863
+ "walker_candidates": len(candidates),
864
+ "decoded_tweets": len(decoded),
865
+ "media_ids_filtered": media_ids_filtered,
866
+ "decode_failed": decode_failed,
867
+ "related_filtered": related_filtered,
868
+ "ancestors_fetched": chain_stats["ancestors_fetched"],
869
+ "chain_reconstructed": True,
870
+ "degraded_to_root_only": walker_slot == "none",
871
+ "walker_slot": walker_slot},
872
+ "posts": posts,
873
+ "errors": [dict(e) for e in ERRORS],
874
+ "metadata": {"duration_sec": round(time.time() - started, 1),
875
+ "counts": counts},
876
+ }
877
+ _write_manifest(out, envelope)
878
+ return envelope
879
+
880
+
881
+ def _write_manifest(out: Path, envelope: dict) -> None:
882
+ try:
883
+ final = out / "thread_manifest.json"
884
+ part = out / "thread_manifest.json.part"
885
+ part.write_text(json.dumps(envelope, indent=2, ensure_ascii=False),
886
+ encoding="utf-8")
887
+ os.replace(part, final) # atomic: the manifest is never half-written
888
+ except OSError as e:
889
+ record_error("orchestrator", E_MANIFEST_WRITE_FAILED, str(e))
890
+ log(f"[err] manifest write failed: {e}")
891
+ raise
892
+
893
+
894
+ def main() -> int:
895
+ ap = argparse.ArgumentParser(
896
+ prog="xthread-agent",
897
+ description="X/Twitter thread content & media harvester — no login, no API keys, no browser.")
898
+ ap.add_argument("status", nargs="?", help="status URL or id")
899
+ ap.add_argument("--out", default="x_thread_media", help="output directory")
900
+ ap.add_argument("--no-download", action="store_true",
901
+ help="manifest only — skip media download")
902
+ ap.add_argument("--json", action="store_true",
903
+ help="print a machine-readable summary to stdout (logs stay on stderr)")
904
+ ap.add_argument("--quiet", action="store_true", help="suppress log lines")
905
+ ap.add_argument("--version", action="store_true", help="print version and exit")
906
+ args = ap.parse_args()
907
+
908
+ if args.version:
909
+ print(__version__)
910
+ return 0
911
+ if not args.status:
912
+ ap.error("status url or id is required")
913
+
914
+ global LOG_QUIET
915
+ LOG_QUIET = args.quiet or args.json
916
+ started = time.time()
917
+
918
+ try:
919
+ try:
920
+ info = normalize_input(args.status)
921
+ except InputError as first_err:
922
+ # t.co shortlinks are valid status inputs: resolve one hop, then
923
+ # re-parse the destination. Any other rejection stands as-is.
924
+ if is_tco(args.status):
925
+ dest = expand_tco(args.status)
926
+ log(f"[ok ] t.co resolved -> {dest}")
927
+ info = normalize_input(dest)
928
+ else:
929
+ raise first_err
930
+ except InputError as e:
931
+ if args.json:
932
+ print(json.dumps({"ok": False, "status": "invalid_input",
933
+ "error": {"code": e.code, "message": str(e)}}))
934
+ return 1
935
+ print(f"error: {e}", file=sys.stderr)
936
+ return 1
937
+
938
+ try:
939
+ envelope = harvest(info["status_id"], Path(args.out),
940
+ do_download=not args.no_download, request_info=info)
941
+ except Exception as e: # noqa: BLE001 — the CLI must never leak a traceback
942
+ if not args.json:
943
+ import traceback
944
+ traceback.print_exc(file=sys.stderr) # full detail for humans
945
+ if args.json:
946
+ print(json.dumps({"ok": False, "status": "error",
947
+ "error": {"code": E_MANIFEST_WRITE_FAILED,
948
+ "message": f"{type(e).__name__}: {e}"}}))
949
+ return 1
950
+ print(f"error: {e}", file=sys.stderr)
951
+ return 1
952
+
953
+ counts = envelope["metadata"]["counts"]
954
+ summary = {
955
+ "ok": counts["posts"] > 0,
956
+ "status": envelope["status"],
957
+ "root_id": info["status_id"],
958
+ "canonical_url": info["canonical_url"],
959
+ "tweets": counts["posts"],
960
+ "videos": counts["videos"],
961
+ "photos": counts["photos"],
962
+ "downloaded": counts["downloaded_media"],
963
+ "failed_downloads": counts["failed_downloads"],
964
+ "out_dir": str(Path(args.out)),
965
+ "manifest_path": str(Path(args.out) / "thread_manifest.json"),
966
+ "errors": len(envelope["errors"]),
967
+ "duration_sec": round(time.time() - started, 1),
968
+ }
969
+ if args.json:
970
+ print(json.dumps(summary, indent=2))
971
+ else:
972
+ # human summary lines go to STDERR (stdout stays data-only, pipe-safe)
973
+ log(f"\n[done] {counts['posts']} posts · {counts['videos']} videos · "
974
+ f"{counts['photos']} photos · {counts['downloaded_media']} files "
975
+ f"-> {summary['manifest_path']}")
976
+ for err in envelope["errors"]:
977
+ log(f"[warn] {err['stage']}: {err['code']} — {err['message']}")
978
+ return 0 if summary["ok"] else 1
979
+
980
+
981
+ if __name__ == "__main__":
982
+ sys.exit(main())