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,262 @@
1
+ """Download ledger — remember what has already been fetched, by post id.
2
+
3
+ Why this exists
4
+ ---------------
5
+ The only deduplication this tool used to have was ``if out.exists()``: the
6
+ *filename* was the key. That is fragile, because the filename is assembled from
7
+ three pieces and only one of them is stable::
8
+
9
+ {date}__{post_id}__{num}.{extension}
10
+ unstable STABLE unstable unstable
11
+
12
+ * ``{extension}`` is derived from the URL, and a wrong guess silently renamed
13
+ whole categories of media between releases (a ``.webm`` saved as ``.jpg``, a
14
+ JPEG poster saved as ``.mp4``). Every such fix re-downloaded the user's entire
15
+ history.
16
+ * ``{date}`` falls back to *today* when the post date is unknown, so the very
17
+ same post produced a different filename on the next day.
18
+
19
+ ``post_id`` and ``index`` come from the API and do not change while the post
20
+ exists, so they make a dependable key. The ledger records them and the
21
+ downloader consults it before touching the network.
22
+
23
+ Storage
24
+ -------
25
+ Append-only JSONL, one object per completed download::
26
+
27
+ {"post_id": "DPxyz", "index": 1, "path": "...", "t": 1756000000}
28
+
29
+ The shape mirrors scrapmf's own archive
30
+ (``~/.config/scrapmf/archive/<site>/<account>.jsonl``), so the two can be
31
+ unified later. The file lives inside the media directory
32
+ (``<dest>/.archive/dedup.jsonl``) so that moving the library moves the record
33
+ along with it.
34
+
35
+ The ledger is opt-in: pass ``--archive`` (or ``archive=True``) to enable it.
36
+ Without it nothing is written and no directory is created, so a plain download
37
+ leaves no bookkeeping behind. Ledgers written by 1.1.0 and earlier, under
38
+ ``.threadstractormf/``, are migrated on first use.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import os
45
+ import re
46
+ import time
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ # Hidden directory kept beside the media so it travels with an archive.
51
+ LEDGER_DIRNAME = ".archive"
52
+ LEDGER_FILENAME = "dedup.jsonl"
53
+
54
+ # The directory used up to 1.1.0. It is renamed, not deleted: an existing
55
+ # library must not re-download itself because the bookkeeping moved.
56
+ LEGACY_LEDGER_DIRNAME = ".threadstractormf"
57
+
58
+
59
+ def ledger_path_for(dest: str | Path) -> Path:
60
+ """Path of the ledger that covers ``dest``.
61
+
62
+ ``dest`` must be the *root* destination, not a ``photos/``/``videos/``
63
+ subdirectory, otherwise one account ends up with several ledgers.
64
+ """
65
+ return Path(dest).expanduser().resolve() / LEDGER_DIRNAME / LEDGER_FILENAME
66
+
67
+
68
+ def migrate_legacy_ledger(dest: str | Path) -> bool:
69
+ """Move a pre-1.1.1 ``.threadstractormf/dedup.jsonl`` to ``.archive/``.
70
+
71
+ Returns True when a ledger was migrated. Losing it would make the next run
72
+ treat every already-downloaded file as new, so the rename has to carry the
73
+ history over rather than quietly starting empty.
74
+
75
+ The legacy directory is removed only when it is empty afterwards, so
76
+ anything else a future version stored there survives.
77
+ """
78
+ root = Path(dest).expanduser().resolve()
79
+ legacy = root / LEGACY_LEDGER_DIRNAME / LEDGER_FILENAME
80
+ # Rechazar symlinks: un enlace plantado como dedup.jsonl podria redirigir
81
+ # el rename o la lectura fuera del directorio de destino.
82
+ if not legacy.is_file() or legacy.is_symlink():
83
+ return False
84
+ target = ledger_path_for(root)
85
+ if target.is_symlink():
86
+ return False
87
+ if target.exists():
88
+ # Both present: keep the new one and merge the old lines into it, so
89
+ # neither set of downloads is forgotten.
90
+ try:
91
+ existing = set(target.read_text(encoding="utf-8").splitlines())
92
+ extra = [
93
+ line
94
+ for line in legacy.read_text(encoding="utf-8").splitlines()
95
+ if line.strip() and line.strip() not in existing
96
+ ]
97
+ if extra:
98
+ with target.open("a", encoding="utf-8") as fh:
99
+ fh.write("\n".join(extra) + "\n")
100
+ except OSError:
101
+ return False
102
+ else:
103
+ try:
104
+ target.parent.mkdir(parents=True, exist_ok=True)
105
+ legacy.replace(target)
106
+ except OSError:
107
+ return False
108
+ try:
109
+ legacy.parent.rmdir()
110
+ except OSError:
111
+ pass # not empty: leave it alone rather than delete unknown files
112
+ return True
113
+
114
+
115
+ class DownloadLedger:
116
+ """Append-only set of ``(post_id, index)`` pairs already on disk.
117
+
118
+ Loading is eager but cheap: the file holds one short line per media item, so
119
+ even a few thousand posts parse in milliseconds. Writes are appended
120
+ immediately so an interrupted run still records what it finished.
121
+ """
122
+
123
+ def __init__(self, path: str | Path | None) -> None:
124
+ self.path = Path(path) if path is not None else None
125
+ self._keys: set[tuple[str, int]] = set()
126
+
127
+ # ── reading ─────────────────────────────────────────────────────────────
128
+
129
+ def load(self) -> None:
130
+ """Read the ledger into memory. Missing/corrupt lines are skipped."""
131
+ self._keys = set()
132
+ if self.path is None or not self.path.exists():
133
+ return
134
+ with self.path.open("r", encoding="utf-8") as fh:
135
+ for line in fh:
136
+ line = line.strip()
137
+ if not line:
138
+ continue
139
+ try:
140
+ entry = json.loads(line)
141
+ key = _entry_key(entry)
142
+ except Exception:
143
+ continue # a torn or hand-edited line must not break a scrape
144
+ if key is not None:
145
+ self._keys.add(key)
146
+
147
+ def has(self, post_id: str, index: int) -> bool:
148
+ return (post_id, index) in self._keys
149
+
150
+ def __len__(self) -> int:
151
+ return len(self._keys)
152
+
153
+ # ── writing ─────────────────────────────────────────────────────────────
154
+
155
+ def record(self, post_id: str, index: int, path: str | Path) -> None:
156
+ """Mark ``(post_id, index)`` as present on disk."""
157
+ if self.path is None:
158
+ return
159
+ key = (post_id, index)
160
+ if key in self._keys:
161
+ return
162
+ self._keys.add(key)
163
+ entry = {
164
+ "post_id": post_id,
165
+ "index": index,
166
+ "path": str(path),
167
+ "t": int(time.time()),
168
+ }
169
+ try:
170
+ self.path.parent.mkdir(parents=True, exist_ok=True)
171
+ with self.path.open("a", encoding="utf-8") as fh:
172
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
173
+ os.chmod(self.path, 0o600)
174
+ except OSError:
175
+ # An unwritable ledger must never abort a download: worst case the
176
+ # filename check keeps deduping as it always did.
177
+ pass
178
+
179
+
180
+ def _entry_key(entry: Any) -> tuple[str, int] | None:
181
+ if not isinstance(entry, dict):
182
+ return None
183
+ post_id = entry.get("post_id")
184
+ if not isinstance(post_id, str) or not post_id:
185
+ return None
186
+ index = entry.get("index", 1)
187
+ if not isinstance(index, int):
188
+ return None
189
+ return (post_id, index)
190
+
191
+
192
+ # ── legacy-file adoption ───────────────────────────────────────────────────
193
+ #
194
+ # When the extension (or any other part of the name) starts being computed
195
+ # differently, files already on disk keep the *old* name. The ledger starts
196
+ # empty at that point, so the plain existence check misses them. Matching on
197
+ # the post id recovers them instead of downloading everything again.
198
+
199
+
200
+ _DIR_LISTING_CACHE: dict[str, tuple[float, list[Path]]] = {}
201
+
202
+
203
+ def _cached_dir_listing(directory: Path) -> list[Path]:
204
+ try:
205
+ key = str(directory.resolve())
206
+ mtime = directory.stat().st_mtime
207
+ except OSError:
208
+ return []
209
+ cached = _DIR_LISTING_CACHE.get(key)
210
+ if cached is not None and cached[0] == mtime:
211
+ return cached[1]
212
+ try:
213
+ entries = [p for p in directory.iterdir() if p.is_file()]
214
+ except OSError:
215
+ return []
216
+ _DIR_LISTING_CACHE[key] = (mtime, entries)
217
+ return entries
218
+
219
+
220
+ def find_existing_for_post(dest: str | Path, post_id: str, index: int = 1) -> Path | None:
221
+ """Find an already-downloaded file for item ``index`` of ``post_id``.
222
+
223
+ Two things must line up, or carousel items overwrite each other's history:
224
+
225
+ * a delimiter after the id — end of name, ``.`` or ``_`` — so ``DPxyz123``
226
+ never adopts ``DPxyz1234``, which is a different post;
227
+ * the item number that follows, when the filename carries one. Every item of
228
+ a carousel shares the same ``post_id``; only ``{num}`` distinguishes them.
229
+ Without this check item 2 of a carousel adopts item 1's file and is never
230
+ downloaded at all.
231
+
232
+ A file with no number can only belong to item 1: that is a post whose media
233
+ is not part of a carousel, or the ``{post_id}.{ext}`` template.
234
+ """
235
+ directory = Path(dest)
236
+ if not directory.is_dir():
237
+ return None
238
+
239
+ numbered: Path | None = None
240
+ plain: Path | None = None
241
+ for candidate in sorted(_cached_dir_listing(directory)):
242
+ name = candidate.name
243
+ position = name.find(post_id)
244
+ if position == -1:
245
+ continue
246
+ # El post_id debe ser un identificador completo, no una subcadena de
247
+ # otro nombre (p. ej. "XP1.jpg" no debe adoptarse para post_id "P1").
248
+ if position > 0 and name[position - 1].isalnum():
249
+ continue
250
+ tail = name[position + len(post_id) :]
251
+ if tail[:1] not in ("", ".", "_"):
252
+ continue
253
+ match = re.match(r"^_(\d+)", tail)
254
+ if match:
255
+ if int(match.group(1)) == index and numbered is None:
256
+ numbered = candidate
257
+ elif index == 1 and plain is None:
258
+ plain = candidate
259
+
260
+ # An explicitly numbered file is what the current naming scheme produces, so
261
+ # it wins over a bare "{post_id}.{ext}" that also matches item 1.
262
+ return numbered or plain
@@ -0,0 +1,319 @@
1
+ """Auth via Netscape cookies.txt — 100% compatible with gallery-dl / yt-dlp.
2
+
3
+ gallery-dl and yt-dlp load cookies with http.cookiejar.MozillaCookieJar
4
+ which requires:
5
+ - Line 1: "# Netscape HTTP Cookie File" or "# HTTP Cookie File"
6
+ - 7 fields separated by TAB: domain\\tTRUE/FALSE\\tpath\\tTRUE/FALSE\\texpires\\tname\\tvalue
7
+ - Domain with "." prefix (e.g. .threads.net) = sent to subdomains (TRUE)
8
+ - Expiry 0 = session cookie
9
+
10
+ This module replicates that behavior so the same cookies.txt works
11
+ in threadstractormf, gallery-dl and yt-dlp without conversion.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import http.cookiejar
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+
24
+ def _parse_netscape_lines(path: Path) -> list[str]:
25
+ """Normalise a Netscape cookie file to TAB-separated lines, **in memory**.
26
+
27
+ Tolerates the things real exports contain:
28
+ - a ``scrapmf-source`` line above the MAGIC header
29
+ - spaces instead of TABs (many generators emit spaces)
30
+ - ``#HttpOnly_`` prefixes, which are stripped here and re-applied by
31
+ :func:`load_netscape_cookies`
32
+ - empty and comment lines, and malformed rows
33
+
34
+ Returns the normalised lines; nothing is written to disk. This used to build
35
+ a temp file purely because ``MozillaCookieJar.load()`` demands a filename,
36
+ which put live session cookies on disk as ``0o644``.
37
+ """
38
+ text = path.read_text(encoding="utf-8", errors="ignore")
39
+ lines = text.splitlines()
40
+ # Find MAGIC header
41
+ magic_idx = -1
42
+ for i, line in enumerate(lines):
43
+ if "HTTP Cookie File" in line:
44
+ magic_idx = i
45
+ break
46
+ if magic_idx == -1:
47
+ raise http.cookiejar.LoadError(f"{path} does not look like a Netscape format cookies file")
48
+ # Rebuild from MAGIC onwards. The header itself is NOT returned: it carries
49
+ # no cookie, only MozillaCookieJar needed it to validate the file, and
50
+ # ensure_netscape_header() already does that.
51
+ out_lines: list[str] = []
52
+ for raw_line in lines[magic_idx + 1 :]:
53
+ stripped = raw_line.strip()
54
+ if not stripped:
55
+ continue
56
+ if stripped.startswith("#") and "HTTP Cookie File" in stripped:
57
+ continue
58
+ if stripped.startswith("#") and not stripped.startswith("#HttpOnly_"):
59
+ # regular comment: keep pure comments?
60
+ # gallery-dl ignores them, we skip them
61
+ if stripped.startswith("# This"):
62
+ continue
63
+ # other comments: ignore
64
+ if stripped.startswith("#"):
65
+ # if it is #HttpOnly_, processed below
66
+ if not stripped.startswith("#HttpOnly_"):
67
+ continue
68
+ # Keep the #HttpOnly_ prefix: it is a real cookie attribute and the
69
+ # consumer re-applies it as rest={"HttpOnly": ""}.
70
+ httponly = raw_line.startswith("#HttpOnly_")
71
+ line = raw_line[len("#HttpOnly_") :] if httponly else raw_line
72
+ # If line still starts with #, it is a comment -> skip
73
+ if line.strip().startswith("#"):
74
+ continue
75
+ # Split by TAB or by 2+ spaces
76
+ # MozillaCookieJar requires TABs, so we convert
77
+ # First split by TAB if tabs are present
78
+ if "\t" in line:
79
+ parts = line.split("\t")
80
+ else:
81
+ # split by whitespace 2+ or \s+ but preserving values with spaces?
82
+ # value may contain spaces / JSON -> use maxsplit=6
83
+ import re
84
+
85
+ parts = re.split(r"\s+", line.strip(), maxsplit=6)
86
+ if len(parts) != 7:
87
+ # malformed line, skip silently (gallery-dl does the same)
88
+ continue
89
+ domain, flag, cpath, secure, expires, name, value = parts
90
+ # Rebuild with TABs, re-attaching the prefix so the HttpOnly flag is
91
+ # not lost.
92
+ rebuilt = "\t".join([domain, flag, cpath, secure, expires, name, value])
93
+ out_lines.append(f"#HttpOnly_{rebuilt}" if httponly else rebuilt)
94
+ return out_lines
95
+
96
+
97
+ def load_netscape_cookies(
98
+ path: str | Path, *, ignore_expires: bool = True, ignore_discard: bool = True
99
+ ) -> http.cookiejar.CookieJar:
100
+ """Load a Netscape cookies.txt and return a CookieJar ready for httpx.
101
+
102
+ Returns a CookieJar (compatible with ``httpx.Client(cookies=jar)``).
103
+ Tolerates scrapmf-source lines, spaces vs TABs, ``#HttpOnly_``, empty lines,
104
+ and rows whose include-subdomains flag disagrees with the domain's leading
105
+ dot. A CookieJar rather than ``httpx.Cookies`` avoids CookieConflict when the
106
+ same name exists on different domains (e.g. sessionid on tiktok +
107
+ instagram).
108
+
109
+ The file is parsed in memory. It used to be rewritten into a
110
+ ``tempfile.mktemp()`` file because ``MozillaCookieJar.load()`` requires a
111
+ filename, which left live session cookies on disk as world-readable
112
+ ``0o644`` and exposed a symlink race before the write.
113
+ """
114
+ p = Path(path).expanduser().resolve()
115
+ if not p.exists():
116
+ raise FileNotFoundError(f"cookies file not found: {p}")
117
+
118
+ # Surface the actionable message first: exporting cookies as JSON is by far
119
+ # the most common mistake, and the bare LoadError does not hint at it.
120
+ ensure_netscape_header(p)
121
+
122
+ jar = http.cookiejar.CookieJar()
123
+ now = time.time()
124
+ for raw in _parse_netscape_lines(p):
125
+ httponly = raw.startswith("#HttpOnly_")
126
+ if httponly:
127
+ raw = raw[len("#HttpOnly_") :]
128
+ domain, flag, cpath, secure, expires, name, value = raw.split("\t")
129
+
130
+ initial_dot = domain.startswith(".")
131
+ # MozillaCookieJar asserts domain_specified == initial_dot, so a file
132
+ # whose flag disagrees with its own domain aborted the entire load with
133
+ # an AssertionError. Derive the flag from the domain instead, which is
134
+ # what the Netscape format actually means.
135
+ domain_specified = flag == "TRUE" or initial_dot
136
+
137
+ # expires == "" and expires == "0" both mean "session cookie". Reading
138
+ # "0" as a timestamp would date it to 1970 and drop a live cookie.
139
+ try:
140
+ expires_at = int(expires) if expires not in ("", "0") else None
141
+ except ValueError:
142
+ expires_at = None
143
+ discard = expires_at is None
144
+ if ignore_discard:
145
+ discard = False
146
+ if not ignore_expires and expires_at is not None and expires_at < now:
147
+ continue
148
+
149
+ jar.set_cookie(
150
+ http.cookiejar.Cookie(
151
+ version=0,
152
+ name=name,
153
+ value=value,
154
+ port=None,
155
+ port_specified=False,
156
+ domain=domain,
157
+ domain_specified=domain_specified,
158
+ domain_initial_dot=initial_dot,
159
+ path=cpath,
160
+ # MozillaCookieJar assumes path_specified is false.
161
+ path_specified=False,
162
+ secure=secure == "TRUE",
163
+ expires=expires_at,
164
+ discard=discard,
165
+ comment=None,
166
+ comment_url=None,
167
+ rest={"HttpOnly": ""} if httponly else {},
168
+ )
169
+ )
170
+ return jar
171
+
172
+
173
+ def load_cookies_dict(
174
+ cookies: dict[str, str], *, domain: str = ".threads.net"
175
+ ) -> http.cookiejar.CookieJar:
176
+ """Build a CookieJar from a plain ``{name: value}`` mapping.
177
+
178
+ Used by ``Threadscraper`` so every accepted ``cookies`` input ends up in the
179
+ same shape, which the Playwright cookie converter requires.
180
+ """
181
+ jar = http.cookiejar.CookieJar()
182
+ for k, v in cookies.items():
183
+ c = http.cookiejar.Cookie(
184
+ version=0,
185
+ name=k,
186
+ value=v,
187
+ port=None,
188
+ port_specified=False,
189
+ domain=domain,
190
+ domain_specified=True,
191
+ domain_initial_dot=domain.startswith("."),
192
+ path="/",
193
+ path_specified=True,
194
+ secure=True,
195
+ expires=None,
196
+ discard=False,
197
+ comment=None,
198
+ comment_url=None,
199
+ rest={},
200
+ )
201
+ jar.set_cookie(c)
202
+ return jar
203
+
204
+
205
+ def get_csrf_token(cookies: http.cookiejar.CookieJar | httpx.Cookies) -> str | None:
206
+ """Extract csrftoken from cookies (required for x-csrftoken header in Threads GraphQL)."""
207
+ if isinstance(cookies, httpx.Cookies):
208
+ return cookies.get("csrftoken")
209
+ for c in cookies:
210
+ if getattr(c, "name", None) == "csrftoken":
211
+ value = getattr(c, "value", None)
212
+ return str(value) if value is not None else None
213
+ return None
214
+ def _load_brave_profile(profile_dir: str, domain: str, browser_cookie3: Any) -> Any:
215
+ """Read cookies from a specific Brave profile (Origin, Beta, Nightly).
216
+
217
+ ``browser_cookie3.brave()`` only resolves the default ``Brave-Browser``
218
+ profile, so a live session kept in ``Brave-Origin`` is invisible to it. The
219
+ ``Brave`` class takes explicit paths, which is what this uses; the
220
+ ``Local State`` file next to it holds the encryption key.
221
+ """
222
+ import browser_cookie3 as _bc3 # noqa: F401 (resolved by caller)
223
+
224
+ root = Path.home() / ".config" / "BraveSoftware" / profile_dir
225
+ cookie_file = root / "Default" / "Cookies"
226
+ key_file = root / "Local State"
227
+ if not cookie_file.exists():
228
+ raise FileNotFoundError(
229
+ f"no Brave profile at {cookie_file}. "
230
+ f"Is '{profile_dir}' installed, or is this a non-Linux layout?"
231
+ )
232
+ return browser_cookie3.Brave(
233
+ str(cookie_file), domain_name=domain, key_file=str(key_file)
234
+ ).load()
235
+
236
+
237
+ def _brave_variant(profile_dir: str, browser_cookie3: Any) -> Any:
238
+ """Build a loader bound to one Brave profile directory."""
239
+ return lambda domain: _load_brave_profile(profile_dir, domain, browser_cookie3)
240
+
241
+
242
+ def load_from_browser(browser: str, *, domain: str = "threads.com") -> http.cookiejar.CookieJar:
243
+ """Load cookies directly from the browser (brave/chrome/firefox)
244
+ like gallery-dl --cookies-from-browser.
245
+
246
+ Requires: pip install browser-cookie3
247
+ On Linux copies the DB to /tmp to avoid database locked while the browser is open.
248
+ Note: browser-cookie3 may leave a temporary copy of the cookie DB in /tmp;
249
+ ensure /tmp is cleared on reboot or use a private temp dir.
250
+ """
251
+ try:
252
+ import browser_cookie3 # type: ignore
253
+ except ImportError as e:
254
+ raise ImportError(
255
+ "browser-cookie3 not installed. "
256
+ "pip install 'threadstractormf[browser]' or 'browser-cookie3'"
257
+ ) from e
258
+
259
+ b = browser.lower()
260
+
261
+ # Brave ships several independent profiles under the same config root, and
262
+ # browser_cookie3 only has a shortcut for the default one:
263
+ #
264
+ # browser_cookie3.brave() -> BraveSoftware/Brave-Browser (the *stale* one)
265
+ #
266
+ # A user whose live session lives in Brave-Origin therefore gets an old jar
267
+ # without any sign of it. The class accepts explicit paths, so each variant
268
+ # is pointed at its own profile directory.
269
+ loaders = {
270
+ "brave": lambda d: browser_cookie3.brave(domain_name=d),
271
+ "brave-browser": lambda d: browser_cookie3.brave(domain_name=d),
272
+ "brave-origin": _brave_variant("Brave-Origin", browser_cookie3),
273
+ "brave-nightly": _brave_variant("Brave-Browser-Nightly", browser_cookie3),
274
+ "brave-beta": _brave_variant("Brave-Beta", browser_cookie3),
275
+ "chrome": lambda d: browser_cookie3.chrome(domain_name=d),
276
+ "chromium": lambda d: browser_cookie3.chromium(domain_name=d),
277
+ "firefox": lambda d: browser_cookie3.firefox(domain_name=d),
278
+ "edge": lambda d: browser_cookie3.edge(domain_name=d),
279
+ "opera": lambda d: browser_cookie3.opera(domain_name=d),
280
+ "vivaldi": lambda d: browser_cookie3.vivaldi(domain_name=d),
281
+ }
282
+ loader = loaders.get(b)
283
+ if loader is None:
284
+ raise ValueError(
285
+ f"unsupported browser: {browser} (use {'|'.join(sorted(loaders))})"
286
+ )
287
+ jar: http.cookiejar.CookieJar = loader(domain)
288
+
289
+ # browser_cookie3 filters by the requested domain only, but Threads keeps
290
+ # the session split between threads.com and threads.net. Merge both for
291
+ # every browser — previously only Brave got the threads.net half, silently
292
+ # leaving Chrome/Firefox/Edge with an incomplete session.
293
+ if domain == "threads.com":
294
+ try:
295
+ for c in loader("threads.net"):
296
+ jar.set_cookie(c)
297
+ except Exception:
298
+ pass
299
+ return jar
300
+
301
+
302
+ def ensure_netscape_header(path: str | Path) -> None:
303
+ """Validate that the file carries the Netscape MAGIC header.
304
+
305
+ The header is searched for anywhere in the file, not just on the first line,
306
+ matching :func:`_parse_netscape_lines`: some exporters (scrapmf among them)
307
+ prepend a source comment before the real header. Checking only line 1 would
308
+ reject files the parser is happy to read.
309
+ """
310
+ p = Path(path)
311
+ if not p.exists():
312
+ return
313
+ lines = p.read_text(encoding="utf-8", errors="ignore").splitlines()
314
+ if not any("HTTP Cookie File" in line for line in lines):
315
+ raise http.cookiejar.LoadError(
316
+ f"{p} does not look like a Netscape format cookies file. "
317
+ "Export with 'Get cookies.txt LOCALLY' (Netscape), not JSON. "
318
+ "The file must contain a line '# Netscape HTTP Cookie File'."
319
+ )