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,353 @@
|
|
|
1
|
+
"""Downloader — port of background.js:787-904 with post_id naming.
|
|
2
|
+
|
|
3
|
+
User rules:
|
|
4
|
+
- media.id = post_id if post has a single media
|
|
5
|
+
- media.id = f"{post_id}_{i}" for carousels (1-index)
|
|
6
|
+
- filename = f"{media.id}.{ext}" (no datetime)
|
|
7
|
+
- profile pic = f"{username}_profile.{ext}"
|
|
8
|
+
- URL validated with isValidMediaUrl (CDN allowlist)
|
|
9
|
+
- filename sanitized
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from threadstractormf._backend import (
|
|
23
|
+
CurlClient,
|
|
24
|
+
StreamClient,
|
|
25
|
+
is_transient_error,
|
|
26
|
+
)
|
|
27
|
+
from threadstractormf.archive import DownloadLedger, find_existing_for_post
|
|
28
|
+
from threadstractormf.models import Media, sanitize_filename
|
|
29
|
+
from threadstractormf.rate_limit import BatchCooldownLimiter
|
|
30
|
+
|
|
31
|
+
# Allowlist same as background.js:112-116
|
|
32
|
+
# Hostname suffixes Meta serves media from.
|
|
33
|
+
#
|
|
34
|
+
# Matched as a *domain suffix*, never as a substring. Two reasons:
|
|
35
|
+
# * the CDN rotates regional hosts (instagram.fpei1-1.fna.fbcdn.net,
|
|
36
|
+
# scontent-xx.cdninstagram.com), so pinning literal hosts would go stale,
|
|
37
|
+
# while a suffix keeps working for a POP we have never seen;
|
|
38
|
+
# * a substring match accepts any host that merely embeds the name, which is
|
|
39
|
+
# what let https://instagram.evil.test/x.jpg and
|
|
40
|
+
# https://scontent.evil.com/v/t51.1/a.jpg through.
|
|
41
|
+
#
|
|
42
|
+
# "instagram" and "threads" are deliberately absent: those are brand names, not
|
|
43
|
+
# hostnames.
|
|
44
|
+
_CDN_HOST_SUFFIXES = (
|
|
45
|
+
".cdninstagram.com",
|
|
46
|
+
".cdninstagram.net",
|
|
47
|
+
".fbcdn.net",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Global limiter por defecto (port background.js:16-17). Se puede inyectar otro por llamada.
|
|
51
|
+
_default_limiter = BatchCooldownLimiter(
|
|
52
|
+
cooldown_ms=2000, batch_size=100, batch_cooldown_ms=120000, jitter=0.2, enabled=True
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Directories that already represent one media type. Landing in one of them is
|
|
57
|
+
# taken as "the caller decided where things go", so no further split happens.
|
|
58
|
+
CONTENT_DIRS = frozenset({"photos", "videos", "profile", "posts"})
|
|
59
|
+
|
|
60
|
+
# Browser-like headers: video endpoints (/v/t65.*) reject python-httpx UA with 403.
|
|
61
|
+
CDN_HEADERS = {
|
|
62
|
+
"User-Agent": (
|
|
63
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
64
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
65
|
+
),
|
|
66
|
+
"Accept": "*/*",
|
|
67
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
68
|
+
"Referer": "https://www.threads.net/",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _split_subfolder(media: Media, dest: Path) -> str | None:
|
|
73
|
+
"""Subdirectory for this media, or None to write straight into ``dest``.
|
|
74
|
+
|
|
75
|
+
Behaviour (unchanged, previously inlined):
|
|
76
|
+
|
|
77
|
+
================== ========== ==========================
|
|
78
|
+
media dest.name result
|
|
79
|
+
================== ========== ==========================
|
|
80
|
+
avatar any but ``profile``
|
|
81
|
+
``profile``
|
|
82
|
+
any one of written into dest
|
|
83
|
+
CONTENT_DIRS
|
|
84
|
+
video anything ``videos``
|
|
85
|
+
else
|
|
86
|
+
image anything ``photos``
|
|
87
|
+
else
|
|
88
|
+
================== ========== ==========================
|
|
89
|
+
|
|
90
|
+
Note the folder comes from ``media.type`` (what the API declares), while the
|
|
91
|
+
file extension comes from the URL. The two are independent: a ``.webm`` video
|
|
92
|
+
is ``type="video"`` and therefore lands in ``videos/``.
|
|
93
|
+
"""
|
|
94
|
+
if media.id.endswith("_profile"):
|
|
95
|
+
return None if dest.name == "profile" else "profile"
|
|
96
|
+
if dest.name in CONTENT_DIRS:
|
|
97
|
+
return None
|
|
98
|
+
return "videos" if media.type == "video" else "photos"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_valid_media_url(url: str) -> bool:
|
|
102
|
+
"""Port background.js:96-144 isValidMediaUrl, with a domain-suffix host check."""
|
|
103
|
+
if not url or not isinstance(url, str):
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
u = urlparse(url)
|
|
107
|
+
if u.scheme != "https":
|
|
108
|
+
return False
|
|
109
|
+
# A fully-qualified name may arrive with a trailing dot.
|
|
110
|
+
host = u.netloc.lower().rstrip(".")
|
|
111
|
+
# s[1:] allows the bare registrable domain (cdninstagram.com); otherwise
|
|
112
|
+
# the dot must match exactly one label boundary, so cdninstagram.com.evil.io
|
|
113
|
+
# and notcdninstagram.com are both rejected.
|
|
114
|
+
if not any(host == s[1:] or host.endswith(s) for s in _CDN_HOST_SUFFIXES):
|
|
115
|
+
return False
|
|
116
|
+
path = u.path.lower()
|
|
117
|
+
has_ext = bool(re.search(r"\.(jpg|jpeg|png|webp|gif|mp4|webm|mov|avi)(\?|$)", path))
|
|
118
|
+
has_media_path = (
|
|
119
|
+
"/v/t51." in path or "/image/" in path or "/video/" in path or "/media/" in path
|
|
120
|
+
)
|
|
121
|
+
has_qs = bool(u.query)
|
|
122
|
+
return has_ext or has_media_path or has_qs
|
|
123
|
+
except Exception:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Retry policy for transient network errors (DNS resolution flaps, timeouts,
|
|
128
|
+
# CDN hiccups). Extra attempts give the resolver/network time to recover.
|
|
129
|
+
_DOWNLOAD_ATTEMPTS = 3
|
|
130
|
+
_DOWNLOAD_BACKOFF_S = (2.0, 5.0)
|
|
131
|
+
|
|
132
|
+
# Generous CDN timeouts: slow-yet-active transfers stay alive (read timeout is
|
|
133
|
+
# per-chunk, not total) and DNS/TLS setup gets breathing room on bad networks.
|
|
134
|
+
_CDN_TIMEOUT = httpx.Timeout(connect=15.0, read=45.0, write=45.0, pool=30.0)
|
|
135
|
+
|
|
136
|
+
# curl_cffi takes (connect, total): the second value bounds the whole transfer,
|
|
137
|
+
# so it must be well above the per-chunk httpx read budget or big videos on slow
|
|
138
|
+
# links get cut mid-download.
|
|
139
|
+
_CDN_CURL_TIMEOUT = (15.0, 900.0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_download_client(
|
|
143
|
+
*, impersonate: str | None, headers: dict[str, str]
|
|
144
|
+
) -> StreamClient:
|
|
145
|
+
"""Create the CDN client: Chrome impersonation when asked, else httpx."""
|
|
146
|
+
if impersonate:
|
|
147
|
+
return CurlClient(
|
|
148
|
+
impersonate=impersonate,
|
|
149
|
+
headers=headers,
|
|
150
|
+
connect_timeout=_CDN_CURL_TIMEOUT[0],
|
|
151
|
+
total_timeout=_CDN_CURL_TIMEOUT[1],
|
|
152
|
+
)
|
|
153
|
+
return httpx.Client(follow_redirects=True, timeout=_CDN_TIMEOUT, http2=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class DownloadStatus(str, Enum):
|
|
157
|
+
"""Whether a media call actually fetched bytes.
|
|
158
|
+
|
|
159
|
+
download_media used to return a bare Path from every branch, so a file the
|
|
160
|
+
ledger already knew about was indistinguishable from a fresh download: the
|
|
161
|
+
CLI announced both as "downloaded" and any summary built on it would have
|
|
162
|
+
been fiction.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
DOWNLOADED = "downloaded"
|
|
166
|
+
SKIPPED = "skipped" # the ledger already had this (post_id, index)
|
|
167
|
+
ADOPTED = "adopted" # found on disk under another name
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class DownloadOutcome:
|
|
172
|
+
"""Result of one media download: where it ended up, and how it got there."""
|
|
173
|
+
|
|
174
|
+
path: Path
|
|
175
|
+
status: DownloadStatus
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def download_media(
|
|
179
|
+
media: Media,
|
|
180
|
+
dest: str | Path,
|
|
181
|
+
*,
|
|
182
|
+
client: StreamClient | None = None,
|
|
183
|
+
impersonate: str | None = None,
|
|
184
|
+
overwrite: bool = False,
|
|
185
|
+
limiter: BatchCooldownLimiter | None = None,
|
|
186
|
+
rate_limit: bool = True,
|
|
187
|
+
filename_template: str | None = None,
|
|
188
|
+
username: str | None = None,
|
|
189
|
+
date_iso: str | None = None,
|
|
190
|
+
ledger: DownloadLedger | None = None,
|
|
191
|
+
adopt_existing: bool = True,
|
|
192
|
+
) -> DownloadOutcome:
|
|
193
|
+
"""Download a Media to dest.
|
|
194
|
+
|
|
195
|
+
Returns where it landed plus whether bytes were actually fetched: a media
|
|
196
|
+
the ledger already knew about returns SKIPPED, not DOWNLOADED.
|
|
197
|
+
|
|
198
|
+
Applies anti rate-limit if ``rate_limit=True``. When ``filename_template`` is
|
|
199
|
+
given it generates the filename (gallery-dl style).
|
|
200
|
+
|
|
201
|
+
Skipping happens at three levels, cheapest first:
|
|
202
|
+
|
|
203
|
+
1. ``(post_id, index)`` already in ``ledger`` — immune to the filename
|
|
204
|
+
changing (a fixed extension guess, a different template, a date that
|
|
205
|
+
rolled over);
|
|
206
|
+
2. the exact destination file exists — records it in the ledger;
|
|
207
|
+
3. with ``adopt_existing``, a file for the same ``post_id`` under a different
|
|
208
|
+
name is adopted — this is what saves a historical archive from being
|
|
209
|
+
re-downloaded when the naming scheme is corrected.
|
|
210
|
+
|
|
211
|
+
Levels 2 and 3 make the ledger self-seeding: the first run after upgrading
|
|
212
|
+
behaves exactly as before (filename dedup) while filling the ledger.
|
|
213
|
+
``overwrite`` bypasses all three.
|
|
214
|
+
"""
|
|
215
|
+
if not is_valid_media_url(media.url):
|
|
216
|
+
raise ValueError(f"invalid download URL: {media.url}")
|
|
217
|
+
|
|
218
|
+
root = Path(dest).expanduser().resolve()
|
|
219
|
+
# Split photos/videos/profile only when dest is not already one of them.
|
|
220
|
+
# The ledger key is derived from `root`, BEFORE the split, so one account
|
|
221
|
+
# keeps a single ledger instead of one per media type.
|
|
222
|
+
subfolder = _split_subfolder(media, root)
|
|
223
|
+
dest = root / subfolder if subfolder else root
|
|
224
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
|
|
226
|
+
if filename_template:
|
|
227
|
+
# For profile, ignore date template and use simple username
|
|
228
|
+
if media.id.endswith("_profile") and "{date" in filename_template:
|
|
229
|
+
filename = media.filename(
|
|
230
|
+
template="{username}_profile.{extension}", username=username, date_iso=date_iso
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
filename = media.filename(
|
|
234
|
+
template=filename_template, username=username, date_iso=date_iso
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
filename = media.filename()
|
|
238
|
+
filename = sanitize_filename(filename)
|
|
239
|
+
out = dest / filename
|
|
240
|
+
|
|
241
|
+
if not overwrite:
|
|
242
|
+
# 1. the ledger knows about it under any name
|
|
243
|
+
if ledger is not None and ledger.has(media.post_id, media.index):
|
|
244
|
+
return DownloadOutcome(out, DownloadStatus.SKIPPED)
|
|
245
|
+
# 2. the exact file is already there -> adopt it into the ledger
|
|
246
|
+
if out.exists():
|
|
247
|
+
if ledger is not None:
|
|
248
|
+
ledger.record(media.post_id, media.index, out)
|
|
249
|
+
return DownloadOutcome(out, DownloadStatus.ADOPTED)
|
|
250
|
+
# 3. the same post under a *different* name (extension fix, template
|
|
251
|
+
# change, date rollover). The ledger starts empty on the first run
|
|
252
|
+
# after such a change, so without this the whole archive re-downloads.
|
|
253
|
+
if adopt_existing and ledger is not None:
|
|
254
|
+
adopted = find_existing_for_post(dest, media.post_id, media.index)
|
|
255
|
+
if adopted is not None:
|
|
256
|
+
ledger.record(media.post_id, media.index, adopted)
|
|
257
|
+
return DownloadOutcome(adopted, DownloadStatus.ADOPTED)
|
|
258
|
+
|
|
259
|
+
# Anti rate-limit: wait cooldown before downloading (port background.js:789-792)
|
|
260
|
+
use_limiter = limiter if limiter is not None else _default_limiter
|
|
261
|
+
# allow disabling globally
|
|
262
|
+
should_wait = rate_limit and use_limiter.enabled
|
|
263
|
+
if should_wait:
|
|
264
|
+
use_limiter.wait()
|
|
265
|
+
|
|
266
|
+
close_client = False
|
|
267
|
+
if client is None:
|
|
268
|
+
client = _build_download_client(impersonate=impersonate, headers=CDN_HEADERS)
|
|
269
|
+
close_client = True
|
|
270
|
+
|
|
271
|
+
# Retry loop for transient network errors; writes go to a .part temp file
|
|
272
|
+
# so a failed attempt never leaves a truncated media at the final path.
|
|
273
|
+
import sys
|
|
274
|
+
import time as _time
|
|
275
|
+
|
|
276
|
+
tmp = out.with_name(out.name + ".part")
|
|
277
|
+
last_exc: BaseException | None = None
|
|
278
|
+
try:
|
|
279
|
+
for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
|
|
280
|
+
try:
|
|
281
|
+
with client.stream("GET", media.url, headers=CDN_HEADERS) as r:
|
|
282
|
+
r.raise_for_status()
|
|
283
|
+
with tmp.open("wb") as f:
|
|
284
|
+
for chunk in r.iter_bytes(chunk_size=8192):
|
|
285
|
+
f.write(chunk)
|
|
286
|
+
tmp.replace(out)
|
|
287
|
+
# El cooldown de lote se cuenta solo tras una descarga exitosa.
|
|
288
|
+
if hasattr(use_limiter, "increment"):
|
|
289
|
+
use_limiter.increment()
|
|
290
|
+
if ledger is not None:
|
|
291
|
+
ledger.record(media.post_id, media.index, out)
|
|
292
|
+
return DownloadOutcome(out, DownloadStatus.DOWNLOADED)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
last_exc = e
|
|
295
|
+
tmp.unlink(missing_ok=True)
|
|
296
|
+
if attempt < _DOWNLOAD_ATTEMPTS and is_transient_error(e):
|
|
297
|
+
wait_s = _DOWNLOAD_BACKOFF_S[
|
|
298
|
+
min(attempt - 1, len(_DOWNLOAD_BACKOFF_S) - 1)
|
|
299
|
+
]
|
|
300
|
+
print(
|
|
301
|
+
f" {media.id} retrying ({attempt}/{_DOWNLOAD_ATTEMPTS - 1}) after "
|
|
302
|
+
f"{wait_s:.0f}s: {e}",
|
|
303
|
+
file=sys.stderr,
|
|
304
|
+
flush=True,
|
|
305
|
+
)
|
|
306
|
+
_time.sleep(wait_s)
|
|
307
|
+
continue
|
|
308
|
+
raise
|
|
309
|
+
raise RuntimeError("download loop exited without result") from last_exc
|
|
310
|
+
finally:
|
|
311
|
+
if close_client:
|
|
312
|
+
client.close()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def download_profile_pic(
|
|
316
|
+
url: str,
|
|
317
|
+
username: str,
|
|
318
|
+
dest: str | Path,
|
|
319
|
+
*,
|
|
320
|
+
client: StreamClient | None = None,
|
|
321
|
+
impersonate: str | None = None,
|
|
322
|
+
limiter: BatchCooldownLimiter | None = None,
|
|
323
|
+
rate_limit: bool = True,
|
|
324
|
+
filename_template: str | None = None,
|
|
325
|
+
ledger: DownloadLedger | None = None,
|
|
326
|
+
adopt_existing: bool = True,
|
|
327
|
+
) -> DownloadOutcome:
|
|
328
|
+
"""Download profile pic with id f"{username}_profile"."""
|
|
329
|
+
# Derivar la extension con el mismo criterio que el resto del scraper,
|
|
330
|
+
# para que Media.ext y el archivo escrito coincidan.
|
|
331
|
+
from threadstractormf._parsing import _guess_ext
|
|
332
|
+
from threadstractormf.models import Media
|
|
333
|
+
|
|
334
|
+
ext = _guess_ext(url)
|
|
335
|
+
media = Media(
|
|
336
|
+
id=f"{sanitize_filename(username)}_profile",
|
|
337
|
+
post_id=f"{sanitize_filename(username)}_profile",
|
|
338
|
+
index=1,
|
|
339
|
+
type="image",
|
|
340
|
+
url=url,
|
|
341
|
+
ext=ext,
|
|
342
|
+
)
|
|
343
|
+
# profile uses filename_template if given, else {username}_{media_id}.{ext}
|
|
344
|
+
if filename_template:
|
|
345
|
+
return download_media(
|
|
346
|
+
media, dest, client=client, impersonate=impersonate, limiter=limiter,
|
|
347
|
+
rate_limit=rate_limit, filename_template=filename_template, username=username,
|
|
348
|
+
ledger=ledger, adopt_existing=adopt_existing,
|
|
349
|
+
)
|
|
350
|
+
return download_media(
|
|
351
|
+
media, dest, client=client, impersonate=impersonate, limiter=limiter,
|
|
352
|
+
rate_limit=rate_limit, ledger=ledger, adopt_existing=adopt_existing,
|
|
353
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Pydantic models — map the extension post_metadata but with post_id naming.
|
|
2
|
+
|
|
3
|
+
Extension (content.js:678-695):
|
|
4
|
+
username, datetime_iso, datetime_display, post_permalink, media_urls,
|
|
5
|
+
post_content, like_count, reply_count
|
|
6
|
+
Library:
|
|
7
|
+
Post.id = post_id extracted from permalink /post/<ID> (regex content.js:311)
|
|
8
|
+
Media.id = post_id if single media, or f"{post_id}_{i}" for carousels (user spec)
|
|
9
|
+
Profile.profile_pic with id f"{username}_profile"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field, field_validator
|
|
17
|
+
|
|
18
|
+
MediaType = Literal["image", "video"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Media(BaseModel):
|
|
22
|
+
id: str = Field(description="media_id: post_id or post_id_N for carousels")
|
|
23
|
+
post_id: str = Field(description="base post_id (/post/<ID>)")
|
|
24
|
+
index: int = Field(ge=1, description="1-index within the carousel")
|
|
25
|
+
type: MediaType
|
|
26
|
+
url: str
|
|
27
|
+
ext: str = Field(description="jpg|mp4|webp... derived from URL")
|
|
28
|
+
width: int | None = None
|
|
29
|
+
height: int | None = None
|
|
30
|
+
|
|
31
|
+
@field_validator("ext")
|
|
32
|
+
@classmethod
|
|
33
|
+
def normalize_ext(cls, v: str) -> str:
|
|
34
|
+
return v.lower().lstrip(".")
|
|
35
|
+
|
|
36
|
+
def filename(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
prefix: str | None = None,
|
|
40
|
+
template: str | None = None,
|
|
41
|
+
username: str | None = None,
|
|
42
|
+
category: str = "threads",
|
|
43
|
+
subcategory: str = "posts",
|
|
44
|
+
date_iso: str | None = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Generate filename. If template is None uses simple post_id naming,
|
|
47
|
+
otherwise gallery-dl style template."""
|
|
48
|
+
if template:
|
|
49
|
+
from threadstractormf.template import render_filename
|
|
50
|
+
|
|
51
|
+
return render_filename(
|
|
52
|
+
template,
|
|
53
|
+
post_id=self.post_id,
|
|
54
|
+
media_id=self.id,
|
|
55
|
+
username=username or self.post_id,
|
|
56
|
+
category=category,
|
|
57
|
+
subcategory=subcategory,
|
|
58
|
+
extension=self.ext,
|
|
59
|
+
num=self.index,
|
|
60
|
+
date_iso=date_iso,
|
|
61
|
+
)
|
|
62
|
+
# For profile pics, id is already username_profile
|
|
63
|
+
if self.post_id.endswith("_profile"):
|
|
64
|
+
return f"{self.id}.{self.ext}"
|
|
65
|
+
# Detect carousel by id != post_id
|
|
66
|
+
if self.id != self.post_id:
|
|
67
|
+
return f"{self.id}.{self.ext}"
|
|
68
|
+
return f"{self.post_id}.{self.ext}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Post(BaseModel):
|
|
72
|
+
id: str = Field(description="post_id = short code from /post/<ID>")
|
|
73
|
+
permalink: str
|
|
74
|
+
username: str
|
|
75
|
+
caption: str | None = None
|
|
76
|
+
datetime_iso: str | None = None
|
|
77
|
+
datetime_display: str | None = None
|
|
78
|
+
like_count: int = 0
|
|
79
|
+
reply_count: int = 0
|
|
80
|
+
media: list[Media] = Field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_carousel(self) -> bool:
|
|
84
|
+
return len(self.media) > 1
|
|
85
|
+
|
|
86
|
+
def primary_media(self) -> Media | None:
|
|
87
|
+
return self.media[0] if self.media else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Profile(BaseModel):
|
|
91
|
+
"""What ``get_profile`` can actually resolve today.
|
|
92
|
+
|
|
93
|
+
Only fields the scraper fills in are declared: a field that always came back
|
|
94
|
+
``None`` would be an API promise the library cannot keep. Bio, follower count
|
|
95
|
+
and privacy state are available in the page HTML but not parsed yet; add
|
|
96
|
+
them here together with the extractor that populates them.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
username: str
|
|
100
|
+
user_id: str | None = None
|
|
101
|
+
profile_pic_url: str | None = None
|
|
102
|
+
profile_pic_media: Media | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Helpers portados de background.js
|
|
106
|
+
def extract_post_id(permalink: str) -> str | None:
|
|
107
|
+
"""Extract post_id from permalink. Replicates content.js:311 regex /post/([^/]+)."""
|
|
108
|
+
import re
|
|
109
|
+
|
|
110
|
+
m = re.search(r"/post/([^/?#]+)", permalink)
|
|
111
|
+
return m.group(1) if m else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def sanitize_filename(name: str) -> str:
|
|
115
|
+
"""Port of background.js:147-154 sanitizeFilename."""
|
|
116
|
+
import re
|
|
117
|
+
|
|
118
|
+
s = re.sub(r"[\/\\\?\*\|<>:\"\"\x00-\x1f\x7f]", "_", name)
|
|
119
|
+
s = s.replace("..", "_")
|
|
120
|
+
s = s.lstrip(".")
|
|
121
|
+
s = re.sub(r"_+", "_", s)
|
|
122
|
+
s = s.strip("_")
|
|
123
|
+
return s[:100]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Anti rate-limit — port of background.js cooldown + API backoff.
|
|
2
|
+
|
|
3
|
+
background.js: cooldownMs=2000, cooldownAfter100=120000, lastCooldownMilestone
|
|
4
|
+
Python: FixedCooldownLimiter + BatchCooldownLimiter + ExponentialBackoff
|
|
5
|
+
Compatible with gallery-dl --sleep-request / yt-dlp --sleep-interval
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RateLimitConfig:
|
|
17
|
+
cooldown_ms: int = 2000 # between downloads (background.js:16)
|
|
18
|
+
cooldown_after_100_ms: int = 120000 # every 100 (background.js:17)
|
|
19
|
+
jitter: float = 0.2 # 20% random to avoid thundering herd
|
|
20
|
+
rps: float = 0.5 # for GraphQL API: 1 req / 2s
|
|
21
|
+
max_retries: int = 5
|
|
22
|
+
backoff_base: float = 2.0
|
|
23
|
+
enabled: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FixedCooldownLimiter:
|
|
27
|
+
"""Wait cooldown_ms between consecutive calls (CDN downloads)."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, cooldown_ms: int = 2000, jitter: float = 0.2, enabled: bool = True):
|
|
30
|
+
self.cooldown_ms = cooldown_ms
|
|
31
|
+
self.jitter = jitter
|
|
32
|
+
self.enabled = enabled
|
|
33
|
+
self._last: float | None = None
|
|
34
|
+
|
|
35
|
+
def wait(self) -> None:
|
|
36
|
+
if not self.enabled or self.cooldown_ms <= 0:
|
|
37
|
+
return
|
|
38
|
+
now = time.monotonic() * 1000
|
|
39
|
+
if self._last is None:
|
|
40
|
+
self._last = now
|
|
41
|
+
return
|
|
42
|
+
elapsed = now - self._last
|
|
43
|
+
wait_ms = self.cooldown_ms - elapsed
|
|
44
|
+
if wait_ms > 0:
|
|
45
|
+
# jitter ±
|
|
46
|
+
if self.jitter:
|
|
47
|
+
wait_ms *= 1 + random.uniform(-self.jitter, self.jitter)
|
|
48
|
+
wait_ms = max(0, wait_ms)
|
|
49
|
+
time.sleep(wait_ms / 1000)
|
|
50
|
+
self._last = time.monotonic() * 1000
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BatchCooldownLimiter(FixedCooldownLimiter):
|
|
54
|
+
"""Extends Fixed with a long cooldown every N downloads (background.js batch)."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
cooldown_ms: int = 2000,
|
|
59
|
+
batch_size: int = 100,
|
|
60
|
+
batch_cooldown_ms: int = 120000,
|
|
61
|
+
jitter: float = 0.2,
|
|
62
|
+
enabled: bool = True,
|
|
63
|
+
):
|
|
64
|
+
super().__init__(cooldown_ms, jitter, enabled)
|
|
65
|
+
self.batch_size = batch_size
|
|
66
|
+
self.batch_cooldown_ms = batch_cooldown_ms
|
|
67
|
+
self._count = 0
|
|
68
|
+
self._last_batch: int = 0
|
|
69
|
+
|
|
70
|
+
def increment(self) -> None:
|
|
71
|
+
self._count += 1
|
|
72
|
+
# if it is a batch multiple and not the same milestone
|
|
73
|
+
milestone = (self._count // self.batch_size) * self.batch_size
|
|
74
|
+
if milestone > 0 and milestone != self._last_batch and self._count % self.batch_size == 0:
|
|
75
|
+
self._last_batch = milestone
|
|
76
|
+
if self.enabled and self.batch_cooldown_ms > 0:
|
|
77
|
+
wait = self.batch_cooldown_ms / 1000
|
|
78
|
+
if self.jitter:
|
|
79
|
+
wait *= 1 + random.uniform(-self.jitter, self.jitter)
|
|
80
|
+
time.sleep(max(0, wait))
|
|
81
|
+
|
|
82
|
+
def wait(self) -> None:
|
|
83
|
+
# Solo pacing: el conteo (y el cooldown de lote) se hace tras una
|
|
84
|
+
# descarga exitosa, para no penalizar intentos fallidos.
|
|
85
|
+
super().wait()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ApiRateLimiter:
|
|
89
|
+
"""Paced limiter for GraphQL requests, with adaptive back-pressure.
|
|
90
|
+
|
|
91
|
+
Base pacing is a plain interval (``rps=0.5`` -> one request every 2s), which
|
|
92
|
+
is deliberately slow because Meta answers a burst with 429s and, eventually,
|
|
93
|
+
with a WAF block.
|
|
94
|
+
|
|
95
|
+
On top of that the limiter reacts to what the server actually says:
|
|
96
|
+
|
|
97
|
+
* a 429 **doubles** the interval (capped at ``max_interval``), so repeated
|
|
98
|
+
complaints space requests out exponentially instead of hammering;
|
|
99
|
+
* a streak of successful requests **relaxes** it back toward the base rate,
|
|
100
|
+
one step at a time, so a single 429 does not permanently slow a crawl.
|
|
101
|
+
|
|
102
|
+
Net effect: a full profile still gets walked to the end, but the request rate
|
|
103
|
+
backs off exactly as much as the server demands and recovers on its own.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
rps: float = 0.5,
|
|
109
|
+
enabled: bool = True,
|
|
110
|
+
*,
|
|
111
|
+
max_interval: float = 60.0,
|
|
112
|
+
penalty_factor: float = 2.0,
|
|
113
|
+
relax_after: int = 5,
|
|
114
|
+
relax_factor: float = 0.75,
|
|
115
|
+
):
|
|
116
|
+
self.rps = rps
|
|
117
|
+
self.enabled = enabled
|
|
118
|
+
self.max_interval = max_interval
|
|
119
|
+
self.penalty_factor = penalty_factor
|
|
120
|
+
self.relax_after = relax_after
|
|
121
|
+
self.relax_factor = relax_factor
|
|
122
|
+
self._last: float | None = None
|
|
123
|
+
self._base_interval = 1.0 / rps if rps > 0 else 0.0
|
|
124
|
+
self._penalty = 1.0
|
|
125
|
+
self._ok_streak = 0
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def base_interval(self) -> float:
|
|
129
|
+
return self._base_interval
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def penalty(self) -> float:
|
|
133
|
+
return self._penalty
|
|
134
|
+
|
|
135
|
+
def current_interval(self) -> float:
|
|
136
|
+
"""Effective seconds to wait between requests right now."""
|
|
137
|
+
if self._base_interval <= 0:
|
|
138
|
+
return 0.0
|
|
139
|
+
return min(self._base_interval * self._penalty, self.max_interval)
|
|
140
|
+
|
|
141
|
+
def _max_penalty(self) -> float:
|
|
142
|
+
if self._base_interval <= 0:
|
|
143
|
+
return 1.0
|
|
144
|
+
return self.max_interval / self._base_interval
|
|
145
|
+
|
|
146
|
+
def penalize(self) -> None:
|
|
147
|
+
"""Call on 429 (or any rate-limit signal): back off harder."""
|
|
148
|
+
if not self.enabled:
|
|
149
|
+
return
|
|
150
|
+
self._penalty = min(self._penalty * self.penalty_factor, self._max_penalty())
|
|
151
|
+
self._ok_streak = 0
|
|
152
|
+
|
|
153
|
+
def relax(self) -> None:
|
|
154
|
+
"""Call on success: decay the penalty back toward the base rate.
|
|
155
|
+
|
|
156
|
+
Decay is multiplicative so recovery from a deep penalty converges in a
|
|
157
|
+
handful of streaks. An additive step needed ~1160 successful requests to
|
|
158
|
+
climb back from the 60s cap, which in practice never happened.
|
|
159
|
+
"""
|
|
160
|
+
if not self.enabled or self._penalty <= 1.0:
|
|
161
|
+
return
|
|
162
|
+
self._ok_streak += 1
|
|
163
|
+
if self._ok_streak >= self.relax_after:
|
|
164
|
+
self._penalty = max(1.0, self._penalty * self.relax_factor)
|
|
165
|
+
self._ok_streak = 0
|
|
166
|
+
|
|
167
|
+
def wait(self) -> None:
|
|
168
|
+
if not self.enabled or self._base_interval <= 0:
|
|
169
|
+
return
|
|
170
|
+
now = time.monotonic()
|
|
171
|
+
if self._last is None:
|
|
172
|
+
self._last = now
|
|
173
|
+
return
|
|
174
|
+
elapsed = now - self._last
|
|
175
|
+
wait = self.current_interval() - elapsed
|
|
176
|
+
if wait > 0:
|
|
177
|
+
time.sleep(wait)
|
|
178
|
+
self._last = time.monotonic()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def backoff_sleep(
|
|
182
|
+
attempt: int, *, base: float = 2.0, max_sleep: float = 60.0, jitter: float = 0.3
|
|
183
|
+
) -> float:
|
|
184
|
+
"""Compute exponential sleep for 429/500. Returns seconds slept."""
|
|
185
|
+
sleep = min(max_sleep, (base**attempt))
|
|
186
|
+
if jitter:
|
|
187
|
+
sleep *= 1 + random.uniform(-jitter, jitter)
|
|
188
|
+
sleep = max(0, sleep)
|
|
189
|
+
time.sleep(sleep)
|
|
190
|
+
return sleep
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_retry_after(headers: dict) -> float | None:
|
|
194
|
+
"""Extract Retry-After if present (seconds)."""
|
|
195
|
+
for k, v in headers.items():
|
|
196
|
+
if k.lower() == "retry-after":
|
|
197
|
+
try:
|
|
198
|
+
return float(str(v).split(",")[0].strip())
|
|
199
|
+
except Exception:
|
|
200
|
+
return None
|
|
201
|
+
return None
|