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
threadstractormf/api.py
ADDED
|
@@ -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
|