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,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
|
threadstractormf/auth.py
ADDED
|
@@ -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
|
+
)
|