threadstractormf 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- threadstractormf/__init__.py +7 -0
- threadstractormf/_backend.py +283 -0
- threadstractormf/_discovery.py +445 -0
- threadstractormf/_parsing.py +244 -0
- threadstractormf/api.py +1269 -0
- threadstractormf/archive.py +262 -0
- threadstractormf/auth.py +319 -0
- threadstractormf/cli.py +426 -0
- threadstractormf/client.py +221 -0
- threadstractormf/downloader.py +353 -0
- threadstractormf/models.py +123 -0
- threadstractormf/rate_limit.py +201 -0
- threadstractormf/template.py +123 -0
- threadstractormf-1.4.0.dist-info/METADATA +163 -0
- threadstractormf-1.4.0.dist-info/RECORD +18 -0
- threadstractormf-1.4.0.dist-info/WHEEL +4 -0
- threadstractormf-1.4.0.dist-info/entry_points.txt +2 -0
- threadstractormf-1.4.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""threadstractormf — Threads library (gallery-dl cookies + post_id naming)."""
|
|
2
|
+
|
|
3
|
+
from threadstractormf.client import Threadscraper
|
|
4
|
+
from threadstractormf.models import Media, Post, Profile
|
|
5
|
+
|
|
6
|
+
__all__ = ["Threadscraper", "Post", "Media", "Profile"]
|
|
7
|
+
__version__ = "1.4.0"
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Shared helpers for talking to Meta's servers through two HTTP backends.
|
|
2
|
+
|
|
3
|
+
Two independent backends are supported:
|
|
4
|
+
|
|
5
|
+
* **httpx** — the default. No browser TLS impersonation.
|
|
6
|
+
* **curl_cffi** — opted into with ``--impersonate chrome``. Meta's WAF inspects
|
|
7
|
+
the JA3/JA4 TLS fingerprint and the HTTP/2 SETTINGS frame, which httpx cannot
|
|
8
|
+
fake; curl_cffi impersonates a real Chrome byte-for-byte, the same mechanism
|
|
9
|
+
``yt-dlp --impersonate`` uses.
|
|
10
|
+
|
|
11
|
+
Everything else (rate limiting, retries, streaming, the GraphQL client and the
|
|
12
|
+
media downloader) is written against the tiny surface both libraries share, so
|
|
13
|
+
the httpx path stays byte-identical and curl_cffi is just an opt-in branch.
|
|
14
|
+
|
|
15
|
+
Also holds the cookie converter used by the three Playwright call sites, which
|
|
16
|
+
used to duplicate it three times (and all three dropped the leading dot, which
|
|
17
|
+
made every auth cookie host-only).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import socket
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
28
|
+
# curl_cffi is an optional extra; the alias is only needed for typing.
|
|
29
|
+
from curl_cffi.requests.session import HttpMethod
|
|
30
|
+
else:
|
|
31
|
+
HttpMethod = str
|
|
32
|
+
|
|
33
|
+
# ─── Retry classification ────────────────────────────────────────────────────
|
|
34
|
+
#
|
|
35
|
+
# Shared by the GraphQL client and the media downloader. Both exception
|
|
36
|
+
# families must be understood here, because curl_cffi replaces httpx wholesale
|
|
37
|
+
# when --impersonate is active.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_retryable_status(status: int) -> bool:
|
|
41
|
+
"""True for statuses worth retrying: 429 (rate limited) and any 5xx.
|
|
42
|
+
|
|
43
|
+
Every other 4xx is permanent and must surface immediately. A 403 from
|
|
44
|
+
Meta's WAF will not fix itself, so retrying it only burns the full
|
|
45
|
+
exponential backoff (~60s) before reporting the very same error.
|
|
46
|
+
"""
|
|
47
|
+
return status == 429 or status >= 500
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _curl_exc() -> Any:
|
|
51
|
+
"""curl_cffi's exception module, or None when the extra is not installed."""
|
|
52
|
+
try:
|
|
53
|
+
from curl_cffi.requests import exceptions as curl_exceptions
|
|
54
|
+
except ImportError:
|
|
55
|
+
return None
|
|
56
|
+
return curl_exceptions
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_transient_error(exc: BaseException) -> bool:
|
|
60
|
+
"""True when a failure is worth retrying (flaky network, DNS, CDN hiccup).
|
|
61
|
+
|
|
62
|
+
Handles both backends. Order matters: curl_cffi's ``RequestException``
|
|
63
|
+
subclasses ``OSError``, so the curl branch must be tested *before* the
|
|
64
|
+
generic ``OSError`` branch or every curl error would be misclassified as a
|
|
65
|
+
plain OSError with no errno and never retried.
|
|
66
|
+
"""
|
|
67
|
+
curl = _curl_exc()
|
|
68
|
+
if curl is not None:
|
|
69
|
+
# Raised by Response.raise_for_status(): retry only if the status allows.
|
|
70
|
+
if isinstance(exc, curl.HTTPError):
|
|
71
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
72
|
+
return status is not None and is_retryable_status(int(status))
|
|
73
|
+
# ConnectTimeout, ReadTimeout, ConnectionError (incl. DNSError, SSLError).
|
|
74
|
+
if isinstance(exc, (curl.Timeout, curl.ConnectionError)):
|
|
75
|
+
return True
|
|
76
|
+
# Any other curl RequestException (InvalidURL, TooManyRedirects, ...)
|
|
77
|
+
# is a permanent programming/URL problem.
|
|
78
|
+
if isinstance(exc, curl.RequestException):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
82
|
+
return is_retryable_status(exc.response.status_code)
|
|
83
|
+
if isinstance(exc, httpx.TransportError):
|
|
84
|
+
# ConnectError (wraps gaierror/[Errno -3]), ConnectTimeout, ReadTimeout,
|
|
85
|
+
# RemoteProtocolError, PoolTimeout, ...
|
|
86
|
+
return True
|
|
87
|
+
if isinstance(exc, httpx.HTTPError):
|
|
88
|
+
# InvalidURL, DecodingError, TooManyRedirects: permanent.
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
if isinstance(exc, OSError):
|
|
92
|
+
# EAI_AGAIN lives in `socket`, not `errno`; referencing errno.EAI_AGAIN
|
|
93
|
+
# raised AttributeError on *any* OSError reaching here, including the
|
|
94
|
+
# very DNS flap this branch exists to detect.
|
|
95
|
+
return exc.errno in (-3, socket.EAI_AGAIN)
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ─── HTTP backend ────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StreamClient(Protocol):
|
|
103
|
+
"""The minimum surface the media downloader needs.
|
|
104
|
+
|
|
105
|
+
Both ``httpx.Client`` and :class:`CurlClient` satisfy it structurally,
|
|
106
|
+
which is what lets ``download_media`` accept either backend. The signature
|
|
107
|
+
lists only the argument the downloader actually passes, because a protocol
|
|
108
|
+
declaring ``**kwargs`` would require the implementation to accept arbitrary
|
|
109
|
+
keywords — which ``httpx.Client.stream`` does not.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def stream(
|
|
113
|
+
self, method: str, url: str, *, headers: dict[str, str] | None = None
|
|
114
|
+
) -> Any: ...
|
|
115
|
+
|
|
116
|
+
def close(self) -> None: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _CurlStreamResponse:
|
|
120
|
+
"""Presents a curl_cffi streamed response through the httpx surface.
|
|
121
|
+
|
|
122
|
+
Only one method actually differs: curl_cffi names it ``iter_content``
|
|
123
|
+
while httpx calls it ``iter_bytes``. Everything else (``status_code``,
|
|
124
|
+
``raise_for_status``) already matches.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
__slots__ = ("_resp",)
|
|
128
|
+
|
|
129
|
+
def __init__(self, resp: Any) -> None:
|
|
130
|
+
self._resp = resp
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def status_code(self) -> int:
|
|
134
|
+
return int(self._resp.status_code)
|
|
135
|
+
|
|
136
|
+
def raise_for_status(self) -> None:
|
|
137
|
+
self._resp.raise_for_status()
|
|
138
|
+
|
|
139
|
+
def iter_bytes(self, chunk_size: int | None = None) -> Any:
|
|
140
|
+
# chunk_size is intentionally dropped: curl pulls what it wants off the
|
|
141
|
+
# socket and warns loudly if we pretend otherwise.
|
|
142
|
+
return self._resp.iter_content()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def known_impersonate_targets() -> set[str]:
|
|
146
|
+
"""Every impersonation target curl_cffi understands (generic + versioned).
|
|
147
|
+
|
|
148
|
+
curl_cffi does **not** validate ``impersonate`` at construction time — it
|
|
149
|
+
accepts any string and only misbehaves later, at request time. Validating up
|
|
150
|
+
front turns a confusing runtime failure into an actionable error.
|
|
151
|
+
"""
|
|
152
|
+
from typing import get_args
|
|
153
|
+
|
|
154
|
+
from curl_cffi.requests.impersonate import REAL_TARGET_MAP, BrowserTypeLiteral
|
|
155
|
+
|
|
156
|
+
return set(REAL_TARGET_MAP) | set(get_args(BrowserTypeLiteral))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CurlClient:
|
|
160
|
+
"""``httpx``-shaped wrapper around a curl_cffi Session.
|
|
161
|
+
|
|
162
|
+
Only instantiated when ``impersonate`` is requested. Three differences from
|
|
163
|
+
``httpx.Client`` are papered over here:
|
|
164
|
+
|
|
165
|
+
* cookies are *assigned* (``session.cookies = jar``), not passed as a kwarg
|
|
166
|
+
* ``timeout`` is ``(connect, total)`` — verified empirically: the second
|
|
167
|
+
element is a whole-transfer budget, **not** a per-phase timeout, so it has
|
|
168
|
+
to be generous enough for large media or slow CDN links get truncated
|
|
169
|
+
mid-download (the same failure the httpx path fixed via a per-chunk read)
|
|
170
|
+
* HTTP/2 must be requested explicitly
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
impersonate: str,
|
|
177
|
+
cookies: Any = None,
|
|
178
|
+
headers: dict[str, str] | None = None,
|
|
179
|
+
connect_timeout: float = 15.0,
|
|
180
|
+
total_timeout: float = 600.0,
|
|
181
|
+
http2: bool = True,
|
|
182
|
+
) -> None:
|
|
183
|
+
try:
|
|
184
|
+
from curl_cffi import requests as curl_requests
|
|
185
|
+
except ImportError as exc: # pragma: no cover - depends on env
|
|
186
|
+
raise ImportError(
|
|
187
|
+
"--impersonate requires curl_cffi. "
|
|
188
|
+
"Install it with: pip install 'threadstractormf[antibot]'"
|
|
189
|
+
) from exc
|
|
190
|
+
|
|
191
|
+
self.impersonate = impersonate
|
|
192
|
+
if impersonate not in known_impersonate_targets():
|
|
193
|
+
examples = ", ".join(sorted(known_impersonate_targets())[:6])
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"unknown impersonation target: {impersonate!r}. "
|
|
196
|
+
f"Valid targets include: {examples} (and versioned forms like "
|
|
197
|
+
"'chrome131' or 'safari18_0')"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
kwargs: dict[str, Any] = {"timeout": (connect_timeout, total_timeout)}
|
|
201
|
+
if http2:
|
|
202
|
+
from curl_cffi.const import CurlHttpVersion
|
|
203
|
+
|
|
204
|
+
kwargs["http_version"] = CurlHttpVersion.V2_0
|
|
205
|
+
self._session = curl_requests.Session(impersonate=impersonate, **kwargs)
|
|
206
|
+
|
|
207
|
+
# A CookieJar instance is converted by curl_cffi on assignment.
|
|
208
|
+
if cookies is not None:
|
|
209
|
+
self._session.cookies = cookies
|
|
210
|
+
if headers:
|
|
211
|
+
self._session.headers.update(headers)
|
|
212
|
+
|
|
213
|
+
def request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
214
|
+
kwargs.setdefault("allow_redirects", True)
|
|
215
|
+
return self._session.request(cast("HttpMethod", method), url, **kwargs)
|
|
216
|
+
|
|
217
|
+
def stream(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
218
|
+
from contextlib import contextmanager
|
|
219
|
+
|
|
220
|
+
verb = cast("HttpMethod", method)
|
|
221
|
+
|
|
222
|
+
@contextmanager
|
|
223
|
+
def _ctx() -> Any:
|
|
224
|
+
with self._session.stream(verb, url, **kwargs) as resp:
|
|
225
|
+
yield _CurlStreamResponse(resp)
|
|
226
|
+
|
|
227
|
+
return _ctx()
|
|
228
|
+
|
|
229
|
+
def close(self) -> None:
|
|
230
|
+
self._session.close()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ─── Playwright ──────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def chromium_launch_args() -> list[str]:
|
|
237
|
+
"""Args for ``chromium.launch``.
|
|
238
|
+
|
|
239
|
+
``--no-sandbox`` is required in many CI/container environments but reduces
|
|
240
|
+
isolation. It can be disabled by setting ``THREADS_USE_SANDBOX=1`` when the
|
|
241
|
+
environment supports an unprivileged sandbox.
|
|
242
|
+
"""
|
|
243
|
+
import os
|
|
244
|
+
|
|
245
|
+
args = ["--disable-blink-features=AutomationControlled"]
|
|
246
|
+
if os.environ.get("THREADS_USE_SANDBOX") != "1":
|
|
247
|
+
args.insert(0, "--no-sandbox")
|
|
248
|
+
return args
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def to_playwright_cookies(jar: Any) -> list[dict[str, Any]]:
|
|
252
|
+
"""Convert a CookieJar into the dict shape ``BrowserContext.add_cookies`` wants.
|
|
253
|
+
|
|
254
|
+
The leading dot on the domain **must be preserved**. Chromium treats a
|
|
255
|
+
domain without one as host-only, so ``threads.net`` would never be sent to
|
|
256
|
+
``www.threads.net`` — i.e. every auth cookie silently dropped and the
|
|
257
|
+
browser left logged out. Verified: with ``.threads.net`` the cookie arrives,
|
|
258
|
+
with ``threads.net`` no ``Cookie`` header is sent at all.
|
|
259
|
+
|
|
260
|
+
Shared by ``ThreadsAPI`` (profile pic + post scraping) and ``scripts/sniff.py``
|
|
261
|
+
so the three former copies of this conversion cannot drift apart again.
|
|
262
|
+
"""
|
|
263
|
+
out: list[dict[str, Any]] = []
|
|
264
|
+
for cookie in jar or ():
|
|
265
|
+
try:
|
|
266
|
+
domain = getattr(cookie, "domain", "") or ""
|
|
267
|
+
if not domain:
|
|
268
|
+
# Host-only cookies have no domain and cannot be added without a
|
|
269
|
+
# URL; skip rather than let add_cookies() fail on the whole batch.
|
|
270
|
+
continue
|
|
271
|
+
out.append(
|
|
272
|
+
{
|
|
273
|
+
"name": cookie.name,
|
|
274
|
+
"value": cookie.value,
|
|
275
|
+
"domain": domain,
|
|
276
|
+
"path": getattr(cookie, "path", "/") or "/",
|
|
277
|
+
"secure": bool(getattr(cookie, "secure", True)),
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
except Exception:
|
|
281
|
+
continue
|
|
282
|
+
return out
|
|
283
|
+
|