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