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.
- threadstractormf/__init__.py +7 -0
- threadstractormf/_backend.py +283 -0
- threadstractormf/_discovery.py +445 -0
- threadstractormf/_parsing.py +244 -0
- threadstractormf/api.py +1269 -0
- threadstractormf/archive.py +262 -0
- threadstractormf/auth.py +319 -0
- threadstractormf/cli.py +426 -0
- threadstractormf/client.py +221 -0
- threadstractormf/downloader.py +353 -0
- threadstractormf/models.py +123 -0
- threadstractormf/rate_limit.py +201 -0
- threadstractormf/template.py +123 -0
- threadstractormf-1.4.0.dist-info/METADATA +163 -0
- threadstractormf-1.4.0.dist-info/RECORD +18 -0
- threadstractormf-1.4.0.dist-info/WHEEL +4 -0
- threadstractormf-1.4.0.dist-info/entry_points.txt +2 -0
- threadstractormf-1.4.0.dist-info/licenses/LICENSE +674 -0
|
@@ -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)
|