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.
- xthread_agent/__init__.py +982 -0
- xthread_agent/__main__.py +6 -0
- xthread_agent-3.2.0.dist-info/METADATA +571 -0
- xthread_agent-3.2.0.dist-info/RECORD +8 -0
- xthread_agent-3.2.0.dist-info/WHEEL +5 -0
- xthread_agent-3.2.0.dist-info/entry_points.txt +2 -0
- xthread_agent-3.2.0.dist-info/licenses/LICENSE +21 -0
- xthread_agent-3.2.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|