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,1269 @@
1
+ """Threads GraphQL client — implemented via Brave sniffing of threads.com.
2
+
3
+ Sniff: POST https://www.threads.com/graphql/query
4
+ form-encoded; the query is a *persisted* one, so the client sends no query
5
+ text, only fb_api_req_friendly_name + the doc_id that indexes the server-side
6
+ copy. Meta rotates that id whenever the query changes, so it is captured from
7
+ the live web app and cached rather than hardcoded — see _discovery.py.
8
+ variables: {"after":null,"first":11,"userID":"<userID>", ...}
9
+ headers: X-CSRFToken, X-IG-App-ID 238260118697367, X-ASBD-ID 129477,
10
+ Referer threads.com, lsd, jazoest
11
+ Respuesta: data.mediaData.edges[].node.thread_items[0].post
12
+ { id, pk, code, user, caption, taken_at, image_versions2, carousel_media, video_versions }
13
+
14
+ post_id mapping: post.code if present else raw pk. Media.id = post_id / post_id_1 for carousels.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ import re
21
+ import sys
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any, cast
26
+
27
+ import httpx
28
+
29
+ from threadstractormf._backend import (
30
+ CurlClient,
31
+ chromium_launch_args,
32
+ is_retryable_status,
33
+ is_transient_error,
34
+ to_playwright_cookies,
35
+ )
36
+ from threadstractormf._discovery import (
37
+ RequestTemplate,
38
+ build_body,
39
+ clear_template,
40
+ discover_and_cache,
41
+ load_template,
42
+ session_fingerprint,
43
+ )
44
+ from threadstractormf._parsing import (
45
+ _guess_ext,
46
+ _parse_post_node,
47
+ _parse_profile_html,
48
+ _walk_xhr_videos,
49
+ )
50
+
51
+ # Re-exported for backwards compat (tests import these from here).
52
+ from threadstractormf._parsing import _pick_best_image as _pick_best_image # noqa: F401
53
+ from threadstractormf._parsing import _pick_best_video as _pick_best_video # noqa: F401
54
+ from threadstractormf.auth import get_csrf_token, load_netscape_cookies
55
+ from threadstractormf.models import Media, Post, Profile, extract_post_id
56
+ from threadstractormf.rate_limit import (
57
+ ApiRateLimiter,
58
+ RateLimitConfig,
59
+ backoff_sleep,
60
+ parse_retry_after,
61
+ )
62
+
63
+ # The GraphQL endpoint. The request's parameters — doc_id above all — are not
64
+ # hardcoded here: see threadstractormf._discovery, which captures them from the
65
+ # live web app. A hardcoded doc_id is exactly what rotted in 1.2.0.
66
+ _GRAPHQL_URL = "https://www.threads.com/graphql/query"
67
+ _X_IG_APP_ID = "238260118697367"
68
+ _X_ASBD_ID = "129477"
69
+
70
+ # Posts requested per GraphQL round trip. Kept at the value Threads was
71
+ # observed to serve; pagination (see get_posts) walks the cursor instead of
72
+ # asking for a bigger page, so a format change is less likely to break us.
73
+ _PAGE_SIZE = 12
74
+ # Hard stop for the cursor walk when no --limit is given. Sized to cover real
75
+ # profiles in full (200 pages x 12 = 2400 posts) rather than to be fast; the
76
+ # adaptive limiter in rate_limit.ApiRateLimiter handles the pacing, so a long
77
+ # walk is slow instead of risky. When a limit is set the budget is derived from
78
+ # it instead (see get_posts).
79
+ _MAX_PAGES = 200
80
+
81
+ # How many times one scrape may capture a GraphQL request template: once to get
82
+ # a starting point, once after the server rejected the one we had. Beyond that
83
+ # the problem is not a rotated id, and retrying only hammers the endpoint.
84
+ _MAX_TEMPLATE_ATTEMPTS = 2
85
+
86
+ # How the browser fallback's scroll walk ended. The ceiling is the only exit
87
+ # that means truncation outright; the others leave it genuinely unclear, and the
88
+ # summary should not claim otherwise.
89
+ _CEILING_REASON = "the scroll ceiling was reached"
90
+ _STOP_RENDERING_REASON = "the profile stopped rendering new posts"
91
+ _LIMIT_REASON = "the requested limit was reached"
92
+
93
+
94
+ def _warn(message: str) -> None:
95
+ """Diagnostic on stderr.
96
+
97
+ stdout carries the "one line per downloaded file" contract that scrapmf
98
+ parses, so anything informational must go to stderr instead.
99
+ """
100
+ print(f"threadstractormf: {message}", file=sys.stderr, flush=True)
101
+
102
+
103
+ @dataclass
104
+ class ScrapeReport:
105
+ """Counters for one get_posts() call.
106
+
107
+ A scrape that silently returns a fraction of a profile is indistinguishable
108
+ from a complete one unless the numbers are kept: the GraphQL path can die
109
+ into the DOM fallback, posts can be dropped as reposts or for failing to
110
+ parse, and the cursor walk can stop early. All of that used to be invisible
111
+ — a 4-post scrape looked exactly like a complete one.
112
+ """
113
+
114
+ posts: int = 0
115
+ media: int = 0
116
+ unparsed: int = 0
117
+ reposts_skipped: int = 0
118
+ no_media: int = 0
119
+ pages_fetched: int = 0
120
+ truncated: bool = False
121
+ stop_reason: str = ""
122
+ used_fallback: bool = False
123
+ # Why the *browser* scraper stopped, when it is the one that produced the
124
+ # output. Kept apart from stop_reason because the two describe different
125
+ # walks: stop_reason belonged to the GraphQL attempt that already handed
126
+ # over, and leaving it there made the summary blame the API for a walk the
127
+ # browser scraper ended.
128
+ fallback_reason: str = ""
129
+ api_error: str = ""
130
+ limit: int | None = None
131
+ # Filled in by the CLI as the media loop runs: download_media reports
132
+ # whether it fetched bytes or merely recognised an existing file.
133
+ downloaded: int = 0
134
+ skipped: int = 0
135
+ failed: int = 0
136
+ # Media present in the scrape but excluded by --photos-only/--videos-only.
137
+ # Exists so the summary can add up: `media` counts everything scraped while
138
+ # downloaded+skipped+failed only counts what was acted on, and the gap used
139
+ # to look like files silently going missing.
140
+ filtered: int = 0
141
+ # Whether the profile avatar was fetched. It is not one of the posts' media,
142
+ # so it is reported apart from the tally rather than inside it.
143
+ profile_pic: bool = False
144
+
145
+
146
+ @dataclass
147
+ class DomScrape:
148
+ """What the browser scraper produced, and why it stopped.
149
+
150
+ The fallback used to hand back a bare list, so "reached the end of the
151
+ timeline" and "ran out of scrolls" were indistinguishable to the caller —
152
+ and the report carried whatever reason the failed API attempt had left
153
+ behind.
154
+ """
155
+
156
+ posts: list[Any] = field(default_factory=list)
157
+ # Proven only by the scroll loop ending on a break. False means the ceiling
158
+ # was hit, which is truncation rather than completion.
159
+ exhausted: bool = False
160
+ reason: str = ""
161
+
162
+
163
+ def coerce_dom(value: Any) -> DomScrape:
164
+ """Accept either a DomScrape or the plain lists the test stubs return.
165
+
166
+ Thirty-odd tests stub the fallback with ``[]`` or ``["DOM"]``; without this
167
+ every one of them would break on an unrelated change.
168
+ """
169
+ if isinstance(value, DomScrape):
170
+ return value
171
+ return DomScrape(posts=list(value) if value else [])
172
+
173
+
174
+ class _NonJsonResponse(Exception):
175
+ """The GraphQL endpoint answered with the web app instead of JSON."""
176
+
177
+
178
+ class _StaleTemplate(Exception):
179
+ """The captured request template no longer resolves; rediscover it.
180
+
181
+ Distinct from every other failure because it is *recoverable*: the cached
182
+ doc_id is simply out of date, and capturing a fresh one is enough. Raising
183
+ here rather than silently retrying keeps the recovery in one place, where it
184
+ can be bounded — one rediscovery per scrape, never a loop.
185
+ """
186
+
187
+
188
+ def _finish_report(report: ScrapeReport, posts: list[Any]) -> None:
189
+ """Fill the derived counters once the post list is final.
190
+
191
+ Tolerates non-Post entries: tests stub the DOM fallback with sentinels, and
192
+ a diagnostic helper must not be the thing that breaks them.
193
+ """
194
+ report.posts = len(posts)
195
+ report.media = sum(len(getattr(p, "media", ())) for p in posts)
196
+ report.no_media = sum(1 for p in posts if not getattr(p, "media", ()))
197
+
198
+
199
+ # Pacing for the DOM fallback's scroll walk. See the loop in
200
+ # _fetch_posts_via_playwright for why the wait is not jittered throughout:
201
+ # too short a wait makes the walk mistake "not rendered yet" for "end of
202
+ # profile". v1.0.1 read whole profiles with 3000ms; 91a3a45 broke that.
203
+ _SCROLL_WAIT_MS = 3000
204
+ _SCROLL_JITTER_SCROLLS = 3
205
+
206
+ # Inject the session cookies into the DOM fallback?
207
+ #
208
+ # Measured, not assumed: on a 9-post profile the fallback returns 4 posts with
209
+ # the session cookies attached and all 9 without them. Threads serves a logged-in
210
+ # client a truncated profile view whose own tab query answers
211
+ # has_next_page=false after 4 posts; the anonymous view carries the whole
212
+ # timeline. v1.0.5 scraped the profile without them (it stripped the leading
213
+ # dot off every cookie domain, which made them host-only and unsent) and read
214
+ # the account in full; v1.1.0 "fixed" the dot, the session arrived, and the
215
+ # scrape silently lost 5 of 9 posts.
216
+ #
217
+ # So the dot is correct for the download/GraphQL paths — it is what makes
218
+ # threads.net session cookies reach www.threads.net — but wrong for reading a
219
+ # public profile page. Keeping both behaviours behind one flag is why this is a
220
+ # parameter and not a revert.
221
+ _PROFILE_FALLBACK_AUTHENTICATED = False
222
+
223
+
224
+ def _require_playwright() -> None:
225
+ """Raise an actionable ImportError when the browser extra is missing.
226
+
227
+ Without this, a fallback to the DOM scraper surfaces as a bare
228
+ ``ImportError: No module named 'playwright'``, which says nothing about how
229
+ to fix it. ``playwright`` lives in the optional ``browser`` extra, and the
230
+ browser binaries are a separate download, so both steps are spelled out.
231
+ """
232
+ try:
233
+ import playwright.sync_api # noqa: F401
234
+ except ImportError as exc:
235
+ raise ImportError(
236
+ "playwright is required for this step but is not installed.\n"
237
+ " pip install 'threadstractormf[browser]'\n"
238
+ " playwright install chromium\n"
239
+ "It is only needed when the GraphQL API path is unavailable; if you "
240
+ "only need the fast path, check that your cookies are valid."
241
+ ) from exc
242
+
243
+
244
+
245
+ class ThreadsAPI:
246
+ """Low-level client. Used by Threadscraper."""
247
+
248
+ def __init__(
249
+ self,
250
+ cookies: httpx.Cookies | Any | str | Path,
251
+ *,
252
+ impersonate: str | None = None,
253
+ timeout: float = 15.0,
254
+ rate_limit_config: RateLimitConfig | None = None,
255
+ ):
256
+
257
+ if isinstance(cookies, (str, Path)):
258
+ cookies = load_netscape_cookies(cookies)
259
+ self.cookies = cookies
260
+ self.impersonate = impersonate
261
+ self.timeout = timeout
262
+ self._client: Any = None
263
+ # True only for a client this class built. An injected one (tests, a
264
+ # caller supplying their own session) is never discarded or rebuilt.
265
+ self._owns_client = False
266
+ self.rate_limit_config = rate_limit_config or RateLimitConfig()
267
+ self._api_limiter = ApiRateLimiter(
268
+ rps=self.rate_limit_config.rps, enabled=self.rate_limit_config.enabled
269
+ )
270
+ self._user_id_cache: dict[str, str] = {}
271
+ # Profile HTML is fetched by both user-id resolution and get_profile; a
272
+ # scrape that does both should not pay for two requests.
273
+ self._profile_cache: dict[str, str] = {}
274
+ self._lsd: str | None = None
275
+ # Replayable GraphQL request parameters, discovered from the web app.
276
+ # Keyed by username: the captured variables carry the userID of the
277
+ # account the browser queried, which is only valid for that profile.
278
+ self._templates: dict[str, RequestTemplate] = {}
279
+ self._template: RequestTemplate | None = None
280
+ self._template_attempted: set[str] = set()
281
+ self._template_attempts = 0
282
+ self._fingerprint_cache: str | None = None
283
+ self._has_session_cache: bool | None = None
284
+ # Counters for the most recent get_posts() call, so the CLI can report a
285
+ # truncated scrape instead of ending in silence.
286
+ self.last_report = ScrapeReport()
287
+
288
+ def _headers(self) -> dict[str, str]:
289
+ csrf = get_csrf_token(self.cookies) or ""
290
+ # The captured template carries the lsd the request was built with, and
291
+ # it must agree with the body's lsd: Threads pairs the two. Prefer the
292
+ # template's; fall back to the one parsed off the profile page.
293
+ lsd = ""
294
+ if self._template is not None:
295
+ lsd = self._template.params.get("lsd", "")
296
+ if not lsd:
297
+ lsd = self._lsd or ""
298
+ h = {
299
+ "User-Agent": (
300
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
301
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
302
+ ),
303
+ "Accept": "*/*",
304
+ "Accept-Language": "en-US,en;q=0.9",
305
+ "X-CSRFToken": csrf,
306
+ "X-IG-App-ID": _X_IG_APP_ID,
307
+ "X-ASBD-ID": _X_ASBD_ID,
308
+ "Referer": "https://www.threads.com/",
309
+ "X-FB-LSD": lsd,
310
+ "Sec-Fetch-Site": "same-origin",
311
+ "Sec-Fetch-Mode": "cors",
312
+ }
313
+ return h
314
+
315
+ def _get_client(self) -> Any:
316
+ """Return the shared HTTP client.
317
+
318
+ ``curl_cffi`` (Chrome TLS/JA4 impersonation) when ``impersonate`` was
319
+ requested, otherwise ``httpx`` — the default path stays untouched.
320
+ """
321
+ if self._client:
322
+ return self._client
323
+ self._owns_client = True
324
+ if self.impersonate:
325
+ # curl_cffi takes (connect, total): the read budget must cover a
326
+ # whole GraphQL response, so scale it off the public `timeout`.
327
+ self._client = CurlClient(
328
+ impersonate=self.impersonate,
329
+ cookies=self.cookies,
330
+ headers=self._headers(),
331
+ connect_timeout=5.0,
332
+ total_timeout=max(30.0, self.timeout * 2),
333
+ )
334
+ return self._client
335
+ # fine-grained: connect fast-fails at 5s; read uses the public
336
+ # `timeout` param (per-chunk, so slow-but-active responses survive)
337
+ site_timeout = httpx.Timeout(
338
+ connect=5.0, read=self.timeout, write=10.0, pool=5.0
339
+ )
340
+ self._client = httpx.Client(
341
+ cookies=self.cookies,
342
+ headers=self._headers(),
343
+ timeout=site_timeout,
344
+ follow_redirects=True,
345
+ http2=True,
346
+ )
347
+ return self._client
348
+
349
+ def _request_with_rate_limit(self, method: str, url: str, **kwargs) -> Any:
350
+ """Rate-limited request with retries for *transient* failures only.
351
+
352
+ Retries are driven by ``is_transient_error`` / ``is_retryable_status``,
353
+ which cover both the httpx and the curl_cffi exception families. In
354
+ particular a permanent 4xx (403 from Meta's WAF, 404, 400) now surfaces
355
+ on the first attempt instead of burning the full exponential backoff
356
+ (~60s) before reporting the very same error.
357
+
358
+ Honours ``Retry-After`` when the server sends it, otherwise falls back to
359
+ exponential backoff.
360
+ """
361
+ client = self._get_client()
362
+ if self.rate_limit_config.enabled:
363
+ self._api_limiter.wait()
364
+
365
+ max_retries = self.rate_limit_config.max_retries
366
+ base = self.rate_limit_config.backoff_base
367
+ last_exc: BaseException | None = None
368
+
369
+ for attempt in range(max_retries + 1):
370
+ try:
371
+ resp = client.request(method, url, **kwargs)
372
+ except Exception as exc: # noqa: BLE001 - re-classified below
373
+ # Transport-level failure (DNS, timeout, TLS, reset...).
374
+ if attempt < max_retries and is_transient_error(exc):
375
+ last_exc = exc
376
+ backoff_sleep(attempt, base=base)
377
+ continue
378
+ raise
379
+
380
+ # Retryable status (429/5xx): honour Retry-After, else back off.
381
+ if is_retryable_status(resp.status_code):
382
+ if resp.status_code == 429:
383
+ # Tell the limiter the server objected, so every later
384
+ # request spaces out even after this one succeeds.
385
+ self._api_limiter.penalize()
386
+ if attempt < max_retries:
387
+ retry_after = parse_retry_after(dict(resp.headers))
388
+ if retry_after is not None:
389
+ time.sleep(retry_after)
390
+ else:
391
+ backoff_sleep(attempt, base=base)
392
+ continue
393
+ last_exc = None
394
+ resp.raise_for_status() # out of retries: surface the failure
395
+ return resp # pragma: no cover - raise_for_status always raises
396
+
397
+ # 2xx/3xx, or a permanent 4xx: raise_for_status() raises right away
398
+ # for the latter, which is exactly what we want.
399
+ resp.raise_for_status()
400
+ self._api_limiter.relax()
401
+ return resp
402
+
403
+ # The loop only falls through when every attempt hit a retryable status
404
+ # and max_retries is exhausted on the last one (handled above), so this
405
+ # is only reachable if max_retries is negative.
406
+ if last_exc is not None:
407
+ raise last_exc
408
+ raise RuntimeError("request failed without an exception")
409
+
410
+ def _profile_html(self, username: str) -> str | None:
411
+ """Fetch and cache the profile page for ``username``.
412
+
413
+ Shared by user-id resolution and the profile lookup so that a scrape
414
+ which does both (the CLI does) pays for a single request instead of two.
415
+ """
416
+ if not username or not re.fullmatch(r"[A-Za-z0-9._]+", username):
417
+ return None
418
+ cached = self._profile_cache.get(username)
419
+ if cached is not None:
420
+ return cached
421
+ try:
422
+ resp = self._request_with_rate_limit(
423
+ "GET", f"https://www.threads.com/@{username}"
424
+ )
425
+ except Exception:
426
+ return None
427
+ html = str(resp.text)
428
+ self._profile_cache[username] = html
429
+ return html
430
+
431
+ def _has_session(self) -> bool:
432
+ """Whether these cookies identify a logged-in account.
433
+
434
+ Gates browser-based discovery, which can only capture what a
435
+ logged-in page actually issues.
436
+ """
437
+ if self._has_session_cache is None:
438
+ self._has_session_cache = session_fingerprint(self.cookies) != "anonymous"
439
+ return self._has_session_cache
440
+
441
+ def _fingerprint(self) -> str:
442
+ """Cache key for the current session's captured template.
443
+
444
+ Computed once: the cookies do not change over a client's life, and this
445
+ runs on every page of the walk.
446
+ """
447
+ if self._fingerprint_cache is None:
448
+ self._fingerprint_cache = session_fingerprint(self.cookies)
449
+ return self._fingerprint_cache
450
+
451
+ def _ensure_template(self, username: str) -> RequestTemplate | None:
452
+ """Return a usable request template, discovering one if needed.
453
+
454
+ Order: in-memory, then disk cache, then a browser capture.
455
+
456
+ Two separate bounds, deliberately different in scope:
457
+
458
+ * the per-profile guard stops the *same* target being re-attempted
459
+ within one scrape, so "the browser is missing" fails once and fast
460
+ instead of stalling on every page;
461
+ * :attr:`_template_attempts` is global, because a batch scrape across
462
+ many profiles must still not spend unbounded time in the browser.
463
+ """
464
+ key = username.lstrip("@").split("/")[0]
465
+ cached_in_memory = self._templates.get(key)
466
+ if cached_in_memory is not None:
467
+ self._template = cached_in_memory
468
+ return cached_in_memory
469
+
470
+ # The cache is per (session, account): the captured tokens are bound to
471
+ # the session that produced them, and the captured variables name the
472
+ # profile the browser visited.
473
+ fingerprint = self._fingerprint()
474
+ cached = load_template(fingerprint, key)
475
+ if cached is not None:
476
+ self._templates[key] = cached
477
+ self._template = cached
478
+ return cached
479
+
480
+ # Per profile, not per instance. A scraper reused across several
481
+ # accounts must be able to discover for each one: a capture that
482
+ # failed (or was skipped) for @a says nothing about @b. As a plain
483
+ # flag this locked discovery out after the first target, so every
484
+ # subsequent profile silently fell back to the DOM path.
485
+ if key in self._template_attempted:
486
+ return None
487
+ self._template_attempted.add(key)
488
+
489
+ # The two checks below abort without attempting anything, so they must
490
+ # come *before* the budget is spent. Counting them turned a batch of
491
+ # anonymous profiles into "the query was refused again after
492
+ # recapturing its id" — which never happened, there was simply no
493
+ # session to capture from and no browser to capture with.
494
+
495
+ # Only a logged-in browser issues the media query. Verified: an
496
+ # anonymous profile page fires only fetchBarcelonaExperimentQuery, so
497
+ # launching a browser to capture the media query provably finds
498
+ # nothing — about 15 wasted seconds on every anonymous scrape, for a
499
+ # path that then falls back to the DOM scraper anyway. The captured
500
+ # lsd and fb_dtsg are session-bound too, so a template captured for
501
+ # one account cannot be replayed by another (measured: execution
502
+ # error). Anonymous scraping keeps using the DOM path.
503
+ if not self._has_session():
504
+ _warn(
505
+ "no GraphQL template cached and no session cookies, so the query "
506
+ "cannot be discovered (Threads only issues it to a logged-in "
507
+ "browser); using the DOM scraper, which is slower but works "
508
+ "anonymously. Pass --cookies or --cookies-from-browser for the "
509
+ "fast API path."
510
+ )
511
+ return None
512
+
513
+ try:
514
+ _require_playwright()
515
+ except ImportError:
516
+ # Degrade cleanly: no template, no browser, so the fast path is
517
+ # impossible for this run. Say exactly how to enable it rather than
518
+ # failing with a bare ImportError later.
519
+ _warn(
520
+ "no cached GraphQL template and the browser extra is missing, so "
521
+ "the query parameters cannot be discovered. Install "
522
+ "'threadstractormf[browser]' and 'playwright install chromium' to "
523
+ "enable the fast API path."
524
+ )
525
+ return None
526
+
527
+ # Everything above only checks whether an attempt is possible; from
528
+ # here the browser really launches, so this is where the budget is
529
+ # spent. Global on purpose: a batch scrape must not spend unbounded
530
+ # time in the browser, however many profiles it visits.
531
+ self._template_attempts += 1
532
+ if self._template_attempts > _MAX_TEMPLATE_ATTEMPTS:
533
+ # Two captures, two rejections: something structural changed.
534
+ # Stop here rather than looping against Threads.
535
+ _warn(
536
+ "the GraphQL query was refused again after recapturing its id; "
537
+ "stopping the fast path for this run"
538
+ )
539
+ return None
540
+
541
+ try:
542
+ template = discover_and_cache(username, self.cookies)
543
+ except Exception as exc:
544
+ _warn(
545
+ f"could not capture the GraphQL request template "
546
+ f"({exc.__class__.__name__}: {exc}); falling back to the DOM scraper"
547
+ )
548
+ return None
549
+
550
+ self._templates[username.lstrip("@").split("/")[0]] = template
551
+ self._template = template
552
+ _warn(f"captured a current GraphQL doc_id ({template.doc_id})")
553
+ return template
554
+
555
+ def _invalidate_template(self) -> None:
556
+ """Drop the template the server just rejected.
557
+
558
+ Both the in-memory copy and the disk cache go, so the next attempt
559
+ rediscovers rather than replaying a doc_id known to be dead.
560
+
561
+ The per-profile guard is cleared as well: this is the server telling us
562
+ the id is wrong, which is a *different* situation from having no way to
563
+ get one, and it deserves its own capture. :attr:`_template_attempts` is
564
+ what stops that from looping, and it stays global on purpose — a scrape
565
+ must not spend unbounded time in the browser across many profiles.
566
+ """
567
+ username = self._template.username if self._template else ""
568
+ self._templates.pop(username, None)
569
+ self._template = None
570
+ self._template_attempted.discard(username)
571
+ clear_template(self._fingerprint(), username)
572
+
573
+ def _resolve_user_id(self, username: str) -> str:
574
+ if username in self._user_id_cache:
575
+ return self._user_id_cache[username]
576
+
577
+ html = self._profile_html(username)
578
+ if html is not None:
579
+ user_id, lsd, _pic = _parse_profile_html(html)
580
+ if user_id:
581
+ self._user_id_cache[username] = user_id
582
+ if lsd:
583
+ self._lsd = lsd
584
+ return user_id
585
+ # No userID: fall through to the DOM scraper rather than burning a
586
+ # GraphQL call on an id we already know is unreliable.
587
+ raise ValueError(f"could not resolve userID for @{username}")
588
+
589
+ def get_profile(self, username: str) -> Profile:
590
+ username = username.lstrip("@").split("/")[0]
591
+ try:
592
+ html = self._profile_html(username)
593
+ user_id = lsd = pic = None
594
+ if html is not None:
595
+ user_id, lsd, pic = _parse_profile_html(html)
596
+ if lsd:
597
+ self._lsd = lsd
598
+ # Seed the cache so get_posts() can skip its own page fetch.
599
+ if user_id:
600
+ self._user_id_cache[username] = user_id
601
+
602
+ if not pic:
603
+ # Best-effort: without a pic in the HTML, ask the browser.
604
+ try:
605
+ pic = self._fetch_profile_pic_via_playwright(username)
606
+ except Exception as exc:
607
+ _warn(
608
+ f"could not read the avatar for @{username} "
609
+ f"({exc.__class__.__name__}); continuing without it"
610
+ )
611
+
612
+ profile_pic_media = None
613
+ if pic:
614
+ from threadstractormf.models import sanitize_filename
615
+
616
+ stem = f"{sanitize_filename(username)}_profile"
617
+ profile_pic_media = Media(
618
+ id=stem,
619
+ post_id=stem,
620
+ index=1,
621
+ type="image",
622
+ url=pic,
623
+ ext=_guess_ext(pic),
624
+ )
625
+ return Profile(
626
+ username=username,
627
+ user_id=user_id,
628
+ profile_pic_url=pic,
629
+ profile_pic_media=profile_pic_media,
630
+ )
631
+ except Exception as e:
632
+ raise RuntimeError(f"get_profile failed for @{username}: {e}") from e
633
+
634
+ def _fetch_profile_pic_via_playwright(self, username: str) -> str | None:
635
+ if not username or not re.fullmatch(r"[A-Za-z0-9._]+", username):
636
+ return None
637
+ try:
638
+ # Optional enrichment: a missing browser here is not fatal, the caller
639
+ # just gets no avatar. Warn instead of failing silently.
640
+ _require_playwright()
641
+ from playwright.sync_api import sync_playwright
642
+
643
+ # NB: the leading dot on the cookie domain must survive — Chromium
644
+ # would otherwise treat every cookie as host-only and send nothing
645
+ # to www.threads.com, leaving the browser logged out.
646
+ pw = to_playwright_cookies(self.cookies)
647
+ with sync_playwright() as p:
648
+ b = p.chromium.launch(
649
+ headless=True,
650
+ args=chromium_launch_args(),
651
+ )
652
+ ctx = b.new_context(
653
+ user_agent=(
654
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
655
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
656
+ )
657
+ )
658
+ if pw:
659
+ ctx.add_cookies(cast("Any", pw))
660
+ page = ctx.new_page()
661
+ page.goto(f"https://www.threads.com/@{username}", wait_until="domcontentloaded")
662
+ page.wait_for_timeout(3000)
663
+ pic = page.evaluate(
664
+ """() => document.querySelector('img[src*="t51.2885-19"]')?.src
665
+ || document.querySelector('img[src*="s150x150"][src*="profile"]')?.src
666
+ || null"""
667
+ )
668
+ b.close()
669
+ return str(pic) if pic else None
670
+ except Exception:
671
+ return None
672
+
673
+ def _fetch_posts_via_playwright(
674
+ self,
675
+ username: str,
676
+ limit: int | None,
677
+ *,
678
+ authenticated: bool = _PROFILE_FALLBACK_AUTHENTICATED,
679
+ ) -> DomScrape:
680
+ """Read the profile with a browser.
681
+
682
+ Returns a :class:`DomScrape` rather than a bare list so the caller can
683
+ tell "the timeline ended" from "we hit the scroll ceiling" — the two
684
+ look identical in the posts and mean the opposite thing about coverage.
685
+ """
686
+ from collections import defaultdict
687
+
688
+ _require_playwright()
689
+ from playwright.sync_api import sync_playwright
690
+
691
+ # See _PROFILE_FALLBACK_AUTHENTICATED: an anonymous context reads the
692
+ # whole profile, a logged-in one is served a truncated view.
693
+ pw = to_playwright_cookies(self.cookies) if authenticated else []
694
+ posts: list[Post] = []
695
+ # XHR capture: threads ships media JSON (incl video_versions) via its own
696
+ # api responses while scrolling; late posts are ONLY available here,
697
+ # never serialized into document HTML.
698
+ captured: list[Any] = []
699
+
700
+ def _on_response(resp: Any) -> None:
701
+ try:
702
+ ct = resp.headers.get("content-type", "")
703
+ if "json" not in ct:
704
+ return
705
+ u = resp.url.lower()
706
+ if "graphql" not in u and "/api/" not in u:
707
+ return
708
+ captured.append(resp.json())
709
+ except Exception:
710
+ pass
711
+
712
+ with sync_playwright() as p:
713
+ b = p.chromium.launch(
714
+ headless=True,
715
+ args=chromium_launch_args(),
716
+ )
717
+ ctx = b.new_context(
718
+ user_agent=(
719
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
720
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
721
+ )
722
+ )
723
+ if pw:
724
+ try:
725
+ ctx.add_cookies(cast("Any", pw))
726
+ except Exception:
727
+ pass
728
+ page = ctx.new_page()
729
+ page.on("response", _on_response)
730
+ page.goto(f"https://www.threads.com/@{username}/media", wait_until="domcontentloaded")
731
+ # human-like jitter: fixed intervals look robotic to bot detection
732
+ initial_wait_ms = random.uniform(2800, 3600)
733
+ page.wait_for_timeout(initial_wait_ms)
734
+ # scroll patiently: stop only after several consecutive empty rounds
735
+ # (threads lazy-loads with delay; waits carry jitter for stealth).
736
+ # Two empty rounds at ~1.8s was not enough patience: the page had
737
+ # simply not finished fetching, so the walk declared the profile
738
+ # finished after the first viewport and returned 4 posts out of a
739
+ # much longer timeline without any warning.
740
+ max_scrolls = 60
741
+ empty_rounds_limit = 5
742
+ seen_permalinks: set[str] = set()
743
+ empty_rounds = 0
744
+ prev_time_count = 0
745
+ data: list[dict[str, Any]] = []
746
+ # Why the walk ended. The three exits below used to be
747
+ # indistinguishable: "the profile ran out", "the caller wanted less"
748
+ # and "we hit the ceiling" all left the caller with the same list
749
+ # and no explanation, so the summary ended up quoting the reason the
750
+ # *API* attempt had failed. The scroll ceiling is the one that
751
+ # actually means truncation, and it is the for/else below.
752
+ reason = ""
753
+ for scroll in range(max_scrolls):
754
+ page.mouse.wheel(0, 4000)
755
+ # Per-scroll pacing is what decides whether the profile is read
756
+ # in full. v1.0.1 waited 3000ms per scroll and needed 3 empty
757
+ # rounds to stop; 91a3a45 ("jittered waits") cut the wait to
758
+ # 1350-2250ms and the threshold to 2, and the scrape has
759
+ # returned only the first viewport ever since — 4 of 9 posts,
760
+ # silently. Threads needs well over 1.8s to render the next
761
+ # batch, so a shorter wait reads "nothing new" as "no more
762
+ # posts" and stops early.
763
+ #
764
+ # The jitter is kept, but only where it was never the problem:
765
+ # the first scrolls, which is what a bot detector actually
766
+ # samples. Deep into the walk, coverage wins.
767
+ if scroll < _SCROLL_JITTER_SCROLLS:
768
+ scroll_wait_ms = random.uniform(_SCROLL_WAIT_MS, _SCROLL_WAIT_MS * 1.6)
769
+ else:
770
+ scroll_wait_ms = _SCROLL_WAIT_MS
771
+ page.wait_for_timeout(scroll_wait_ms)
772
+ time_count = page.evaluate(
773
+ "() => document.querySelectorAll('time[datetime]').length"
774
+ )
775
+ if time_count == prev_time_count:
776
+ empty_rounds += 1
777
+ if empty_rounds >= empty_rounds_limit:
778
+ # The walk believes it reached the end of the timeline.
779
+ # "Believes" is doing real work here: this is a heuristic
780
+ # on rendering, and it is the one that once truncated 9
781
+ # posts to 4 in silence.
782
+ reason = _STOP_RENDERING_REASON
783
+ break
784
+ # skip the heavy extraction when nothing new rendered
785
+ continue
786
+ else:
787
+ empty_rounds = 0
788
+ prev_time_count = time_count
789
+ # check if new posts loaded
790
+ data = page.evaluate(r"""() => {
791
+ // Limitar el HTML parseado al contenedor principal reduce el
792
+ // coste de la serializacion en perfiles largos; si no existe
793
+ // (versiones antiguas de la pagina), se cae a document.body.
794
+ const root = document.querySelector('main, [role="main"]') || document.body;
795
+ const html = root.innerHTML;
796
+ // valid codes: only those appearing in real /post/ links
797
+ // (filters false positives like "code":"en_US")
798
+ const validCodes = new Set();
799
+ document.querySelectorAll('a[href*="/post/"]').forEach(a=>{
800
+ const m=a.href.match(/\/post\/([^\/?#]+)/);
801
+ if(m) validCodes.add(m[1]);
802
+ });
803
+ // positions of every valid code occurrence in full html
804
+ const codePositions=[];
805
+ for(const m of html.matchAll(/"code":"([^"]+)"/g)){
806
+ if(validCodes.has(m[1])) codePositions.push({code:m[1], idx:m.index});
807
+ }
808
+ codePositions.sort((a,b)=>a.idx-b.idx);
809
+ // GLOBAL video map: every non-empty video_versions array is
810
+ // assigned to the nearest valid code AFTER it in the html.
811
+ // (empirically each post's media JSON follows its code; a fixed
812
+ // window around the first occurrence misses carousels/videos)
813
+ const codeVideos={};
814
+ const seenBase=new Set();
815
+ for(const m of html.matchAll(/"video_versions":\[([^\]]*)\]/g)){
816
+ if(!m[1].trim()) continue;
817
+ const um=m[1].match(/"url":"([^"]+)"/);
818
+ if(!um) continue;
819
+ const u=um[1].replaceAll(/\\u0026/g,"&")
820
+ .replaceAll(/\\u0025/g,"%").replace(/\\\//g,"/");
821
+ if(!u.includes('.mp4')) continue;
822
+ const base=u.split('?')[0];
823
+ if(seenBase.has(base)) continue;
824
+ seenBase.add(base);
825
+ let owner=null;
826
+ for(const cp of codePositions){
827
+ if(cp.idx>=m.index && cp.idx-m.index<=80000){ owner=cp.code; break; }
828
+ }
829
+ if(!owner && codePositions.length){
830
+ owner=codePositions[codePositions.length-1].code;
831
+ }
832
+ if(owner){
833
+ (codeVideos[owner]=codeVideos[owner]||[]).push(u);
834
+ }
835
+ }
836
+ const posts=[];
837
+ document.querySelectorAll('time[datetime]').forEach(t=>{
838
+ const a=t.closest('a[href*="/post/"]');
839
+ const permalink=a?a.href:null;
840
+ if(!permalink) return;
841
+ const dt=t.getAttribute('datetime');
842
+ let container=t;
843
+ let media_urls=[];
844
+ let postCode = (permalink.match(/\/post\/([^/?#]+)/) || [])[1] || "";
845
+ // poster extraction per container (images + visible posters)
846
+ for(let i=0;i<10 && media_urls.length==0;i++){
847
+ let els=[...container.querySelectorAll('img, video, source')];
848
+ media_urls=els.filter(el=>{
849
+ const s=el.src||el.srcset||"";
850
+ if(!s) return false;
851
+ if(s.includes('t51.2885-19')) return false;
852
+ // Host suffix, not substring: a substring test
853
+ // accepts any host embedding the name
854
+ // (instagram.evil.test). The bare ".mp4"
855
+ // alternative used to be here and bypassed the
856
+ // host check entirely.
857
+ try{
858
+ const host=new URL(s, location.href).hostname;
859
+ return [".cdninstagram.com",".cdninstagram.net",".fbcdn.net"]
860
+ .some(d=>host===d.slice(1)||host.endsWith(d));
861
+ }catch(e){ return false; }
862
+ }).map(el=>el.src||el.srcset.split(' ')[0]);
863
+ if(media_urls.length>0) break;
864
+ container=container.parentElement;
865
+ if(!container) break;
866
+ }
867
+ // merge globally-mapped video urls for this post
868
+ if(postCode && codeVideos[postCode]){
869
+ for(const u of codeVideos[postCode]) media_urls.push(u);
870
+ }
871
+ posts.push({permalink, datetime:dt, media_urls: [...new Set(media_urls)]});
872
+ });
873
+ return posts;
874
+ }""")
875
+ # Strict filter: only posts whose permalink contains /@<target>/ (exact user)
876
+ target_lower = username.lower()
877
+ data = [d for d in data if f"/@{target_lower}/" in d["permalink"].lower()]
878
+ # NOTE: videos are covered by the in-page global video map and the
879
+ # XHR response capture; per-post detail fetches were removed —
880
+ # post pages no longer serialize video_versions (Meta format change).
881
+ # deduplicate
882
+ new_posts = []
883
+ for d in data:
884
+ if d["permalink"] not in seen_permalinks:
885
+ seen_permalinks.add(d["permalink"])
886
+ new_posts.append(d)
887
+ # convert to Post
888
+ for d in new_posts:
889
+ pid = extract_post_id(d["permalink"])
890
+ if not pid:
891
+ continue
892
+ # simple video detection; assume image otherwise
893
+ medias = []
894
+ for idx, u in enumerate(d["media_urls"]):
895
+ from threadstractormf.downloader import is_valid_media_url
896
+
897
+ if not is_valid_media_url(u):
898
+ continue
899
+ ext = _guess_ext(u)
900
+ mid = pid if len(d["media_urls"]) == 1 else f"{pid}_{idx+1}"
901
+ medias.append(
902
+ Media(
903
+ id=mid, post_id=pid, index=idx + 1,
904
+ type="video" if ext == "mp4" else "image",
905
+ url=u, ext=ext,
906
+ )
907
+ )
908
+ if not medias:
909
+ continue
910
+ posts.append(
911
+ Post(
912
+ id=pid,
913
+ permalink=d["permalink"],
914
+ username=username,
915
+ datetime_iso=d["datetime"],
916
+ media=medias,
917
+ )
918
+ )
919
+ if limit is not None and len(posts) >= limit:
920
+ break
921
+ if limit is not None and len(posts) >= limit:
922
+ reason = _LIMIT_REASON
923
+ break
924
+ # NOTE: no early break on empty rounds here — the scroll loop
925
+ # already stops after 3 consecutive rounds without new renders
926
+ else:
927
+ # The loop ran its full course without a break: the ceiling is
928
+ # the one exit that means the timeline was cut short rather
929
+ # than finished.
930
+ reason = _CEILING_REASON
931
+ b.close()
932
+ # merge XHR-captured videos into converted posts (late posts only exist
933
+ # here — their media JSON is never serialized into document HTML)
934
+ xhr_map: dict[str, list[str]] = defaultdict(list)
935
+ for j in captured:
936
+ _walk_xhr_videos(j, xhr_map)
937
+ for post in posts:
938
+ extra = xhr_map.get(post.id, [])
939
+ if not extra:
940
+ continue
941
+ existing = {m.url.split("?")[0] for m in post.media}
942
+ for u in extra:
943
+ base = u.split("?")[0]
944
+ if base in existing:
945
+ continue
946
+ existing.add(base)
947
+ ext = _guess_ext(u)
948
+ idx_next = len(post.media) + 1
949
+ post.media.append(
950
+ Media(
951
+ id=f"{post.id}_{idx_next}",
952
+ post_id=post.id,
953
+ index=idx_next,
954
+ type="video" if ext == "mp4" else "image",
955
+ url=u,
956
+ ext=ext,
957
+ )
958
+ )
959
+ if limit is not None:
960
+ posts = posts[:limit]
961
+ # repost filter already implicit: username == target (DOM filtered)
962
+ # `exhausted` means "we know this is everything the caller asked for",
963
+ # not "we read the whole profile". Only the requested limit earns that:
964
+ # stopping because the page stopped rendering is a heuristic, and it is
965
+ # the one that once returned 4 posts out of 9 in silence. The ceiling is
966
+ # the opposite — known truncation.
967
+ return DomScrape(posts=posts, exhausted=reason == _LIMIT_REASON, reason=reason)
968
+
969
+ def _walk_graphql(
970
+ self,
971
+ username: str,
972
+ user_id: str | None,
973
+ *,
974
+ limit: int | None,
975
+ exclude_reposts: bool,
976
+ report: ScrapeReport,
977
+ collected: list[Post],
978
+ ) -> list[Post]:
979
+ """Walk the profile cursor, appending into ``collected``.
980
+
981
+ ``collected`` is passed in rather than returned so that posts parsed
982
+ before a mid-walk failure survive the exception: a crawl that dies on
983
+ page 7 still has six pages of good output, and dropping them would
984
+ throw away work that was already paid for.
985
+
986
+ Split out of :meth:`get_posts` so the stale-template retry can re-enter
987
+ it from scratch with a freshly captured template, instead of resuming a
988
+ walk whose cursor belongs to parameters that no longer resolve.
989
+
990
+ ``userID`` is read from the captured template rather than from the
991
+ profile HTML: the HTML carries several ``userID`` fields and the first
992
+ one is the *viewer's own* account, not the profile being read — using it
993
+ made every GraphQL query ask for the wrong account, which Threads
994
+ refuses with ``execution error``. The template's variables name the
995
+ account the browser actually queried, which is by construction the
996
+ right one.
997
+ """
998
+ template = self._ensure_template(username)
999
+ if template is None:
1000
+ # No template and no way to get one: the walk cannot start. Report
1001
+ # the real reason (already warned by _ensure_template) rather than
1002
+ # sending a request known to fail, and let the caller fall back.
1003
+ if not report.api_error:
1004
+ report.api_error = (
1005
+ "no GraphQL request template available and discovery is "
1006
+ "unavailable"
1007
+ )
1008
+ raise _NonJsonResponse(report.api_error)
1009
+
1010
+ # The client bakes X-FB-LSD in at construction, and the lsd it needs
1011
+ # comes from this template — discovered *after* the first request of the
1012
+ # process already built the client (the profile fetch runs first).
1013
+ # Rebuilding is how the new lsd is picked up. Patching the header dict
1014
+ # in place only worked for httpx, and CurlClient (--impersonate) has no
1015
+ # ``headers`` at all, which raised AttributeError on the path the README
1016
+ # recommends for the WAF.
1017
+ #
1018
+ # Once per walk, not per page: this used to sit inside the loop below,
1019
+ # so a 200-page crawl paid 200 TLS handshakes for no reason.
1020
+ #
1021
+ # Only a client this class built is replaced. An injected one (tests, a
1022
+ # caller supplying their own session) is left alone: discarding it would
1023
+ # silently swap a mocked transport for a real one and send the request
1024
+ # to the network.
1025
+ if self._client is not None and self._owns_client:
1026
+ self._client.close()
1027
+ self._client = None
1028
+
1029
+ target_id = template.variables.get("userID") or template.user_id or user_id
1030
+ if not target_id:
1031
+ raise _NonJsonResponse("could not determine the target account id")
1032
+
1033
+ variables = {
1034
+ "after": None,
1035
+ "allow_page_info_for_lox_user": False,
1036
+ "before": None,
1037
+ "first": _PAGE_SIZE,
1038
+ "last": None,
1039
+ "userID": target_id,
1040
+ }
1041
+ out: list[Post] = []
1042
+ seen_ids: set[str] = set()
1043
+ cursor: str | None = None
1044
+ seen_cursors: set[str] = set()
1045
+ # With a limit, never fetch more pages than could be needed.
1046
+ # Reposts get filtered and posts are deduped, so a page can
1047
+ # yield fewer than _PAGE_SIZE usable posts: leave headroom.
1048
+ # _MAX_PAGES stays a hard ceiling so an absurd --limit cannot
1049
+ # turn into an unbounded walk.
1050
+ page_budget = _MAX_PAGES
1051
+ if limit is not None:
1052
+ page_budget = min(_MAX_PAGES, max(1, -(-limit // _PAGE_SIZE) + 2))
1053
+ for _ in range(page_budget):
1054
+ variables["after"] = cursor
1055
+ body = build_body(template, variables)
1056
+ resp = self._request_with_rate_limit("POST", template.endpoint, data=body)
1057
+ report.pages_fetched += 1
1058
+ # Threads answers 200 text/html (the web app shell) when the
1059
+ # query is rejected — stale doc_id, a rotated __rev, a
1060
+ # missing LSD. That used to raise inside resp.json(), get
1061
+ # swallowed by the broad except below and surface only as
1062
+ # "falling back to the DOM scraper", hiding the real cause.
1063
+ # Decode defensively rather than trusting the content-type:
1064
+ # a real GraphQL reply may omit the header, and what matters
1065
+ # is whether the body parses as JSON at all.
1066
+ try:
1067
+ j = resp.json()
1068
+ except ValueError as exc:
1069
+ ctype = (resp.headers.get("content-type") or "no content-type")
1070
+ report.api_error = (
1071
+ f"GraphQL returned {resp.status_code} {ctype.split(';')[0]} "
1072
+ f"instead of JSON ({exc.__class__.__name__}); "
1073
+ "stale doc_id or __rev"
1074
+ )
1075
+ raise _NonJsonResponse(report.api_error) from exc
1076
+ if not (j.get("data") and j["data"].get("mediaData")):
1077
+ # Distinguish "the server refused the query" from "the
1078
+ # payload was merely partial". Threads answers a stale
1079
+ # doc_id / rotated __rev with 200 JSON carrying
1080
+ # errors: [{"severity": "CRITICAL", ...}] and no data —
1081
+ # indistinguishable from an empty profile unless the
1082
+ # errors field is inspected, which it never was.
1083
+ errors = j.get("errors") or []
1084
+ if errors:
1085
+ msgs = "; ".join(
1086
+ str(e.get("message", e))[:60]
1087
+ for e in errors[:2]
1088
+ if isinstance(e, dict)
1089
+ ) or "no detail"
1090
+ report.api_error = f"GraphQL returned errors: {msgs}"
1091
+ report.stop_reason = "the API rejected the query"
1092
+ # The cached template just proved it no longer
1093
+ # resolves. Throw it away and capture a fresh one
1094
+ # from the web app, then let the walk be retried by
1095
+ # the caller rather than dying on a stale id.
1096
+ self._invalidate_template()
1097
+ raise _StaleTemplate(report.api_error)
1098
+ else:
1099
+ report.stop_reason = "mediaData absent"
1100
+ break # partial/absent payload: keep what we have
1101
+
1102
+ media_data = j["data"]["mediaData"]
1103
+ for edge in media_data.get("edges", []) or []:
1104
+ node = edge.get("node") or {}
1105
+ post = _parse_post_node(node, fallback_username=username)
1106
+ if not post:
1107
+ report.unparsed += 1
1108
+ continue
1109
+ if exclude_reposts and post.username.lower() != username.lower():
1110
+ report.reposts_skipped += 1
1111
+ continue
1112
+ # Guard against a cursor that loops back over edges we
1113
+ # already emitted.
1114
+ if post.id in seen_ids:
1115
+ continue
1116
+ seen_ids.add(post.id)
1117
+ out.append(post)
1118
+ # Mirror into `collected` immediately: if the next request
1119
+ # raises, this post is still real output and must not be lost.
1120
+ collected.append(post)
1121
+ if limit is not None and len(out) >= limit:
1122
+ break
1123
+
1124
+ if limit is not None and len(out) >= limit:
1125
+ report.stop_reason = f"limit reached ({limit})"
1126
+ break
1127
+
1128
+ # page_info parsing is defensive: a missing/renamed key
1129
+ # must not turn into an infinite walk.
1130
+ page_info = media_data.get("page_info") or {}
1131
+ if not isinstance(page_info, dict) or not page_info.get("has_next_page"):
1132
+ report.stop_reason = "the server reports no further pages"
1133
+ break
1134
+ next_cursor = page_info.get("end_cursor")
1135
+ if not next_cursor or not isinstance(next_cursor, str):
1136
+ report.stop_reason = "page_info carries no usable end_cursor"
1137
+ break
1138
+ if next_cursor in seen_cursors:
1139
+ report.stop_reason = "the server repeated the cursor"
1140
+ break # server repeating the cursor -> stop
1141
+ seen_cursors.add(next_cursor)
1142
+ cursor = next_cursor
1143
+ else:
1144
+ report.truncated = True
1145
+ report.stop_reason = f"page budget of {page_budget} exhausted"
1146
+
1147
+ if out:
1148
+ if limit is not None:
1149
+ out = out[:limit]
1150
+ return out
1151
+ return []
1152
+
1153
+ def get_posts(
1154
+ self, username: str, *, limit: int | None = None, exclude_reposts: bool = True
1155
+ ) -> list[Post]:
1156
+ username = username.lstrip("@").split("/")[0]
1157
+ # Try GraphQL; on failure (execution error) fall back to Playwright DOM
1158
+ # (robust for public profiles)
1159
+ collected: list[Post] = []
1160
+ report = ScrapeReport(limit=limit)
1161
+ self.last_report = report
1162
+ try:
1163
+ # GraphQL path — minimal, falls back to DOM on failure
1164
+ user_id = None
1165
+ try:
1166
+ user_id = self._resolve_user_id(username)
1167
+ except Exception as exc:
1168
+ # Not fatal. An unauthenticated profile page carries no
1169
+ # userID at all (Threads renders it client-side), and this
1170
+ # guard used to send every anonymous scrape straight to the
1171
+ # DOM path without ever trying discovery — which *can* resolve
1172
+ # the account on its own, because the captured variables name
1173
+ # the profile the browser actually visited.
1174
+ report.api_error = f"userID: {exc.__class__.__name__}"
1175
+ try:
1176
+ out = self._walk_graphql(
1177
+ username,
1178
+ user_id,
1179
+ limit=limit,
1180
+ exclude_reposts=exclude_reposts,
1181
+ report=report,
1182
+ collected=collected,
1183
+ )
1184
+ except _StaleTemplate:
1185
+ # The cached doc_id was refused. This is the whole reason
1186
+ # the template is captured rather than hardcoded: capture a
1187
+ # fresh one and walk again. Bounded by
1188
+ # _MAX_TEMPLATE_ATTEMPTS — a template that fails twice is
1189
+ # not going to fix itself.
1190
+ _warn(
1191
+ "the cached GraphQL query id is no longer accepted; "
1192
+ "capturing the current one and retrying"
1193
+ )
1194
+ # Nothing was collected under the dead id, and the new walk
1195
+ # starts from the first page, so the old list is dropped
1196
+ # rather than merged: the ids may not even mean the same
1197
+ # thing under a different query revision.
1198
+ collected.clear()
1199
+ try:
1200
+ out = self._walk_graphql(
1201
+ username,
1202
+ user_id,
1203
+ limit=limit,
1204
+ exclude_reposts=exclude_reposts,
1205
+ report=report,
1206
+ collected=collected,
1207
+ )
1208
+ report.api_error = ""
1209
+ except Exception as exc:
1210
+ # Keep the *original* rejection as the reported cause:
1211
+ # "we could not rediscover a working id" is a detail of
1212
+ # the recovery, while "the server refused the query" is
1213
+ # what the user needs to read.
1214
+ detail = exc.__class__.__name__
1215
+ _warn(
1216
+ f"the GraphQL query is still rejected after recapture "
1217
+ f"({detail}); falling back"
1218
+ )
1219
+ out = []
1220
+ if not report.api_error:
1221
+ report.api_error = (
1222
+ "GraphQL query rejected and no replacement "
1223
+ "doc_id could be captured"
1224
+ )
1225
+
1226
+ if out:
1227
+ _finish_report(report, out)
1228
+ return out
1229
+ except Exception as exc:
1230
+ # The cursor walk can fail several pages in. Anything already
1231
+ # collected is still valid output, so keep it and only fall back to
1232
+ # the DOM scraper when we came away empty-handed.
1233
+ if collected:
1234
+ report.truncated = True
1235
+ _warn(f"GraphQL pagination stopped early ({exc.__class__.__name__})")
1236
+ _finish_report(report, collected[:limit] if limit is not None else collected)
1237
+ return collected[:limit] if limit is not None else collected
1238
+ detail = report.api_error or f"{exc.__class__.__name__}: {exc}"
1239
+ _warn(
1240
+ f"GraphQL API path unavailable ({detail}); "
1241
+ "falling back to the Playwright scraper, which is slower."
1242
+ )
1243
+ # Fallback DOM robusto
1244
+ report.used_fallback = True
1245
+ # Still pessimistic on purpose. The scroll walk can only say it stopped
1246
+ # *rendering*, which is not proof the timeline ended — that heuristic
1247
+ # once returned 4 posts out of 9 in silence. So truncated stays True and
1248
+ # what changes is the reason: it now names the walk that produced the
1249
+ # output instead of the API attempt that already handed over.
1250
+ report.truncated = True
1251
+ dom = coerce_dom(self._fetch_posts_via_playwright(username, limit))
1252
+ posts = dom.posts
1253
+ report.fallback_reason = dom.reason
1254
+ if dom.reason:
1255
+ report.stop_reason = dom.reason
1256
+ if not posts:
1257
+ # Not an exception: an empty or private profile legitimately yields
1258
+ # nothing, and that cannot be told apart from a silent failure.
1259
+ _warn(
1260
+ f"no posts found for @{username}. The account may be empty or "
1261
+ "private, or the session may lack access."
1262
+ )
1263
+ _finish_report(report, posts)
1264
+ return posts
1265
+
1266
+ def close(self) -> None:
1267
+ if self._client:
1268
+ self._client.close()
1269
+ self._client = None