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,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
+