threadstractormf 1.4.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,445 @@
1
+ """Discover the current GraphQL request parameters from the live web app.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ The Threads profile-media query is a *persisted query*: the client sends no
6
+ query text, only ``fb_api_req_friendly_name`` plus the ``doc_id`` that indexes
7
+ the server-side copy. When Meta edits the query it registers a **new** doc_id
8
+ and the old one stops resolving — the server answers
9
+
10
+ {"errors": [{"message": "execution error", "severity": "CRITICAL"}]}
11
+
12
+ which says nothing about what went wrong. That is exactly what a hardcoded
13
+ constant cannot survive: v1.2.0 shipped ``doc_id 37598244946487292`` and it
14
+ went stale, so every scrape silently fell through to the slow DOM fallback.
15
+
16
+ The id is a server-side index. It cannot be derived, guessed, or computed — the
17
+ only source of truth is the request the web app itself issues. So we watch that
18
+ request once, keep it, and replay it.
19
+
20
+ Measured, not assumed
21
+ ---------------------
22
+ Comparing the page-1 request against page-3 of the same scroll (2026-10):
23
+
24
+ * identical across pages: ``doc_id``, ``fb_dtsg``, ``lsd``, ``jazoest``,
25
+ ``__rev``, ``__s``, ``__hsi``, ``av``, ``__crn``, ``__spin_*``, ``__user``
26
+ * different per request: ``__csr``, ``__dyn``, ``__hsdp``, ``__sjsp``,
27
+ ``__req`` (a counter) and ``variables``
28
+
29
+ Replaying with only the stable set — the per-request nonces dropped, cursor
30
+ moved to page 2 — returned 10 edges and 0 errors. So a captured template is
31
+ reusable for every page of the walk.
32
+
33
+ The nonces are still stored. They are anti-abuse tokens rather than part of the
34
+ query, and keeping them costs nothing; if a future deploy starts demanding them
35
+ per request, re-using the captured values is the first thing to try.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import hashlib
41
+ import json
42
+ import os
43
+ import re
44
+ import time
45
+ from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+ from typing import Any, cast
48
+ from urllib.parse import parse_qs
49
+
50
+ # The query we need a template for. Stable across doc_id rotations: Meta reuses
51
+ # the friendly name and issues a new id.
52
+ MEDIA_QUERY_NAME = "BarcelonaProfileMediaTabRefetchableDirectQuery"
53
+
54
+ _GRAPHQL_PATH = "https://www.threads.com/graphql/query"
55
+
56
+ # Where the capture lives. XDG, with the documented fallbacks, so the template
57
+ # survives across CLI invocations — the whole point is to pay for the browser
58
+ # once, not once per process.
59
+ _CACHE_SUBDIR = "threadstractormf"
60
+ _CACHE_FILENAME = "request_template.json"
61
+
62
+ # How long an unused template is kept. The tokens in it are session-bound and
63
+ # therefore dead long before this, so it is a grace period for convenience,
64
+ # not for correctness: recapturing costs one browser launch.
65
+ _CACHE_MAX_AGE_DAYS = 14
66
+
67
+ _UA = (
68
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
69
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
70
+ )
71
+
72
+ # Only these are replayed. Verified stable across pages; see the module
73
+ # docstring. Anything else the browser sends is a per-request nonce.
74
+ STABLE_FIELDS = (
75
+ "doc_id",
76
+ "fb_api_req_friendly_name",
77
+ "fb_api_caller_class",
78
+ "fb_dtsg",
79
+ "lsd",
80
+ "jazoest",
81
+ "av",
82
+ "__user",
83
+ "__a",
84
+ "__rev",
85
+ "__s",
86
+ "__hsi",
87
+ "__hs",
88
+ "__crn",
89
+ "__spin_r",
90
+ "__spin_b",
91
+ "__spin_t",
92
+ "dpr",
93
+ "__ccg",
94
+ "server_timestamps",
95
+ )
96
+
97
+ # Kept but not replayed by default: re-used as a base if the stable set ever
98
+ # stops being enough.
99
+ NONCE_FIELDS = ("__csr", "__dyn", "__hsdp", "__sjsp", "__hblp", "__comet_req")
100
+
101
+
102
+ def session_fingerprint(cookies: Any) -> str:
103
+ """A stable key for *which account* a captured template belongs to.
104
+
105
+ Why this exists: the captured request carries session-bound tokens — ``lsd``,
106
+ ``fb_dtsg``, ``__s``, ``__hsi``, and ``av`` (the caller's own id). Replaying
107
+ them under a different account's cookies gets the query refused, and each
108
+ refusal rediscovered and overwrote the file, so two users sharing a machine
109
+ traded the cache back and forth and every scrape paid the browser.
110
+
111
+ Keyed by a hash of the session cookie rather than the raw value: the cache
112
+ file is user-readable, and a sessionid is a live credential.
113
+ """
114
+ values: list[str] = []
115
+ for cookie in cookies or ():
116
+ name = getattr(cookie, "name", None)
117
+ if name in {"sessionid", "ds_user_id"}:
118
+ values.append(f"{name}={getattr(cookie, 'value', '')}")
119
+ if not values:
120
+ # Anonymous: the tokens are not tied to an account, so one shared
121
+ # bucket is correct and still avoids a recapture per profile.
122
+ return "anonymous"
123
+ digest = hashlib.sha256("\n".join(sorted(values)).encode()).hexdigest()[:16]
124
+ return digest
125
+
126
+
127
+ def _cache_path(fingerprint: str = "anonymous", username: str = "") -> Path:
128
+ """XDG cache location for the captured template.
129
+
130
+ One file per (session, account). Both parts matter: the tokens are bound to
131
+ the session, and the captured ``variables.userID`` — the account the browser
132
+ actually queried — belongs to the profile that was visited. A template
133
+ captured for @a is meaningless for @b.
134
+ """
135
+ base = os.environ.get("XDG_CACHE_HOME")
136
+ if base:
137
+ root = Path(base)
138
+ else:
139
+ home = Path.home()
140
+ root = home / ".cache" if os.name == "posix" else home / "AppData" / "Local"
141
+ suffix = f".{fingerprint}"
142
+ if username:
143
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "", username)[:40]
144
+ suffix = f".{safe}{suffix}"
145
+ return root / _CACHE_SUBDIR / f"{_CACHE_FILENAME}{suffix}"
146
+
147
+
148
+ def legacy_cache_path() -> Path:
149
+ """The pre-1.3 single-file cache, kept so it can be cleaned up or ignored."""
150
+ base = os.environ.get("XDG_CACHE_HOME")
151
+ if base:
152
+ root = Path(base)
153
+ else:
154
+ home = Path.home()
155
+ root = home / ".cache" if os.name == "posix" else home / "AppData" / "Local"
156
+ return root / _CACHE_SUBDIR / _CACHE_FILENAME
157
+
158
+
159
+ @dataclass
160
+ class RequestTemplate:
161
+ """A replayable Threads GraphQL request body.
162
+
163
+ ``params`` holds the query's own fields; ``nonces`` the per-request
164
+ anti-abuse tokens, kept for the day they become mandatory.
165
+
166
+ ``variables`` is the captured ``variables`` blob, and it is **not**
167
+ optional decoration. The query carries ~38
168
+ ``__relay_internal__pv__Barcelona*relayprovider`` feature flags, and Threads
169
+ refuses the query with ``execution error`` when they are missing — measured,
170
+ not assumed: the same captured body answered with 12 edges when they were
171
+ replayed and 0 edges from the 7-key set this module used to send. So the
172
+ flags travel with the template and only the fields we actually control
173
+ (``userID``, the cursor, the page size) are overwritten per request.
174
+ """
175
+
176
+ params: dict[str, str] = field(default_factory=dict)
177
+ nonces: dict[str, str] = field(default_factory=dict)
178
+ variables: dict[str, Any] = field(default_factory=dict)
179
+ username: str = ""
180
+ user_id: str = ""
181
+ endpoint: str = _GRAPHQL_PATH
182
+ captured_at: float = 0.0
183
+
184
+ @property
185
+ def doc_id(self) -> str:
186
+ return self.params.get("doc_id", "")
187
+
188
+ def to_dict(self) -> dict[str, Any]:
189
+ return {
190
+ "params": self.params,
191
+ "nonces": self.nonces,
192
+ "variables": self.variables,
193
+ "username": self.username,
194
+ "user_id": self.user_id,
195
+ "endpoint": self.endpoint,
196
+ "captured_at": self.captured_at,
197
+ }
198
+
199
+ @classmethod
200
+ def from_dict(cls, data: dict[str, Any]) -> RequestTemplate:
201
+ variables = data.get("variables")
202
+ return cls(
203
+ params={str(k): str(v) for k, v in (data.get("params") or {}).items()},
204
+ nonces={str(k): str(v) for k, v in (data.get("nonces") or {}).items()},
205
+ variables=variables if isinstance(variables, dict) else {},
206
+ username=str(data.get("username") or ""),
207
+ user_id=str(data.get("user_id") or ""),
208
+ endpoint=str(data.get("endpoint") or _GRAPHQL_PATH),
209
+ captured_at=float(data.get("captured_at") or 0.0),
210
+ )
211
+
212
+ def is_usable(self) -> bool:
213
+ """A template is replayable only with an id, an lsd, and its variables.
214
+
215
+ The variables are part of the requirement, not a nicety: a template
216
+ without the relay flags replays into ``execution error`` every time, so
217
+ loading one without them would produce a template that looks fine and
218
+ never works.
219
+ """
220
+ return bool(
221
+ self.params.get("doc_id")
222
+ and self.params.get("lsd")
223
+ and self.variables
224
+ )
225
+
226
+
227
+ def load_template(fingerprint: str = "anonymous", username: str = "") -> RequestTemplate | None:
228
+ """Read the cached template for this session, or None when absent/corrupt.
229
+
230
+ A cache that cannot be parsed is treated as absent rather than fatal: the
231
+ caller's response to a missing template is to capture a new one, and that
232
+ path must not itself raise.
233
+ """
234
+ path = _cache_path(fingerprint, username)
235
+ try:
236
+ data = json.loads(path.read_text(encoding="utf-8"))
237
+ except (OSError, ValueError):
238
+ return None
239
+ if not isinstance(data, dict):
240
+ return None
241
+ template = RequestTemplate.from_dict(data)
242
+ return template if template.is_usable() else None
243
+
244
+
245
+ def save_template(
246
+ template: RequestTemplate, fingerprint: str = "anonymous", username: str = ""
247
+ ) -> None:
248
+ """Persist the template so the next run skips the browser.
249
+
250
+ Best effort: a read-only or full home must not break a scrape that is
251
+ otherwise working in memory.
252
+
253
+ Written to a temporary file and renamed, so a crash or a second process
254
+ reading mid-write cannot leave truncated JSON behind. Parallel scrapes on
255
+ one machine are normal for scrapmf, and a corrupt cache would send every
256
+ one of them back to the browser.
257
+
258
+ Old templates are pruned: one file per (session, account), so every
259
+ logout/login and every new profile adds a file, and nothing was ever
260
+ removing them. The tokens in them are dead by then, so they are pure
261
+ clutter. Only files untouched for ``_CACHE_MAX_AGE_DAYS`` go, and the one
262
+ just written is excluded from the sweep.
263
+ """
264
+ path = _cache_path(fingerprint, username or template.username)
265
+ try:
266
+ path.parent.mkdir(parents=True, exist_ok=True)
267
+ try:
268
+ path.parent.chmod(0o700)
269
+ except OSError:
270
+ pass
271
+ tmp = path.with_suffix(f"{path.suffix}.{os.getpid()}.tmp")
272
+ # Crear el temporal con modo 0600 desde el inicio, evitando la ventana
273
+ # en la que existiría con la umask por defecto.
274
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
275
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
276
+ fh.write(json.dumps(template.to_dict(), indent=2))
277
+ try:
278
+ tmp.chmod(0o600)
279
+ except OSError:
280
+ pass
281
+ os.replace(tmp, path)
282
+ except OSError:
283
+ return
284
+ _prune_old_templates(path)
285
+
286
+
287
+ def _prune_old_templates(keep: Path) -> None:
288
+ """Delete cached templates nobody has looked at in a while.
289
+
290
+ Best effort and deliberately conservative: an error here must never matter
291
+ to a scrape that already has its template, and a file is only removed once
292
+ it is clearly stale. Anything that is not a plain file is left alone.
293
+ """
294
+ cutoff = time.time() - _CACHE_MAX_AGE_DAYS * 86400
295
+ try:
296
+ entries = list(keep.parent.iterdir())
297
+ except OSError:
298
+ return
299
+ for entry in entries:
300
+ if entry == keep:
301
+ continue
302
+ try:
303
+ if not entry.is_file():
304
+ continue
305
+ # Solo se podan archivos de caché de esta herramienta; cualquier
306
+ # otro archivo del directorio se preserva.
307
+ if not entry.name.startswith("request_template.json"):
308
+ continue
309
+ if entry.stat().st_mtime > cutoff:
310
+ continue
311
+ entry.unlink()
312
+ except OSError:
313
+ continue
314
+
315
+
316
+ def clear_template(fingerprint: str = "anonymous", username: str = "") -> None:
317
+ """Drop the cached template after the server rejected it."""
318
+ try:
319
+ _cache_path(fingerprint, username).unlink()
320
+ except OSError:
321
+ pass
322
+
323
+
324
+ def build_body(template: RequestTemplate, variables: dict[str, Any]) -> dict[str, str]:
325
+ """The form body for one page: captured params + the merged variables.
326
+
327
+ ``variables`` overrides the captured blob rather than replacing it. Threads
328
+ validates the whole ``variables`` object against the registered query —
329
+ dropping the ``__relay_internal__pv__Barcelona*`` feature flags that came
330
+ with it turns a working request into ``execution error`` — so the capture
331
+ is the baseline and only the keys passed in here are changed (the target
332
+ account, the cursor, the page size).
333
+ """
334
+ body = dict(template.params)
335
+ merged = dict(template.variables)
336
+ merged.update(variables)
337
+ body["variables"] = json.dumps(merged)
338
+ return body
339
+
340
+
341
+ def _extract_user_id(body: dict[str, str]) -> str:
342
+ """Pull the target account's id out of the captured variables."""
343
+ try:
344
+ parsed = json.loads(body.get("variables") or "{}")
345
+ except ValueError:
346
+ return ""
347
+ uid = parsed.get("userID")
348
+ return str(uid) if uid else ""
349
+
350
+
351
+ def _parse_variables(raw: str | None) -> dict[str, Any]:
352
+ """Decode the captured ``variables`` blob.
353
+
354
+ Kept whole, relay flags included — see :class:`RequestTemplate`. A blob that
355
+ will not decode yields an empty dict, which makes the template unusable
356
+ rather than replayable-but-wrong.
357
+ """
358
+ try:
359
+ parsed = json.loads(raw or "{}")
360
+ except ValueError:
361
+ return {}
362
+ return parsed if isinstance(parsed, dict) else {}
363
+
364
+
365
+ def discover(username: str, cookies: Any) -> RequestTemplate:
366
+ """Capture a replayable template by watching the web app's own request.
367
+
368
+ Raises RuntimeError when no matching request was observed — the caller
369
+ decides whether that is fatal or just a reason to fall back.
370
+ """
371
+ from playwright.sync_api import sync_playwright
372
+
373
+ from threadstractormf._backend import chromium_launch_args, to_playwright_cookies
374
+
375
+ captured: list[dict[str, str]] = []
376
+ url = f"https://www.threads.com/@{username}/media"
377
+
378
+ with sync_playwright() as p:
379
+ browser = p.chromium.launch(
380
+ headless=True,
381
+ args=chromium_launch_args(),
382
+ )
383
+ try:
384
+ context = browser.new_context(user_agent=_UA)
385
+ pw = to_playwright_cookies(cookies)
386
+ if pw:
387
+ # Best effort: an anonymous capture is still useful, it just
388
+ # may not carry a session-bound lsd.
389
+ try:
390
+ context.add_cookies(cast("Any", pw))
391
+ except Exception:
392
+ pass
393
+ page = context.new_page()
394
+
395
+ def on_request(request: Any) -> None:
396
+ try:
397
+ post_data = request.post_data or ""
398
+ except Exception:
399
+ return
400
+ if MEDIA_QUERY_NAME not in post_data:
401
+ return
402
+ if "graphql" not in request.url.lower():
403
+ return
404
+ captured.append({k: v[0] for k, v in parse_qs(post_data).items()})
405
+
406
+ page.on("request", on_request)
407
+ page.goto(url, wait_until="domcontentloaded", timeout=45000)
408
+ # The profile tab fires its own query on load; the wait is generous
409
+ # because a slow first paint must not read as "no query happened".
410
+ page.wait_for_timeout(8000)
411
+ # One scroll in case the first page was served from cache and the
412
+ # query only fires on the next batch.
413
+ try:
414
+ page.mouse.wheel(0, 4000)
415
+ page.wait_for_timeout(3000)
416
+ except Exception:
417
+ pass
418
+ finally:
419
+ browser.close()
420
+
421
+ for body in captured:
422
+ if body.get("doc_id") and body.get("lsd"):
423
+ template = RequestTemplate(
424
+ params={k: body[k] for k in STABLE_FIELDS if k in body},
425
+ nonces={k: body[k] for k in NONCE_FIELDS if k in body},
426
+ variables=_parse_variables(body.get("variables")),
427
+ username=username.lstrip("@"),
428
+ user_id=_extract_user_id(body),
429
+ endpoint=_GRAPHQL_PATH,
430
+ captured_at=time.time(),
431
+ )
432
+ if template.is_usable():
433
+ return template
434
+
435
+ raise RuntimeError(
436
+ f"no {MEDIA_QUERY_NAME} request observed while loading {url}; "
437
+ "Threads may have changed its client, or the profile is unavailable"
438
+ )
439
+
440
+
441
+ def discover_and_cache(username: str, cookies: Any) -> RequestTemplate:
442
+ """Capture a template and persist it for subsequent runs."""
443
+ template = discover(username, cookies)
444
+ save_template(template, session_fingerprint(cookies), template.username)
445
+ return template
@@ -0,0 +1,244 @@
1
+ """Parsing helpers for Threads API responses.
2
+
3
+ Extracted from ``api.py`` to keep that module focused on orchestration (GraphQL
4
+ walk + Playwright fallback). Re-exported by ``api.py`` for backwards compat.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+ from urllib.parse import urlparse
13
+
14
+ from threadstractormf.models import Media, MediaType, Post
15
+
16
+
17
+ def _guess_ext(url: str) -> str:
18
+ """Best-effort file extension from the URL *path*.
19
+
20
+ Extension is read from the path segment itself rather than by searching the
21
+ whole URL: an image like ".../video_cover.jpg" used to be tagged `.mp4`
22
+ because the path contained the word "video", which produced files whose
23
+ extension disagreed with their contents.
24
+ """
25
+ path = urlparse(url).path.lower()
26
+ last_segment = path.rsplit("/", 1)[-1]
27
+ suffix = last_segment.rsplit(".", 1)[-1] if "." in last_segment else ""
28
+ # "jpg" is kept as-is (not folded into "jpeg") so filenames stay identical
29
+ # to previous versions and existing archives still dedup by name.
30
+ if suffix in {"jpg", "jpeg", "png", "webp", "gif", "avif", "heic", "bmp"}:
31
+ return suffix
32
+ video = {"mp4", "webm", "mov", "m4v"}
33
+ if suffix in video:
34
+ return suffix
35
+ # No usable extension in the path: fall back to the URL as a whole, which
36
+ # is how Threads serves some CDN URLs.
37
+ low = url.lower()
38
+ if ".mp4" in low or ".webm" in low or ".mov" in low:
39
+ return "mp4" if ".mp4" in low else ("webm" if ".webm" in low else "mov")
40
+ return "jpg"
41
+
42
+
43
+ def _pick_best_image(candidates: list[dict]) -> str | None:
44
+ if not candidates:
45
+ return None
46
+ # mayor width primero
47
+ try:
48
+ c = max(candidates, key=lambda x: int(x.get("width", 0)))
49
+ return c.get("url")
50
+ except Exception:
51
+ return candidates[0].get("url")
52
+
53
+
54
+ def _pick_best_video(versions: Any) -> str | None:
55
+ """Best MP4 URL out of a ``video_versions`` list.
56
+
57
+ Instagram orders these ascending by bitrate, so index 0 is the *worst*
58
+ rendition — picking it silently threw away resolution. Prefer the entry
59
+ with the largest pixel area, falling back to pixel count and then to the
60
+ last element (the highest rung in the ladder).
61
+ """
62
+ if not isinstance(versions, list) or not versions:
63
+ return None
64
+ candidates = [v for v in versions if isinstance(v, dict) and v.get("url")]
65
+ if not candidates:
66
+ return None
67
+
68
+ def score(item: dict) -> tuple[int, int]:
69
+ return (int(item.get("width") or 0) * int(item.get("height") or 0),
70
+ int(item.get("width") or 0))
71
+
72
+ best = max(candidates, key=score)
73
+ if score(best)[0] == 0 and len(candidates) > 1:
74
+ # No dimensions reported: trust the ordering and take the top rung.
75
+ best = candidates[-1]
76
+ url = best.get("url")
77
+ return str(url) if url else None
78
+
79
+
80
+ def _parse_profile_html(html: str) -> tuple[str | None, str | None, str | None]:
81
+ """Extract ``(user_id, lsd, profile_pic_url)`` from a profile page.
82
+
83
+ A single pass over the document, shared by ``_resolve_user_id`` and
84
+ ``get_profile`` so one page fetch serves both.
85
+
86
+ Only ``userID`` identifies the account. The previous fallback to ``pk``
87
+ matched the *first* ``pk`` in the document, which belongs to a post — the
88
+ same field ``_parse_post_node`` treats as a post id — and then used it as
89
+ the GraphQL ``userID``. That produced empty results and a silent, very slow
90
+ fallback to the DOM scraper, with no way to tell the user why.
91
+ """
92
+ user_id = None
93
+ lsd = None
94
+ pic = None
95
+
96
+ m = re.search(r'"userID"\s*:\s*"(\d+)"', html)
97
+ if m:
98
+ user_id = m.group(1)
99
+
100
+ # Threads stopped shipping ``"lsd":"<token>"`` and now nests it in the
101
+ # Relay require payload as ``["LSD",[],{"token":"<token>"},<prio>]``. The
102
+ # old regex silently matched nothing, so every request went out with an
103
+ # empty X-FB-LSD and the query was rejected. Both shapes are accepted
104
+ # because which one ships depends on the rollout, not on our side.
105
+ m = re.search(r'\["LSD",\s*\[\],\s*\{"token":"([^"]+)"', html)
106
+ if m:
107
+ lsd = m.group(1)
108
+ else:
109
+ m = re.search(r'"lsd"\s*:\s*"([^"]+)"', html)
110
+ if m:
111
+ lsd = m.group(1)
112
+
113
+ m = re.search(r'"profile_pic_url"\s*:\s*"((?:[^"\\]|\\.)*)"', html)
114
+ if m:
115
+ # The value is JSON-escaped inside the HTML blob. Only ``\u0026`` and
116
+ # ``\/`` matter for a URL, and leaving ``\/`` in place produces a URL the
117
+ # CDN will not serve.
118
+ pic = m.group(1).replace(r"\u0026", "&").replace(r"\/", "/")
119
+
120
+ return user_id, lsd, pic
121
+
122
+
123
+ def _parse_post_node(node: dict, fallback_username: str | None = None) -> Post | None:
124
+ # node = {"thread_items": [{"post": {...}}]}
125
+ try:
126
+ items = node.get("thread_items") or []
127
+ if not items:
128
+ return None
129
+ post = items[0].get("post")
130
+ if not post:
131
+ return None
132
+ # reposts: if post.user.username != fallback_username and excluded, filtered above
133
+ pk = str(post.get("pk") or "")
134
+ code = post.get("code")
135
+ # code is the short post_id like DKxyz (threads.net/@user/post/<code>)
136
+ post_id = code if code else pk
137
+ if not post_id:
138
+ return None
139
+ username = post.get("user", {}).get("username") or fallback_username or "unknown"
140
+ permalink = f"https://www.threads.com/@{username}/post/{post_id}"
141
+ # caption
142
+ cap = post.get("caption", {})
143
+ caption = None
144
+ if isinstance(cap, dict):
145
+ caption = cap.get("text")
146
+ # datetime from taken_at (unix)
147
+ taken = post.get("taken_at")
148
+ datetime_iso = None
149
+ if taken:
150
+ try:
151
+ datetime_iso = datetime.fromtimestamp(int(taken), tz=timezone.utc).isoformat()
152
+ except Exception:
153
+ pass
154
+ # counts (no vienen en mediaData, set 0)
155
+ like_count = 0
156
+ # carousel
157
+ medias: list[Media] = []
158
+ vtype: MediaType = "image"
159
+ carousel = post.get("carousel_media")
160
+ # single media case
161
+ if carousel and isinstance(carousel, list) and len(carousel) > 0:
162
+ for idx, cm in enumerate(carousel):
163
+ # cm has image_versions2 or video_versions
164
+ url = None
165
+ vtype = "image"
166
+ if cm.get("video_versions"):
167
+ url = _pick_best_video(cm["video_versions"])
168
+ vtype = "video"
169
+ if not url:
170
+ iv2 = cm.get("image_versions2", {})
171
+ url = _pick_best_image(iv2.get("candidates", []))
172
+ if not url:
173
+ continue
174
+ ext = _guess_ext(url)
175
+ mid = post_id if len(carousel) == 1 else f"{post_id}_{idx+1}"
176
+ medias.append(
177
+ Media(id=mid, post_id=post_id, index=idx + 1, type=vtype, url=url, ext=ext)
178
+ )
179
+ else:
180
+ # single image/video
181
+ url = None
182
+ vtype = "image"
183
+ if post.get("video_versions"):
184
+ url = _pick_best_video(post["video_versions"])
185
+ vtype = "video"
186
+ if not url:
187
+ iv2 = post.get("image_versions2", {})
188
+ url = _pick_best_image(iv2.get("candidates", []))
189
+ if url:
190
+ ext = _guess_ext(url)
191
+ medias.append(
192
+ Media(id=post_id, post_id=post_id, index=1, type=vtype, url=url, ext=ext)
193
+ )
194
+ else:
195
+ # text only? skip if no media (we do not want text-only posts)
196
+ pass
197
+ # no medias: skip entirely (user asked for photos/videos only)
198
+ if not medias:
199
+ return None
200
+ return Post(
201
+ id=post_id,
202
+ permalink=permalink,
203
+ username=username,
204
+ caption=caption,
205
+ datetime_iso=datetime_iso,
206
+ like_count=like_count,
207
+ media=medias,
208
+ )
209
+ except Exception:
210
+ return None
211
+
212
+
213
+ def _walk_xhr_videos(node: Any, found: dict[str, list[str]]) -> None:
214
+ """Recursively collect {post_code: [mp4 urls]} from threads site API JSON.
215
+
216
+ Post objects carry "code" plus video_versions at their own level or inside
217
+ carousel_media items; this walks arbitrary response trees.
218
+ """
219
+ if isinstance(node, dict):
220
+ code = node.get("code")
221
+ if isinstance(code, str):
222
+ urls: list[str] = []
223
+ vv = node.get("video_versions")
224
+ if isinstance(vv, list):
225
+ for v in vv:
226
+ if isinstance(v, dict) and ".mp4" in (v.get("url") or ""):
227
+ urls.append(str(v["url"]))
228
+ car = node.get("carousel_media")
229
+ if isinstance(car, list):
230
+ for item in car:
231
+ if (
232
+ isinstance(item, dict)
233
+ and isinstance(item.get("video_versions"), list)
234
+ ):
235
+ for v in item["video_versions"]:
236
+ if isinstance(v, dict) and ".mp4" in (v.get("url") or ""):
237
+ urls.append(str(v["url"]))
238
+ if urls:
239
+ found[code].extend(urls)
240
+ for v in node.values():
241
+ _walk_xhr_videos(v, found)
242
+ elif isinstance(node, list):
243
+ for v in node:
244
+ _walk_xhr_videos(v, found)