tikstractormf 1.0.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.
- tikstractormf/__init__.py +7 -0
- tikstractormf/_backend.py +283 -0
- tikstractormf/_parsing.py +267 -0
- tikstractormf/api.py +952 -0
- tikstractormf/archive.py +262 -0
- tikstractormf/auth.py +325 -0
- tikstractormf/cli.py +508 -0
- tikstractormf/client.py +288 -0
- tikstractormf/downloader.py +566 -0
- tikstractormf/models.py +124 -0
- tikstractormf/rate_limit.py +201 -0
- tikstractormf/template.py +123 -0
- tikstractormf-1.0.0.dist-info/METADATA +163 -0
- tikstractormf-1.0.0.dist-info/RECORD +17 -0
- tikstractormf-1.0.0.dist-info/WHEEL +4 -0
- tikstractormf-1.0.0.dist-info/entry_points.txt +2 -0
- tikstractormf-1.0.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""tikstractormf — TikTok media downloader (gallery-dl cookies + post_id naming)."""
|
|
2
|
+
|
|
3
|
+
from tikstractormf.client import Tiktokscraper
|
|
4
|
+
from tikstractormf.models import Media, Post, Profile
|
|
5
|
+
|
|
6
|
+
__all__ = ["Tiktokscraper", "Post", "Media", "Profile"]
|
|
7
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Shared helpers for talking to Meta's servers through two HTTP backends.
|
|
2
|
+
|
|
3
|
+
Two independent backends are supported:
|
|
4
|
+
|
|
5
|
+
* **httpx** — the default. No browser TLS impersonation.
|
|
6
|
+
* **curl_cffi** — opted into with ``--impersonate chrome``. Meta's WAF inspects
|
|
7
|
+
the JA3/JA4 TLS fingerprint and the HTTP/2 SETTINGS frame, which httpx cannot
|
|
8
|
+
fake; curl_cffi impersonates a real Chrome byte-for-byte, the same mechanism
|
|
9
|
+
``yt-dlp --impersonate`` uses.
|
|
10
|
+
|
|
11
|
+
Everything else (rate limiting, retries, streaming, the GraphQL client and the
|
|
12
|
+
media downloader) is written against the tiny surface both libraries share, so
|
|
13
|
+
the httpx path stays byte-identical and curl_cffi is just an opt-in branch.
|
|
14
|
+
|
|
15
|
+
Also holds the cookie converter used by the three Playwright call sites, which
|
|
16
|
+
used to duplicate it three times (and all three dropped the leading dot, which
|
|
17
|
+
made every auth cookie host-only).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import socket
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
28
|
+
# curl_cffi is an optional extra; the alias is only needed for typing.
|
|
29
|
+
from curl_cffi.requests.session import HttpMethod
|
|
30
|
+
else:
|
|
31
|
+
HttpMethod = str
|
|
32
|
+
|
|
33
|
+
# ─── Retry classification ────────────────────────────────────────────────────
|
|
34
|
+
#
|
|
35
|
+
# Shared by the GraphQL client and the media downloader. Both exception
|
|
36
|
+
# families must be understood here, because curl_cffi replaces httpx wholesale
|
|
37
|
+
# when --impersonate is active.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_retryable_status(status: int) -> bool:
|
|
41
|
+
"""True for statuses worth retrying: 429 (rate limited) and any 5xx.
|
|
42
|
+
|
|
43
|
+
Every other 4xx is permanent and must surface immediately. A 403 from
|
|
44
|
+
Meta's WAF will not fix itself, so retrying it only burns the full
|
|
45
|
+
exponential backoff (~60s) before reporting the very same error.
|
|
46
|
+
"""
|
|
47
|
+
return status == 429 or status >= 500
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _curl_exc() -> Any:
|
|
51
|
+
"""curl_cffi's exception module, or None when the extra is not installed."""
|
|
52
|
+
try:
|
|
53
|
+
from curl_cffi.requests import exceptions as curl_exceptions
|
|
54
|
+
except ImportError:
|
|
55
|
+
return None
|
|
56
|
+
return curl_exceptions
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_transient_error(exc: BaseException) -> bool:
|
|
60
|
+
"""True when a failure is worth retrying (flaky network, DNS, CDN hiccup).
|
|
61
|
+
|
|
62
|
+
Handles both backends. Order matters: curl_cffi's ``RequestException``
|
|
63
|
+
subclasses ``OSError``, so the curl branch must be tested *before* the
|
|
64
|
+
generic ``OSError`` branch or every curl error would be misclassified as a
|
|
65
|
+
plain OSError with no errno and never retried.
|
|
66
|
+
"""
|
|
67
|
+
curl = _curl_exc()
|
|
68
|
+
if curl is not None:
|
|
69
|
+
# Raised by Response.raise_for_status(): retry only if the status allows.
|
|
70
|
+
if isinstance(exc, curl.HTTPError):
|
|
71
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
72
|
+
return status is not None and is_retryable_status(int(status))
|
|
73
|
+
# ConnectTimeout, ReadTimeout, ConnectionError (incl. DNSError, SSLError).
|
|
74
|
+
if isinstance(exc, (curl.Timeout, curl.ConnectionError)):
|
|
75
|
+
return True
|
|
76
|
+
# Any other curl RequestException (InvalidURL, TooManyRedirects, ...)
|
|
77
|
+
# is a permanent programming/URL problem.
|
|
78
|
+
if isinstance(exc, curl.RequestException):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
82
|
+
return is_retryable_status(exc.response.status_code)
|
|
83
|
+
if isinstance(exc, httpx.TransportError):
|
|
84
|
+
# ConnectError (wraps gaierror/[Errno -3]), ConnectTimeout, ReadTimeout,
|
|
85
|
+
# RemoteProtocolError, PoolTimeout, ...
|
|
86
|
+
return True
|
|
87
|
+
if isinstance(exc, httpx.HTTPError):
|
|
88
|
+
# InvalidURL, DecodingError, TooManyRedirects: permanent.
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
if isinstance(exc, OSError):
|
|
92
|
+
# EAI_AGAIN lives in `socket`, not `errno`; referencing errno.EAI_AGAIN
|
|
93
|
+
# raised AttributeError on *any* OSError reaching here, including the
|
|
94
|
+
# very DNS flap this branch exists to detect.
|
|
95
|
+
return exc.errno in (-3, socket.EAI_AGAIN)
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ─── HTTP backend ────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StreamClient(Protocol):
|
|
103
|
+
"""The minimum surface the media downloader needs.
|
|
104
|
+
|
|
105
|
+
Both ``httpx.Client`` and :class:`CurlClient` satisfy it structurally,
|
|
106
|
+
which is what lets ``download_media`` accept either backend. The signature
|
|
107
|
+
lists only the argument the downloader actually passes, because a protocol
|
|
108
|
+
declaring ``**kwargs`` would require the implementation to accept arbitrary
|
|
109
|
+
keywords — which ``httpx.Client.stream`` does not.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def stream(
|
|
113
|
+
self, method: str, url: str, *, headers: dict[str, str] | None = None
|
|
114
|
+
) -> Any: ...
|
|
115
|
+
|
|
116
|
+
def close(self) -> None: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _CurlStreamResponse:
|
|
120
|
+
"""Presents a curl_cffi streamed response through the httpx surface.
|
|
121
|
+
|
|
122
|
+
Only one method actually differs: curl_cffi names it ``iter_content``
|
|
123
|
+
while httpx calls it ``iter_bytes``. Everything else (``status_code``,
|
|
124
|
+
``raise_for_status``) already matches.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
__slots__ = ("_resp",)
|
|
128
|
+
|
|
129
|
+
def __init__(self, resp: Any) -> None:
|
|
130
|
+
self._resp = resp
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def status_code(self) -> int:
|
|
134
|
+
return int(self._resp.status_code)
|
|
135
|
+
|
|
136
|
+
def raise_for_status(self) -> None:
|
|
137
|
+
self._resp.raise_for_status()
|
|
138
|
+
|
|
139
|
+
def iter_bytes(self, chunk_size: int | None = None) -> Any:
|
|
140
|
+
# chunk_size is intentionally dropped: curl pulls what it wants off the
|
|
141
|
+
# socket and warns loudly if we pretend otherwise.
|
|
142
|
+
return self._resp.iter_content()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def known_impersonate_targets() -> set[str]:
|
|
146
|
+
"""Every impersonation target curl_cffi understands (generic + versioned).
|
|
147
|
+
|
|
148
|
+
curl_cffi does **not** validate ``impersonate`` at construction time — it
|
|
149
|
+
accepts any string and only misbehaves later, at request time. Validating up
|
|
150
|
+
front turns a confusing runtime failure into an actionable error.
|
|
151
|
+
"""
|
|
152
|
+
from typing import get_args
|
|
153
|
+
|
|
154
|
+
from curl_cffi.requests.impersonate import REAL_TARGET_MAP, BrowserTypeLiteral
|
|
155
|
+
|
|
156
|
+
return set(REAL_TARGET_MAP) | set(get_args(BrowserTypeLiteral))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CurlClient:
|
|
160
|
+
"""``httpx``-shaped wrapper around a curl_cffi Session.
|
|
161
|
+
|
|
162
|
+
Only instantiated when ``impersonate`` is requested. Three differences from
|
|
163
|
+
``httpx.Client`` are papered over here:
|
|
164
|
+
|
|
165
|
+
* cookies are *assigned* (``session.cookies = jar``), not passed as a kwarg
|
|
166
|
+
* ``timeout`` is ``(connect, total)`` — verified empirically: the second
|
|
167
|
+
element is a whole-transfer budget, **not** a per-phase timeout, so it has
|
|
168
|
+
to be generous enough for large media or slow CDN links get truncated
|
|
169
|
+
mid-download (the same failure the httpx path fixed via a per-chunk read)
|
|
170
|
+
* HTTP/2 must be requested explicitly
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
impersonate: str,
|
|
177
|
+
cookies: Any = None,
|
|
178
|
+
headers: dict[str, str] | None = None,
|
|
179
|
+
connect_timeout: float = 15.0,
|
|
180
|
+
total_timeout: float = 600.0,
|
|
181
|
+
http2: bool = True,
|
|
182
|
+
) -> None:
|
|
183
|
+
try:
|
|
184
|
+
from curl_cffi import requests as curl_requests
|
|
185
|
+
except ImportError as exc: # pragma: no cover - depends on env
|
|
186
|
+
raise ImportError(
|
|
187
|
+
"--impersonate requires curl_cffi. "
|
|
188
|
+
"Install it with: pip install 'tikstractormf[antibot]'"
|
|
189
|
+
) from exc
|
|
190
|
+
|
|
191
|
+
self.impersonate = impersonate
|
|
192
|
+
if impersonate not in known_impersonate_targets():
|
|
193
|
+
examples = ", ".join(sorted(known_impersonate_targets())[:6])
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"unknown impersonation target: {impersonate!r}. "
|
|
196
|
+
f"Valid targets include: {examples} (and versioned forms like "
|
|
197
|
+
"'chrome131' or 'safari18_0')"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
kwargs: dict[str, Any] = {"timeout": (connect_timeout, total_timeout)}
|
|
201
|
+
if http2:
|
|
202
|
+
from curl_cffi.const import CurlHttpVersion
|
|
203
|
+
|
|
204
|
+
kwargs["http_version"] = CurlHttpVersion.V2_0
|
|
205
|
+
self._session = curl_requests.Session(impersonate=impersonate, **kwargs)
|
|
206
|
+
|
|
207
|
+
# A CookieJar instance is converted by curl_cffi on assignment.
|
|
208
|
+
if cookies is not None:
|
|
209
|
+
self._session.cookies = cookies
|
|
210
|
+
if headers:
|
|
211
|
+
self._session.headers.update(headers)
|
|
212
|
+
|
|
213
|
+
def request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
214
|
+
kwargs.setdefault("allow_redirects", True)
|
|
215
|
+
return self._session.request(cast("HttpMethod", method), url, **kwargs)
|
|
216
|
+
|
|
217
|
+
def stream(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
218
|
+
from contextlib import contextmanager
|
|
219
|
+
|
|
220
|
+
verb = cast("HttpMethod", method)
|
|
221
|
+
|
|
222
|
+
@contextmanager
|
|
223
|
+
def _ctx() -> Any:
|
|
224
|
+
with self._session.stream(verb, url, **kwargs) as resp:
|
|
225
|
+
yield _CurlStreamResponse(resp)
|
|
226
|
+
|
|
227
|
+
return _ctx()
|
|
228
|
+
|
|
229
|
+
def close(self) -> None:
|
|
230
|
+
self._session.close()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ─── Playwright ──────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def chromium_launch_args() -> list[str]:
|
|
237
|
+
"""Args for ``chromium.launch``.
|
|
238
|
+
|
|
239
|
+
``--no-sandbox`` is required in many CI/container environments but reduces
|
|
240
|
+
isolation. It can be disabled by setting ``TIKTOK_USE_SANDBOX=1`` when the
|
|
241
|
+
environment supports an unprivileged sandbox.
|
|
242
|
+
"""
|
|
243
|
+
import os
|
|
244
|
+
|
|
245
|
+
args = ["--disable-blink-features=AutomationControlled"]
|
|
246
|
+
if os.environ.get("TIKTOK_USE_SANDBOX") != "1":
|
|
247
|
+
args.insert(0, "--no-sandbox")
|
|
248
|
+
return args
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def to_playwright_cookies(jar: Any) -> list[dict[str, Any]]:
|
|
252
|
+
"""Convert a CookieJar into the dict shape ``BrowserContext.add_cookies`` wants.
|
|
253
|
+
|
|
254
|
+
The leading dot on the domain **must be preserved**. Chromium treats a
|
|
255
|
+
domain without one as host-only, so ``threads.net`` would never be sent to
|
|
256
|
+
``www.threads.net`` — i.e. every auth cookie silently dropped and the
|
|
257
|
+
browser left logged out. Verified: with ``.threads.net`` the cookie arrives,
|
|
258
|
+
with ``threads.net`` no ``Cookie`` header is sent at all.
|
|
259
|
+
|
|
260
|
+
Shared by ``TiktokAPI`` (profile pic + post scraping) and ``scripts/sniff.py``
|
|
261
|
+
so the three former copies of this conversion cannot drift apart again.
|
|
262
|
+
"""
|
|
263
|
+
out: list[dict[str, Any]] = []
|
|
264
|
+
for cookie in jar or ():
|
|
265
|
+
try:
|
|
266
|
+
domain = getattr(cookie, "domain", "") or ""
|
|
267
|
+
if not domain:
|
|
268
|
+
# Host-only cookies have no domain and cannot be added without a
|
|
269
|
+
# URL; skip rather than let add_cookies() fail on the whole batch.
|
|
270
|
+
continue
|
|
271
|
+
out.append(
|
|
272
|
+
{
|
|
273
|
+
"name": cookie.name,
|
|
274
|
+
"value": cookie.value,
|
|
275
|
+
"domain": domain,
|
|
276
|
+
"path": getattr(cookie, "path", "/") or "/",
|
|
277
|
+
"secure": bool(getattr(cookie, "secure", True)),
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
except Exception:
|
|
281
|
+
continue
|
|
282
|
+
return out
|
|
283
|
+
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Parsing helpers for TikTok API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from tikstractormf.models import Media, Post
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _guess_ext(url: str, format_hint: str | None = None) -> str:
|
|
14
|
+
# format_hint comes from the JSON (video.format, mime_type, etc.)
|
|
15
|
+
if format_hint:
|
|
16
|
+
fh = format_hint.lower().strip(".")
|
|
17
|
+
if fh in {"jpg", "jpeg", "png", "webp", "gif", "avif", "heic", "bmp",
|
|
18
|
+
"mp4", "webm", "mov", "m4v"}:
|
|
19
|
+
return "jpeg" if fh == "jpeg" else fh
|
|
20
|
+
if "mp4" in fh:
|
|
21
|
+
return "mp4"
|
|
22
|
+
if "webp" in fh:
|
|
23
|
+
return "webp"
|
|
24
|
+
if "png" in fh:
|
|
25
|
+
return "png"
|
|
26
|
+
path = urlparse(url).path.lower()
|
|
27
|
+
last_segment = path.rsplit("/", 1)[-1]
|
|
28
|
+
suffix = last_segment.rsplit(".", 1)[-1] if "." in last_segment else ""
|
|
29
|
+
if suffix in {"jpg", "jpeg", "png", "webp", "gif", "avif", "heic", "bmp"}:
|
|
30
|
+
return suffix
|
|
31
|
+
video = {"mp4", "webm", "mov", "m4v"}
|
|
32
|
+
if suffix in video:
|
|
33
|
+
return suffix
|
|
34
|
+
# inspect query string for mime_type / format
|
|
35
|
+
from urllib.parse import parse_qs
|
|
36
|
+
qs = parse_qs(urlparse(url).query)
|
|
37
|
+
mime = (qs.get("mime_type") or qs.get("mime") or [""])[0].lower()
|
|
38
|
+
fmt = (qs.get("format") or qs.get("file_format") or [""])[0].lower()
|
|
39
|
+
if "video" in mime or fmt in {"mp4", "webm", "mov", "m4v"}:
|
|
40
|
+
if "webm" in mime or fmt == "webm":
|
|
41
|
+
return "webm"
|
|
42
|
+
if "mov" in mime or fmt == "mov":
|
|
43
|
+
return "mov"
|
|
44
|
+
return "mp4"
|
|
45
|
+
if "webp" in mime or fmt == "webp":
|
|
46
|
+
return "webp"
|
|
47
|
+
if "png" in mime or fmt == "png":
|
|
48
|
+
return "png"
|
|
49
|
+
low = url.lower()
|
|
50
|
+
if ".mp4" in low or ".webm" in low or ".mov" in low:
|
|
51
|
+
return "mp4" if ".mp4" in low else ("webm" if ".webm" in low else "mov")
|
|
52
|
+
return "jpg"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_item(item: dict, fallback_username: str | None = None,
|
|
56
|
+
extract_audio: bool = False) -> Post | None:
|
|
57
|
+
"""Parse one TikTok item (from item_list) into a Post."""
|
|
58
|
+
try:
|
|
59
|
+
vid = str(item.get("id") or "")
|
|
60
|
+
if not vid:
|
|
61
|
+
return None
|
|
62
|
+
author = item.get("author") or {}
|
|
63
|
+
username = author.get("uniqueId") or fallback_username or "unknown"
|
|
64
|
+
desc = item.get("desc")
|
|
65
|
+
create_time = item.get("createTime")
|
|
66
|
+
datetime_iso = None
|
|
67
|
+
if create_time:
|
|
68
|
+
try:
|
|
69
|
+
datetime_iso = datetime.fromtimestamp(int(create_time), tz=timezone.utc).isoformat()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
permalink = f"https://www.tiktok.com/@{username}/video/{vid}"
|
|
73
|
+
|
|
74
|
+
medias: list[Media] = []
|
|
75
|
+
video = item.get("video") or {}
|
|
76
|
+
# Prefer best quality from bitrateInfo
|
|
77
|
+
play_addr = None
|
|
78
|
+
format_hint = video.get("format")
|
|
79
|
+
bitrate_info = video.get("bitrateInfo")
|
|
80
|
+
bitrate_candidates: list[str] = []
|
|
81
|
+
if isinstance(bitrate_info, list) and bitrate_info:
|
|
82
|
+
# pick highest width*height
|
|
83
|
+
best_score = -1
|
|
84
|
+
best_url_list: list[str] = []
|
|
85
|
+
for bi in bitrate_info:
|
|
86
|
+
pa = bi.get("PlayAddr") or {}
|
|
87
|
+
url_list = pa.get("UrlList") or []
|
|
88
|
+
if not url_list:
|
|
89
|
+
continue
|
|
90
|
+
for u in url_list:
|
|
91
|
+
if u and u not in bitrate_candidates:
|
|
92
|
+
bitrate_candidates.append(u)
|
|
93
|
+
w = int(pa.get("Width") or 0)
|
|
94
|
+
h = int(pa.get("Height") or 0)
|
|
95
|
+
score = w * h
|
|
96
|
+
if score > best_score:
|
|
97
|
+
best_score = score
|
|
98
|
+
play_addr = url_list[0]
|
|
99
|
+
best_url_list = list(url_list)
|
|
100
|
+
format_hint = pa.get("DataSize") and format_hint
|
|
101
|
+
if not play_addr and bitrate_info:
|
|
102
|
+
first = bitrate_info[0].get("PlayAddr", {}).get("UrlList") or []
|
|
103
|
+
if first:
|
|
104
|
+
play_addr = first[0]
|
|
105
|
+
best_url_list = list(first)
|
|
106
|
+
if best_url_list:
|
|
107
|
+
for u in best_url_list:
|
|
108
|
+
if u and u not in bitrate_candidates:
|
|
109
|
+
bitrate_candidates.append(u)
|
|
110
|
+
if not play_addr:
|
|
111
|
+
play_addr = video.get("playAddr") or video.get("downloadAddr")
|
|
112
|
+
cover = video.get("cover") or video.get("originCover") or video.get("dynamicCover")
|
|
113
|
+
download_addr = video.get("downloadAddr")
|
|
114
|
+
# Prefer downloadAddr unless it is a watermarked "logo" variant, in
|
|
115
|
+
# which case playAddr is usually the clean source.
|
|
116
|
+
if download_addr and "logo" in download_addr.lower() and play_addr:
|
|
117
|
+
primary = play_addr
|
|
118
|
+
else:
|
|
119
|
+
primary = download_addr or play_addr
|
|
120
|
+
|
|
121
|
+
# Collect all download candidates, preferring downloadAddr, then playAddr,
|
|
122
|
+
# then every UrlList entry from bitrateInfo. Dedup while preserving order.
|
|
123
|
+
all_candidates: list[str] = []
|
|
124
|
+
for cand in (
|
|
125
|
+
download_addr,
|
|
126
|
+
play_addr,
|
|
127
|
+
video.get("playAddr"),
|
|
128
|
+
*bitrate_candidates,
|
|
129
|
+
):
|
|
130
|
+
if cand and cand not in all_candidates:
|
|
131
|
+
all_candidates.append(cand)
|
|
132
|
+
|
|
133
|
+
fallback_urls: list[str] = []
|
|
134
|
+
if primary:
|
|
135
|
+
for cand in all_candidates:
|
|
136
|
+
if cand and cand != primary and cand not in fallback_urls:
|
|
137
|
+
fallback_urls.append(cand)
|
|
138
|
+
ext = _guess_ext(primary, format_hint or video.get("format"))
|
|
139
|
+
medias.append(
|
|
140
|
+
Media(
|
|
141
|
+
id=vid, post_id=vid, index=1, type="video", url=primary,
|
|
142
|
+
fallback_urls=fallback_urls, ext=ext,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
# photo posts
|
|
146
|
+
image_post = item.get("imagePost") or {}
|
|
147
|
+
images = image_post.get("images") or []
|
|
148
|
+
if images and not play_addr:
|
|
149
|
+
for idx, img in enumerate(images):
|
|
150
|
+
iu = None
|
|
151
|
+
img_fallbacks: list[str] = []
|
|
152
|
+
if isinstance(img, dict):
|
|
153
|
+
raw = img.get("imageURL")
|
|
154
|
+
ul: list[str] = []
|
|
155
|
+
if isinstance(raw, dict):
|
|
156
|
+
ul = raw.get("urlList") or raw.get("UrlList") or []
|
|
157
|
+
if ul:
|
|
158
|
+
iu = ul[0]
|
|
159
|
+
img_fallbacks = [u for u in ul[1:] if u]
|
|
160
|
+
elif isinstance(raw, str):
|
|
161
|
+
iu = raw
|
|
162
|
+
iu = iu or img.get("displayImage") or img.get("url")
|
|
163
|
+
if not iu and isinstance(img.get("image"), dict):
|
|
164
|
+
iu2 = img["image"].get("url") or img["image"].get("imageURL")
|
|
165
|
+
if isinstance(iu2, dict):
|
|
166
|
+
ul2 = iu2.get("urlList") or iu2.get("UrlList") or []
|
|
167
|
+
if ul2:
|
|
168
|
+
iu = ul2[0]
|
|
169
|
+
img_fallbacks = [u for u in ul2[1:] if u]
|
|
170
|
+
elif isinstance(iu2, str):
|
|
171
|
+
iu = iu2
|
|
172
|
+
# displayImage may also carry a urlList
|
|
173
|
+
di = img.get("displayImage")
|
|
174
|
+
if isinstance(di, dict):
|
|
175
|
+
dul = di.get("urlList") or di.get("UrlList") or []
|
|
176
|
+
if dul and not iu:
|
|
177
|
+
iu = dul[0]
|
|
178
|
+
img_fallbacks = [u for u in dul[1:] if u]
|
|
179
|
+
elif dul:
|
|
180
|
+
for u in dul:
|
|
181
|
+
if u and u != iu and u not in img_fallbacks:
|
|
182
|
+
img_fallbacks.append(u)
|
|
183
|
+
if not iu:
|
|
184
|
+
continue
|
|
185
|
+
ext = _guess_ext(iu)
|
|
186
|
+
mid = vid if len(images) == 1 else f"{vid}_{idx+1}"
|
|
187
|
+
medias.append(
|
|
188
|
+
Media(
|
|
189
|
+
id=mid, post_id=vid, index=idx + 1, type="image", url=iu,
|
|
190
|
+
fallback_urls=img_fallbacks, ext=ext,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
# Audio track attached to a photo carousel (gallery-dl extracts it when
|
|
194
|
+
# audio=True). Only add when the item has no playable video.
|
|
195
|
+
music = item.get("music") or {}
|
|
196
|
+
music_url = music.get("playUrl")
|
|
197
|
+
if extract_audio and images and not play_addr and music_url:
|
|
198
|
+
ext = _guess_ext(music_url)
|
|
199
|
+
medias.append(
|
|
200
|
+
Media(
|
|
201
|
+
id=f"{vid}_audio",
|
|
202
|
+
post_id=vid,
|
|
203
|
+
index=len(images) + 1,
|
|
204
|
+
type="audio",
|
|
205
|
+
url=music_url,
|
|
206
|
+
ext=ext if ext in {"mp3", "m4a", "aac", "wav", "ogg"} else "mp3",
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
# cover as image if no media? skip
|
|
210
|
+
if not medias and cover:
|
|
211
|
+
ext = _guess_ext(cover)
|
|
212
|
+
medias.append(Media(id=vid, post_id=vid, index=1, type="image", url=cover, ext=ext))
|
|
213
|
+
|
|
214
|
+
if not medias:
|
|
215
|
+
return None
|
|
216
|
+
return Post(
|
|
217
|
+
id=vid,
|
|
218
|
+
permalink=permalink,
|
|
219
|
+
username=username,
|
|
220
|
+
caption=desc,
|
|
221
|
+
datetime_iso=datetime_iso,
|
|
222
|
+
media=medias,
|
|
223
|
+
)
|
|
224
|
+
except Exception:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _pick_best_image(candidates):
|
|
229
|
+
if not candidates:
|
|
230
|
+
return None
|
|
231
|
+
try:
|
|
232
|
+
c = max(candidates, key=lambda x: int(x.get("width", 0)))
|
|
233
|
+
return c.get("url")
|
|
234
|
+
except Exception:
|
|
235
|
+
return candidates[0].get("url") if candidates else None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def extract_user_id(html: str) -> str | None:
|
|
239
|
+
m = re.search(r'"uniqueId"\s*:\s*"([^"]+)"', html)
|
|
240
|
+
return m.group(1) if m else None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _unescape_json_string(value: str) -> str:
|
|
244
|
+
"""Decode the escape sequences found in a JSON string literal."""
|
|
245
|
+
try:
|
|
246
|
+
result = _json.loads('"' + value + '"')
|
|
247
|
+
return str(result)
|
|
248
|
+
except Exception:
|
|
249
|
+
return (
|
|
250
|
+
value.replace(r"\u0026", "&")
|
|
251
|
+
.replace(r"\u002F", "/")
|
|
252
|
+
.replace(r"\u002f", "/")
|
|
253
|
+
.replace(r"\/", "/")
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def parse_profile_html(html: str):
|
|
258
|
+
"""Return the profile pic URL (or None)."""
|
|
259
|
+
pic = None
|
|
260
|
+
m = re.search(r'"avatarLarger"\s*:\s*"((?:[^"\\]|\\.)*)"', html)
|
|
261
|
+
if m:
|
|
262
|
+
pic = _unescape_json_string(m.group(1))
|
|
263
|
+
if not pic:
|
|
264
|
+
m = re.search(r'"avatarMedium"\s*:\s*"((?:[^"\\]|\\.)*)"', html)
|
|
265
|
+
if m:
|
|
266
|
+
pic = _unescape_json_string(m.group(1))
|
|
267
|
+
return pic
|