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,426 @@
1
+ """gallery-dl / yt-dlp style CLI — entry point: threadstractormf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.cookiejar
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from threadstractormf import __version__
13
+ from threadstractormf.api import ScrapeReport
14
+ from threadstractormf.auth import load_netscape_cookies
15
+ from threadstractormf.downloader import DownloadOutcome, DownloadStatus
16
+
17
+ try: # browser_cookie3 is an optional extra
18
+ from browser_cookie3 import BrowserCookieError as _BrowserCookieError
19
+ except ImportError: # pragma: no cover
20
+
21
+ class _BrowserCookieError(Exception): # type: ignore[no-redef]
22
+ pass
23
+
24
+ app = typer.Typer(
25
+ add_completion=False,
26
+ help="threadstractormf — Threads downloader (post_id naming, gallery-dl cookies)",
27
+ )
28
+ console = Console()
29
+ err_console = Console(stderr=True)
30
+
31
+
32
+ # stdout carries the "one line per downloaded file" contract that scrapmf counts,
33
+ # so nothing printed to it may be wrapped. Rich wraps at the terminal width
34
+ # (80 columns when piped), which silently turned a single 600-character CDN URL
35
+ # into eight lines and made an orchestrator count eight files instead of one.
36
+ # ``soft_wrap=True`` disables that for the lines we emit.
37
+
38
+
39
+ def _count(outcome: DownloadOutcome, report: ScrapeReport) -> None:
40
+ """Tally one media outcome so the summary can tell the truth."""
41
+ if outcome.status is DownloadStatus.DOWNLOADED:
42
+ report.downloaded += 1
43
+ else:
44
+ report.skipped += 1
45
+
46
+
47
+ def _report_scrape(report: ScrapeReport) -> None:
48
+ """Print what the scrape actually found, and what it could not.
49
+
50
+ Goes to stderr: stdout is the "one line per downloaded file" contract, and
51
+ a summary there would be counted as a file by scrapmf. Before this, a
52
+ scrape that returned 4 posts out of a 60-post profile ended in complete
53
+ silence and was indistinguishable from a full one.
54
+ """
55
+ parts = [
56
+ f"posts: {report.posts}",
57
+ f"media: {report.media}",
58
+ f"downloaded: {report.downloaded}",
59
+ f"skipped: {report.skipped}",
60
+ ]
61
+ # Only when it is non-zero: `filtered: 0` on every unfiltered run is noise,
62
+ # and it only explains a number the reader cannot otherwise reconcile.
63
+ if report.filtered:
64
+ parts.append(f"filtered: {report.filtered}")
65
+ parts.append(f"failed: {report.failed}")
66
+ if report.profile_pic:
67
+ parts.append("profile pic: 1")
68
+ # `downloaded: 0` next to a non-zero `skipped` reads as a failed run when it
69
+ # is the opposite: every file was already there. Say so.
70
+ if not report.downloaded and report.skipped:
71
+ err_console.print(
72
+ f"note — nothing was downloaded because all {report.skipped} "
73
+ "file(s) were already on disk.",
74
+ soft_wrap=True,
75
+ )
76
+ if report.no_media:
77
+ parts.append(f"without media: {report.no_media}")
78
+ if report.reposts_skipped:
79
+ parts.append(f"reposts excluded: {report.reposts_skipped}")
80
+ if report.unparsed:
81
+ parts.append(f"unparseable: {report.unparsed}")
82
+ err_console.print("summary — " + " | ".join(parts), soft_wrap=True)
83
+
84
+ if report.api_error:
85
+ err_console.print(
86
+ f"warning — the API did not respond as expected: {report.api_error}",
87
+ soft_wrap=True,
88
+ )
89
+ if report.used_fallback:
90
+ msg = (
91
+ "warning — the browser fallback was used: it is slower than the "
92
+ "API, but it reads the public profile in full."
93
+ )
94
+ if report.fallback_reason:
95
+ msg += f" It stopped because {report.fallback_reason}."
96
+ err_console.print(msg, soft_wrap=True)
97
+ elif report.truncated:
98
+ # Only when the API walk is the one that delivered the posts. With the
99
+ # fallback, truncation is folded into the message above: printing both
100
+ # claimed the profile was read in full and cut short at the same time,
101
+ # and the reason quoted belonged to the API attempt that had already
102
+ # handed over.
103
+ err_console.print(
104
+ f"warning — the walk stopped before exhausting the profile "
105
+ f"({report.stop_reason or 'unknown reason'}).",
106
+ soft_wrap=True,
107
+ )
108
+ if report.failed:
109
+ err_console.print(
110
+ f"warning — {report.failed} media item(s) could not be downloaded.",
111
+ soft_wrap=True,
112
+ )
113
+
114
+
115
+ def _version_callback(value: bool) -> None:
116
+ """Print the version and exit.
117
+
118
+ Eager and callback-style rather than a plain option in main(): the target
119
+ argument is required, so a plain flag could not run before typer rejected
120
+ the missing argument. A callback with is_eager runs first and exits.
121
+ """
122
+ if value:
123
+ # __version__ is what semantic-release rewrites (version_variables in
124
+ # pyproject.toml), so this can never drift from the released build.
125
+ console.print(f"threadstractormf {__version__}", soft_wrap=True)
126
+ raise typer.Exit()
127
+
128
+
129
+ @app.command()
130
+ def main(
131
+ target: str = typer.Argument(..., help="URL https://www.threads.net/@user/media or @username"),
132
+ version: bool = typer.Option(
133
+ False,
134
+ "--version",
135
+ callback=_version_callback,
136
+ is_eager=True,
137
+ help="Show the installed version and exit",
138
+ ),
139
+ cookies: Path | None = typer.Option(
140
+ None, "--cookies", "-c", help="Netscape cookies.txt (gallery-dl compatible)"
141
+ ),
142
+ cookies_from_browser: str | None = typer.Option(
143
+ None,
144
+ "--cookies-from-browser",
145
+ help="brave|chrome|firefox (reads local DB, like gallery-dl)",
146
+ ),
147
+ dest: Path = typer.Option(Path("./dl"), "--dest", "-d", help="Output directory"),
148
+ limit: int | None = typer.Option(None, "--limit", "-l", help="Posts limit (default all)"),
149
+ profile_pic_only: bool = typer.Option(False, "--profile-pic-only", help="Profile pic only"),
150
+ photos_only: bool = typer.Option(False, "--photos-only", help="Photos only"),
151
+ videos_only: bool = typer.Option(False, "--videos-only", help="Videos only"),
152
+ impersonate: str | None = typer.Option(
153
+ None, "--impersonate", help="chrome (requires curl_cffi to be installed)"
154
+ ),
155
+ overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing files"),
156
+ no_rate_limit: bool = typer.Option(
157
+ False, "--no-rate-limit", help="Disable anti rate-limit (not recommended)"
158
+ ),
159
+ cooldown: int = typer.Option(
160
+ 2000, "--cooldown", help="Cooldown between downloads ms (default 2000, same as extension)"
161
+ ),
162
+ batch_cooldown: int = typer.Option(
163
+ 120000, "--batch-cooldown", help="Cooldown every 100 downloads ms (default 120000)"
164
+ ),
165
+ rps: float = typer.Option(0.5, "--rps", help="Requests/s for GraphQL API (default 0.5)"),
166
+ filename_template: str | None = typer.Option(
167
+ None,
168
+ "--filename-template",
169
+ help="Template filename like gallery-dl: {date:%Y-%m-%d}_{post_id}_{num:02d}.{extension}",
170
+ ),
171
+ directory_template: str | None = typer.Option(
172
+ None,
173
+ "--directory-template",
174
+ help="Template directory, e.g. {scrapmf_root}/{category}/{username}/{subcategory}",
175
+ ),
176
+ archive: bool = typer.Option(
177
+ False,
178
+ "--archive",
179
+ help="Keep a ledger of downloads in <dest>/.archive so re-runs skip them",
180
+ ),
181
+ no_adopt_existing: bool = typer.Option(
182
+ False,
183
+ "--no-adopt-existing",
184
+ help="Do not adopt files already on disk under an older name",
185
+ ),
186
+ get_urls: bool = typer.Option(
187
+ False,
188
+ "--get-urls",
189
+ help="Print the media URLs instead of downloading (dry run)",
190
+ ),
191
+ ) -> None:
192
+ """Download Threads media by post_id."""
193
+ # normalize @user — supports threads.net and threads.com, URL or @handle
194
+ import re as _re
195
+
196
+ if "threads.com" in target or "threads.net" in target:
197
+ m = _re.search(r"/@([^/]+)", target)
198
+ if m:
199
+ username = m.group(1)
200
+ else:
201
+ # fallback: last non-empty segment
202
+ username = target.strip().rstrip("/").split("/")[-1].lstrip("@")
203
+ else:
204
+ username = target.lstrip("@").split("/")[0]
205
+ if not username:
206
+ console.print("[red]Invalid target. Use @username or URL[/red]")
207
+ raise typer.Exit(1)
208
+
209
+ # Validate --impersonate before any work. The check otherwise only runs when the
210
+ # first download builds the client, so a typo surfaced minutes later — or
211
+ # never, on a profile with no media.
212
+ if impersonate:
213
+ from threadstractormf._backend import known_impersonate_targets
214
+
215
+ if impersonate not in known_impersonate_targets():
216
+ err_console.print(
217
+ f"[red]unknown --impersonate target: {impersonate!r}[/red]\n"
218
+ f"Valid targets include: "
219
+ f"{', '.join(sorted(known_impersonate_targets())[:8])}"
220
+ )
221
+ raise typer.Exit(1)
222
+
223
+ # Load cookies before touching the network, and report a bad file, a missing
224
+ # browser or an unknown browser name as a plain message instead of a
225
+ # traceback: exporting cookies as JSON is the most common mistake, and the
226
+ # LoadError text is what explains it.
227
+ cookies_arg: Any
228
+ try:
229
+ if cookies_from_browser:
230
+ from threadstractormf.auth import load_from_browser
231
+
232
+ cookies_arg = load_from_browser(cookies_from_browser)
233
+ elif cookies is not None:
234
+ if not cookies.exists():
235
+ err_console.print(f"[red]cookies file not found: {cookies}[/red]")
236
+ raise typer.Exit(1)
237
+ cookies_arg = load_netscape_cookies(cookies)
238
+ else:
239
+ err_console.print(
240
+ "[red]--cookies or --cookies-from-browser required "
241
+ "(gallery-dl compatible)[/red]"
242
+ )
243
+ raise typer.Exit(1)
244
+ except typer.Exit:
245
+ raise
246
+ except (
247
+ http.cookiejar.LoadError,
248
+ OSError,
249
+ ValueError,
250
+ # browser_cookie3.BrowserCookieError subclasses Exception directly, so it
251
+ # is not caught by the OSError/ValueError branches above; its message is
252
+ # the actionable one ("Failed to find cookies for Chrome browser").
253
+ _BrowserCookieError,
254
+ ) as exc:
255
+ err_console.print(f"[red]{exc}[/red]")
256
+ raise typer.Exit(1) from exc
257
+ except ImportError as exc:
258
+ err_console.print(f"[red]{exc}[/red]")
259
+ raise typer.Exit(1) from exc
260
+
261
+ _execute_scrape(
262
+ username=username,
263
+ cookies_arg=cookies_arg,
264
+ dest=dest,
265
+ limit=limit,
266
+ profile_pic_only=profile_pic_only,
267
+ photos_only=photos_only,
268
+ videos_only=videos_only,
269
+ impersonate=impersonate,
270
+ overwrite=overwrite,
271
+ no_rate_limit=no_rate_limit,
272
+ cooldown=cooldown,
273
+ batch_cooldown=batch_cooldown,
274
+ rps=rps,
275
+ filename_template=filename_template,
276
+ directory_template=directory_template,
277
+ archive=archive,
278
+ no_adopt_existing=no_adopt_existing,
279
+ get_urls=get_urls,
280
+ )
281
+
282
+
283
+ def _execute_scrape(
284
+ username: str,
285
+ cookies_arg: Any,
286
+ dest: Path,
287
+ limit: int | None,
288
+ profile_pic_only: bool,
289
+ photos_only: bool,
290
+ videos_only: bool,
291
+ impersonate: str | None,
292
+ overwrite: bool,
293
+ no_rate_limit: bool,
294
+ cooldown: int,
295
+ batch_cooldown: int,
296
+ rps: float,
297
+ filename_template: str | None,
298
+ directory_template: str | None,
299
+ archive: bool,
300
+ no_adopt_existing: bool,
301
+ get_urls: bool,
302
+ ) -> None:
303
+ from threadstractormf.client import Threadscraper
304
+
305
+ scraper = Threadscraper(
306
+ cookies=cookies_arg,
307
+ impersonate=impersonate,
308
+ rate_limit=not no_rate_limit,
309
+ cooldown_ms=cooldown,
310
+ cooldown_after_100_ms=batch_cooldown,
311
+ rps=rps,
312
+ archive=archive,
313
+ )
314
+ adopt_existing = not no_adopt_existing
315
+ # resolve directory_template -> final dest
316
+ final_dest = dest
317
+ if directory_template:
318
+ from threadstractormf.template import render_directory
319
+
320
+ # scrapmf passes scrapmf_root/profile; default used otherwise
321
+ subcat = "profile" if profile_pic_only else "posts"
322
+ rendered = render_directory(
323
+ directory_template, username=username, category="threads", subcategory=subcat
324
+ )
325
+ # si dest es ~/Descargas y template es {scrapmf_root}/threads/... lo unimos
326
+ # If template is already absolute, use it as-is
327
+ if rendered.startswith("/"):
328
+ final_dest = Path(rendered)
329
+ else:
330
+ # For All, rendered is "<user_root>" without '/', goes to dest/<user_root>
331
+ final_dest = dest / rendered if rendered else dest
332
+ # Defence in depth. render_directory already reduces {username} to a
333
+ # single component, but a relative template must not be able to walk out
334
+ # of --dest either way: an absolute template is a deliberate choice and
335
+ # is honoured, a relative one may not escape. Catching it here turns a
336
+ # silent write into an error, instead of files landing one directory up.
337
+ if not rendered.startswith("/"):
338
+ root_dir = dest.expanduser().resolve()
339
+ if not final_dest.resolve().is_relative_to(root_dir):
340
+ err_console.print(
341
+ f"[red]--directory-template resolves outside --dest: "
342
+ f"{final_dest.resolve()} (escapes {root_dir})[/red]"
343
+ )
344
+ raise typer.Exit(1)
345
+ if not get_urls:
346
+ # A dry run must leave the filesystem untouched, so it does not
347
+ # create the directory tree it would have written into.
348
+ final_dest.mkdir(parents=True, exist_ok=True)
349
+
350
+ try:
351
+ if profile_pic_only:
352
+ if get_urls:
353
+ # One URL per line, same shape as the per-file lines scrapmf
354
+ # counts, so a dry run reads the same way.
355
+ profile = scraper.get_profile(username)
356
+ if profile.profile_pic_url:
357
+ console.print(profile.profile_pic_url, soft_wrap=True)
358
+ return
359
+ out = scraper.download_profile_pic(
360
+ username, final_dest, filename_template=filename_template
361
+ )
362
+ console.print(f"[green]profile pic -> {out}[/green]", soft_wrap=True)
363
+ return
364
+ posts = scraper.get_posts(username, limit=limit)
365
+ report = scraper.last_report
366
+ # For All (no filter), also download profile pic after posts as sibling
367
+ is_all = not photos_only and not videos_only and not profile_pic_only
368
+ if is_all and not get_urls:
369
+ try:
370
+ outcome = scraper.download_profile_pic(
371
+ username, final_dest, filename_template=None
372
+ )
373
+ # Not counted through _count: the avatar is not one of the
374
+ # posts' media, so tallying it made downloaded+skipped exceed
375
+ # `media` and broke the identity the summary is read by.
376
+ # Reported on its own line instead.
377
+ report.profile_pic = outcome.path is not None
378
+ console.print(f" {username}_profile -> {outcome.path}", soft_wrap=True)
379
+ except Exception:
380
+ pass
381
+ for post in posts:
382
+ for media in post.media:
383
+ if photos_only and media.type != "image":
384
+ report.filtered += 1
385
+ continue
386
+ if videos_only and media.type != "video":
387
+ report.filtered += 1
388
+ continue
389
+ if get_urls:
390
+ # Dry run: print the URL and move on, leaving the pacing
391
+ # limiter alone (a scrape still must not hammer the API).
392
+ console.print(media.url, soft_wrap=True)
393
+ continue
394
+ # media.id is already post_id or post_id_N,
395
+ # with configurable template for chronological ordering
396
+ try:
397
+ outcome = scraper.download(
398
+ media,
399
+ final_dest,
400
+ overwrite=overwrite,
401
+ filename_template=filename_template,
402
+ username=username,
403
+ date_iso=post.datetime_iso,
404
+ adopt_existing=adopt_existing,
405
+ )
406
+ _count(outcome, report)
407
+ console.print(f" {media.id} -> {outcome.path}", soft_wrap=True)
408
+ except Exception as e:
409
+ report.failed += 1
410
+ # un media fallido (URL firmada expirada, 403 CDN, red)
411
+ # must not abort the whole batch. Failures go to STDERR
412
+ # (stdout is the "one line per downloaded file" contract
413
+ # with orchestrators like scrapmf — a failed line there
414
+ # would be miscounted as a successful download).
415
+ err_console.print(f"[yellow] {media.id} failed: {e}[/yellow]")
416
+ # En modo dry-run (--get-urls) no se descarga nada: imprimir el tally
417
+ # de descargas (downloaded=0/skipped=0) seria engañoso, asi que se omite.
418
+ if not get_urls:
419
+ _report_scrape(report)
420
+
421
+
422
+ finally:
423
+ scraper.close()
424
+
425
+ if __name__ == "__main__":
426
+ app()
@@ -0,0 +1,221 @@
1
+ """Main facade — import threadstractormf / Threadscraper.
2
+
3
+ Usage:
4
+ from threadstractormf import Threadscraper
5
+ scraper = Threadscraper(cookies="cookies.txt")
6
+ posts = scraper.get_posts("user", limit=50)
7
+ scraper.download(posts[0].media[0], dest="./dl")
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from threadstractormf.api import ScrapeReport, ThreadsAPI
18
+ from threadstractormf.archive import (
19
+ DownloadLedger,
20
+ ledger_path_for,
21
+ migrate_legacy_ledger,
22
+ )
23
+ from threadstractormf.auth import load_cookies_dict, load_netscape_cookies
24
+ from threadstractormf.downloader import (
25
+ DownloadOutcome,
26
+ download_media,
27
+ download_profile_pic,
28
+ )
29
+ from threadstractormf.models import Media, Post, Profile
30
+
31
+
32
+ def _coerce_cookies(cookies: str | Path | dict[str, str] | httpx.Cookies | Any) -> Any:
33
+ """Normalise every accepted ``cookies`` input into a CookieJar.
34
+
35
+ A plain dict was previously stored as-is: ``get_csrf_token`` still worked
36
+ through its fallback, but the Playwright path received a mapping where it
37
+ expected Cookie objects and produced an *empty* cookie list, so the DOM
38
+ scraper ran with no session at all. Normalising here means one shape for
39
+ every consumer.
40
+ """
41
+ if isinstance(cookies, dict):
42
+ return load_cookies_dict(cookies, domain=".threads.net")
43
+ return cookies
44
+
45
+
46
+ class Threadscraper:
47
+ def __init__(
48
+ self,
49
+ cookies: str | Path | httpx.Cookies | Any,
50
+ *,
51
+ impersonate: str | None = None,
52
+ timeout: float = 15.0,
53
+ rate_limit: bool = True,
54
+ cooldown_ms: int = 2000,
55
+ cooldown_after_100_ms: int = 120000,
56
+ rps: float = 0.5,
57
+ archive: bool = False,
58
+ ):
59
+ if isinstance(cookies, (str, Path)):
60
+ cookies = load_netscape_cookies(cookies)
61
+ cookies = _coerce_cookies(cookies)
62
+ self.cookies = cookies
63
+ # Kept so downloads use the same TLS fingerprint as the API calls:
64
+ # a WAF that blocks the GraphQL call also blocks the CDN fetches.
65
+ self.impersonate = impersonate
66
+ # Download ledger, keyed by (post_id, index) so a corrected extension or
67
+ # a different filename template never re-downloads an existing archive.
68
+ self.archive = archive
69
+ self._ledgers: dict[str, DownloadLedger] = {}
70
+ self._download_client: Any = None
71
+ from threadstractormf.rate_limit import BatchCooldownLimiter, RateLimitConfig
72
+
73
+ self.rate_limit = rate_limit
74
+ self.download_limiter = BatchCooldownLimiter(
75
+ cooldown_ms=cooldown_ms if rate_limit else 0,
76
+ batch_size=100,
77
+ batch_cooldown_ms=cooldown_after_100_ms if rate_limit else 0,
78
+ enabled=rate_limit,
79
+ )
80
+ cfg = RateLimitConfig(
81
+ cooldown_ms=cooldown_ms,
82
+ cooldown_after_100_ms=cooldown_after_100_ms,
83
+ rps=rps,
84
+ enabled=rate_limit,
85
+ )
86
+ self.api = ThreadsAPI(
87
+ cookies, impersonate=impersonate, timeout=timeout, rate_limit_config=cfg
88
+ )
89
+
90
+ # --- API delegada ---
91
+ def get_profile(self, username: str) -> Profile:
92
+ return self.api.get_profile(username)
93
+
94
+ def get_posts(
95
+ self, username: str, *, limit: int | None = None, exclude_reposts: bool = True
96
+ ) -> list[Post]:
97
+ return self.api.get_posts(username, limit=limit, exclude_reposts=exclude_reposts)
98
+
99
+ @property
100
+ def last_report(self) -> ScrapeReport:
101
+ """Counters for the most recent get_posts() call."""
102
+ return self.api.last_report
103
+
104
+ def _get_download_client(self) -> Any:
105
+ """Cliente de descarga reutilizable (lazy)."""
106
+ if self._download_client is None:
107
+ from threadstractormf.downloader import CDN_HEADERS, _build_download_client
108
+
109
+ self._download_client = _build_download_client(
110
+ impersonate=self.impersonate, headers=CDN_HEADERS
111
+ )
112
+ return self._download_client
113
+
114
+ # --- download ---
115
+ def download(
116
+ self,
117
+ media: Media,
118
+ dest: str | Path,
119
+ *,
120
+ overwrite: bool = False,
121
+ filename_template: str | None = None,
122
+ username: str | None = None,
123
+ date_iso: str | None = None,
124
+ adopt_existing: bool = True,
125
+ ) -> DownloadOutcome:
126
+ return download_media(
127
+ media,
128
+ dest,
129
+ client=self._get_download_client(),
130
+ overwrite=overwrite,
131
+ impersonate=self.impersonate,
132
+ limiter=self.download_limiter,
133
+ rate_limit=self.rate_limit,
134
+ filename_template=filename_template,
135
+ username=username,
136
+ date_iso=date_iso,
137
+ ledger=self._ledger_for(dest),
138
+ adopt_existing=adopt_existing,
139
+ )
140
+
141
+ def _ledger_for(self, dest: str | Path) -> DownloadLedger | None:
142
+ """Ledger covering ``dest``, or None when archiving is disabled.
143
+
144
+ One ledger per destination root — not per photos/ videos/ subdirectory —
145
+ created and loaded on first use. Archiving is opt-in, so without it no
146
+ ledger object is built and no directory is written.
147
+ """
148
+ if not self.archive:
149
+ return None
150
+ key = str(Path(dest).expanduser().resolve())
151
+ ledger = self._ledgers.get(key)
152
+ if ledger is None:
153
+ # Carry over a pre-1.1.1 ledger before reading, so renaming the
154
+ # bookkeeping directory does not re-download the whole library.
155
+ migrate_legacy_ledger(key)
156
+ ledger = DownloadLedger(ledger_path_for(key))
157
+ ledger.load()
158
+ self._ledgers[key] = ledger
159
+ return ledger
160
+
161
+ def download_many(
162
+ self,
163
+ medias: list[Media],
164
+ dest: str | Path,
165
+ *,
166
+ overwrite: bool = False,
167
+ filename_template: str | None = None,
168
+ username: str | None = None,
169
+ date_iso: str | None = None,
170
+ ) -> list[DownloadOutcome]:
171
+ """Download a list with built-in anti rate-limit protection.
172
+
173
+ ``date_iso`` is passed through to the template: without it a
174
+ ``{date:...}`` filename stamped every file with the current date.
175
+ """
176
+ out: list[DownloadOutcome] = []
177
+ for m in medias:
178
+ out.append(
179
+ self.download(
180
+ m,
181
+ dest,
182
+ overwrite=overwrite,
183
+ filename_template=filename_template,
184
+ username=username,
185
+ date_iso=date_iso,
186
+ )
187
+ )
188
+ return out
189
+
190
+ def download_profile_pic(
191
+ self, username: str, dest: str | Path, *, filename_template: str | None = None
192
+ ) -> DownloadOutcome:
193
+ profile = self.get_profile(username)
194
+ if not profile.profile_pic_url:
195
+ raise ValueError(f"No profile_pic_url for {username}")
196
+ return download_profile_pic(
197
+ profile.profile_pic_url,
198
+ username,
199
+ dest,
200
+ client=self._get_download_client(),
201
+ impersonate=self.impersonate,
202
+ limiter=self.download_limiter,
203
+ rate_limit=self.rate_limit,
204
+ filename_template=filename_template,
205
+ ledger=self._ledger_for(dest),
206
+ )
207
+
208
+ def close(self) -> None:
209
+ if self._download_client is not None:
210
+ try:
211
+ self._download_client.close()
212
+ except Exception:
213
+ pass
214
+ self._download_client = None
215
+ self.api.close()
216
+
217
+ def __enter__(self) -> Threadscraper:
218
+ return self
219
+
220
+ def __exit__(self, *_: object) -> None:
221
+ self.close()