titusgiap Claude Opus 5 commited on
Commit
7196abe
·
1 Parent(s): 07d3e14

feat(down list): port đường tải qua site tải hộ từ tool local down_ads

Browse files

Bản web mới chỉ có yt-dlp + tikwm/ssstik, còn Douyin thì đi qua trang chia sẻ
iesdouyin — mà trang đó đã bị ByteDance rút sạch dữ liệu (đo 2026-09), nên
Douyin trên web coi như không tải được. Bản local down_ads đã giải bằng cách
hỏi các SITE TẢI HỘ; nay bê phần đó sang.

Thêm backend/app/services/downloader/webdl.py (port từ down_ads/core/webdl.py):
- 4 site: savetik, tiktokio, snapsave, lovetik; parser CHUNG (quét URL giống
link media, không dò thẻ/class) nên site đổi giao diện vẫn chạy.
- Lưới cuối: không khớp tên miền CDN nào thì quét mọi URL rồi HỎI SERVER cái nào
là video qua Content-Type — không cần biết trước tên miền.
- Đo dung lượng thật bằng Range rồi lấy bản to nhất; nhãn "MP4 HD" của savetik
trỏ file 576p nhỏ hơn cả link thường, không tin được.
- Bóc link CDN gốc trong token JWT của snapcdn; giữ token nguyên vẹn lúc tải vì
token dùng được đúng MỘT LẦN.
- Giữ nhịp hỏi theo từng site (savetik chịu ~1 request/2.5s).

Nối vào ydl.py:
- douyin: site tải hộ -> iesdouyin -> yt-dlp (trước là iesdouyin -> yt-dlp).
- tiktok: sau tikwm thì thử site tải hộ.
- download_many chạy SONG SONG (trần 4, đổi bằng DOWNLOAD_WORKERS) — trước là
vòng for tuần tự dù phần lớn thời gian chỉ ngồi chờ mạng. Giữ nguyên thứ tự
kết quả, 1 link hỏng không giết cả mẻ.

Khác bản local, đều vì môi trường máy chủ:
- KHÔNG gọi thẳng site. Space từng bị HF khoá vì "Suspicious domain call", nên
mọi request đi qua Worker relay. Worker thêm endpoint '?form=' (POST hộ, tự
gắn Referer/Origin) và '?proxy=' nay chuyển tiếp header Range.
- Bỏ lớp DNS-over-HTTPS + xoay IP của bản local: sinh ra để vượt nhà mạng VN
chặn savetik, Worker chạy IP Cloudflare nên không vướng — mà chính kiểu
outbound đó làm Space bị gắn cờ.
- Worker chưa deploy bản mới -> nhận ra ngay từ site đầu và báo đúng việc cần
làm, thay vì thử 4 site x 3 lần mất ~30s vô ích.

Kiểm thật (không phải mock): hỏi 4 site bằng link TikTok công khai -> savetik
1.2s/3 link, tiktokio 6.6s/3 link, snapsave và lovetik không ra link. Tải trọn
đường qua savetik: 8.9s, ra file h264 576x1024, 2.82 MB, 10.5s.

117/117 test PASS (thêm 12 test).

VIỆC CÒN LẠI CỦA USER: deploy lại Cloudflare Worker bằng bản mới của
deploy/tiktok_relay_worker.js, nếu không thì đường site tải hộ chưa chạy trên
Space (các đường cũ vẫn nguyên, không hồi quy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/app/services/downloader/webdl.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tải video bằng cách HỎI CÁC SITE TẢI HỘ (savetik, tiktokio, snapsave, lovetik).
2
+
3
+ Port từ tool local `down_ads` (core/webdl.py) sang bản chạy trên máy chủ. Đây là
4
+ lưới dự phòng cho TikTok và là ĐƯỜNG CHÍNH cho Douyin: trang chia sẻ iesdouyin đã
5
+ bị ByteDance rút sạch dữ liệu (đo 2026-09), API detail trả 403, nên `_douyin_direct`
6
+ trong ydl.py gần như không còn tải được gì.
7
+
8
+ Khác bản local ở hai chỗ, đều vì môi trường máy chủ:
9
+
10
+ 1. KHÔNG gọi thẳng site. Space từng bị Hugging Face khoá với lý do "Suspicious
11
+ domain call" vì IP máy chủ gọi thẳng các domain scraper. Nên mọi request ở đây
12
+ đi qua Worker relay (`TIKTOK_RELAY_URL`) — Worker mới cần endpoint `?form=`
13
+ (POST hộ) và `?proxy=` biết chuyển tiếp header Range. Dev local (không phải
14
+ Space) vẫn gọi thẳng cho tiện thử.
15
+ 2. Bỏ lớp DNS-over-HTTPS + xoay IP của bản local. Lớp đó sinh ra để vượt nhà mạng
16
+ VN chặn savetik.co; Worker chạy trên IP Cloudflare nên không vướng, mà kiểu
17
+ outbound đó chính là thứ làm Space bị gắn cờ.
18
+
19
+ Parser CHUNG (quét mọi URL giống link media, không dò thẻ/class) nên site đổi giao
20
+ diện vẫn chạy; thêm site mới chỉ là thêm một dòng vào SITES.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import json
27
+ import os
28
+ import re
29
+ import threading
30
+ import time
31
+
32
+ _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
33
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
34
+
35
+ # gap: nhịp tối thiểu giữa 2 lần hỏi CÙNG một site. Đo thực tế ở bản local:
36
+ # savetik chỉ chịu ~1 request/2.5s, gọi dồn là 429 hàng loạt. Quan trọng hơn từ
37
+ # khi download_many chạy SONG SONG — nhiều luồng cùng hỏi một site.
38
+ BUILTIN_SITES: list[dict] = [
39
+ {"name": "savetik", "home": "https://savetik.co/vi",
40
+ "api": "https://savetik.co/api/ajaxSearch", "gap": 2.5},
41
+ {"name": "tiktokio", "home": "https://tiktokio.to/en",
42
+ "api": "https://tiktokio.to/api/ajaxSearch", "gap": 1.5},
43
+ {"name": "snapsave", "home": "https://snapsave.app/vn",
44
+ "api": "https://snapsave.app/action.php", "gap": 1.5},
45
+ {"name": "lovetik", "home": "https://lovetik.com/",
46
+ "api": "https://lovetik.com/api/ajax/search", "gap": 1.5},
47
+ ]
48
+
49
+
50
+ def get_sites() -> list[dict]:
51
+ """Danh sách site. Đổi được bằng biến môi trường DL_SITES_JSON mà không build lại."""
52
+ raw = (os.environ.get("DL_SITES_JSON") or "").strip()
53
+ if raw:
54
+ try:
55
+ got = json.loads(raw)
56
+ if isinstance(got, list) and got:
57
+ return [s for s in got if s.get("api") and s.get("home")]
58
+ except Exception:
59
+ pass
60
+ return BUILTIN_SITES
61
+
62
+
63
+ # Link media của Douyin/TikTok, hoặc link proxy của chính site tải hộ.
64
+ MEDIA_RE = re.compile(
65
+ r"https?://[^\s\"'<>\\]{0,250}?"
66
+ r"(?:douyinvod|zjcdn|douyinstatic|bytecdn|amemv|tikcdn|snapcdn\.app/get"
67
+ r"|tikwm\.com/video|\.mp4)"
68
+ # Đuôi phải RỘNG: token JWT của dl.snapcdn.app dài hơn 250 ký tự. Cắt cụt thì
69
+ # link vừa hỏng vừa âm thầm rơi mất bản nét nhất (bản local đã dính: tải về
70
+ # 576p trong khi bản 1080p nằm ở đoạn link bị cắt).
71
+ r"[^\s\"'<>\\]{0,2000}",
72
+ re.I,
73
+ )
74
+ # Link chỉ là nhạc/ảnh bìa — không phải video cần tải.
75
+ SKIP_RE = re.compile(r"(music|/aweme/v1/play|cover|avatar|thumb|\.jpe?g|\.png|\.mp3)", re.I)
76
+
77
+ # Lưới cuối: nhận video KHÔNG dựa vào tên miền. MEDIA_RE vẫn phải liệt kê tên CDN
78
+ # (zjcdn, douyinvod…) vì link Douyin không có đuôi .mp4 — ngày ByteDance đổi sang
79
+ # CDN tên khác là regex đó trượt sạch. Khi không khớp gì, quét MỌI url rồi HỎI
80
+ # SERVER cái nào là video (Content-Type), không cần biết trước tên miền nào.
81
+ ANY_URL_RE = re.compile(r"https?://[^\s\"'<>\\)]{10,2000}", re.I)
82
+ STATIC_RE = re.compile(
83
+ r"\.(css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|xml|txt)(\?|$)", re.I)
84
+ NOISE_RE = re.compile(
85
+ r"(google|gstatic|doubleclick|facebook|twitter|cloudflare|jquery|bootstrap"
86
+ r"|fontawesome|analytics|adsbygoogle|recaptcha|sentry|cdnjs)", re.I)
87
+
88
+ _LOCK = threading.Lock()
89
+ _LAST: dict[str, float] = {}
90
+
91
+
92
+ def _wait(site: dict) -> None:
93
+ """Giữ nhịp hỏi cho TỪNG site, dùng chung cho mọi luồng tải."""
94
+ name, gap = site["name"], float(site.get("gap", 1.5))
95
+ with _LOCK:
96
+ wait = gap - (time.time() - _LAST.get(name, 0.0))
97
+ if wait > 0:
98
+ time.sleep(wait)
99
+ _LAST[name] = time.time()
100
+
101
+
102
+ def _unescape(u: str) -> str:
103
+ return (u.replace("\\u002F", "/").replace("\\u0026", "&")
104
+ .replace("\\/", "/").replace("\\", "").replace("&amp;", "&"))
105
+
106
+
107
+ def _snapcdn_origin(link: str) -> str:
108
+ """Link proxy dl.snapcdn.app/get?token=<JWT> có chứa sẵn link CDN gốc.
109
+
110
+ Token là JWT KHÔNG mã hoá: payload base64 chứa {"url": "<link thật>"}. Bóc ra
111
+ để lỡ proxy của họ hỏng thì vẫn tải thẳng được.
112
+ """
113
+ try:
114
+ payload = link.split("token=", 1)[1].split(".")[1]
115
+ payload += "=" * (-len(payload) % 4)
116
+ return json.loads(base64.urlsafe_b64decode(payload)).get("url") or ""
117
+ except Exception:
118
+ return ""
119
+
120
+
121
+ # ───────────────────────── lớp đi mạng (relay hay thẳng) ─────────────────────
122
+
123
+ class RelayTooOld(RuntimeError):
124
+ """Worker relay đang chạy bản CŨ, chưa có endpoint `?form=`.
125
+
126
+ Đây không phải lỗi site — thử tiếp 4 site nữa chỉ tốn thêm ~30s chờ vô ích,
127
+ nên gặp là dừng cả chuỗi và nói thẳng việc cần làm.
128
+ """
129
+
130
+
131
+ def _relay() -> str | None:
132
+ from .ydl import _tiktok_relay
133
+ return _tiktok_relay()
134
+
135
+
136
+ def _guard() -> None:
137
+ from .ydl import _guard_direct
138
+ _guard_direct("site tải hộ")
139
+
140
+
141
+ def _post_site(site: dict, ask_url: str, timeout: int = 30):
142
+ """POST tới API của site -> requests.Response. Qua relay khi có cấu hình."""
143
+ import requests # type: ignore
144
+
145
+ payload = {"q": ask_url, "url": ask_url, "query": ask_url, "lang": "vi"}
146
+ relay = _relay()
147
+ if relay:
148
+ # Worker POST hộ: nó tự gắn Referer/Origin theo `ref` nên site không chặn.
149
+ r = requests.post(relay, params={"form": site["api"], "ref": site["home"]},
150
+ data=payload, timeout=timeout,
151
+ headers={"User-Agent": _UA})
152
+ # Worker bản cũ không biết `?form=` -> trả JSON lỗi tham số của chính nó.
153
+ body = (r.text or "")[:200]
154
+ if "thiếu tham số" in body and '"code"' in body:
155
+ raise RelayTooOld(
156
+ "Worker relay chưa có endpoint '?form='. Mở Cloudflare Workers, dán đè "
157
+ "deploy/tiktok_relay_worker.js bản mới rồi Deploy lại.")
158
+ return r
159
+ _guard() # trên Space mà thiếu relay -> báo lỗi, KHÔNG gọi thẳng
160
+ hdr = {"User-Agent": _UA, "Referer": site["home"],
161
+ "Origin": site["home"].rstrip("/").rsplit("/", 1)[0],
162
+ "X-Requested-With": "XMLHttpRequest"}
163
+ return requests.post(site["api"], data=payload, headers=hdr, timeout=timeout)
164
+
165
+
166
+ def _get_media(link: str, timeout: int = 60, stream: bool = False,
167
+ headers: dict | None = None):
168
+ """GET một URL media -> requests.Response. Qua relay khi có cấu hình.
169
+
170
+ Header `Range` được chuyển tiếp qua relay để đo dung lượng mà không tải cả file.
171
+ """
172
+ import requests # type: ignore
173
+
174
+ hdr = dict(headers or {})
175
+ hdr.setdefault("User-Agent", _UA)
176
+ relay = _relay()
177
+ if relay:
178
+ fwd = {"User-Agent": _UA}
179
+ if hdr.get("Range"):
180
+ fwd["Range"] = hdr["Range"]
181
+ return requests.get(relay, params={"proxy": link}, timeout=timeout,
182
+ stream=stream, headers=fwd)
183
+ _guard()
184
+ return requests.get(link, timeout=timeout, stream=stream, headers=hdr)
185
+
186
+
187
+ # ────────────────────────────── hỏi site & chọn link ─────────────────────────
188
+
189
+ def _sniff_video_urls(text: str, limit: int = 8) -> list[str]:
190
+ """Quét mọi url trong phản hồi rồi hỏi server cái nào là video.
191
+
192
+ Lưới cuối cho tình huống site/CDN đổi tên miền: không đoán theo tên, chỉ tải
193
+ 1 byte đầu (Range) và xem Content-Type. Chỉ chạy khi cách nhanh phía trên
194
+ không tìm được gì nên không làm chậm đường chạy bình thường.
195
+ """
196
+ seen: list[str] = []
197
+ for m in ANY_URL_RE.finditer(text):
198
+ u = _unescape(m.group(0)).rstrip("\"',);")
199
+ if (u in seen or STATIC_RE.search(u) or NOISE_RE.search(u)
200
+ or SKIP_RE.search(u)):
201
+ continue
202
+ seen.append(u)
203
+ if len(seen) >= 60:
204
+ break
205
+ found: list[str] = []
206
+ for u in seen[:limit]:
207
+ try:
208
+ with _get_media(u, timeout=15, stream=True,
209
+ headers={"Range": "bytes=0-0"}) as r:
210
+ ctype = (r.headers.get("Content-Type") or "").lower()
211
+ clen = r.headers.get("Content-Range") or r.headers.get("Content-Length") or ""
212
+ if ctype.startswith("video/") or ("octet-stream" in ctype and clen):
213
+ found.append(u)
214
+ except Exception:
215
+ continue
216
+ return found
217
+
218
+
219
+ def _ask_site(site: dict, ask_url: str) -> tuple[list[str], str]:
220
+ """Hỏi 1 site -> (danh sách link media, tiêu đề). Ném lỗi nếu site không trả gì."""
221
+ text = ""
222
+ last = ""
223
+ for att in range(3):
224
+ _wait(site)
225
+ try:
226
+ r = _post_site(site, ask_url)
227
+ if r.status_code == 429:
228
+ last = "giới hạn tần suất (429)"
229
+ time.sleep(3.0 * (att + 1))
230
+ continue
231
+ text = r.text or ""
232
+ if len(text) >= 200:
233
+ break
234
+ last = f"HTTP {r.status_code}, phản hồi rỗng"
235
+ except RelayTooOld:
236
+ raise # không retry, không nuốt
237
+ except Exception as e:
238
+ from .ydl import clean_error
239
+ last = clean_error(e)
240
+ time.sleep(1.0 * (att + 1))
241
+ if not text:
242
+ raise RuntimeError(last or "không có phản hồi")
243
+
244
+ links: list[str] = []
245
+ for m in MEDIA_RE.finditer(text):
246
+ u = _unescape(m.group(0))
247
+ if not SKIP_RE.search(u) and u not in links:
248
+ links.append(u)
249
+ if not links:
250
+ links = _sniff_video_urls(text)
251
+ title = ""
252
+ mt = (re.search(r"<h3[^>]*>([^<]{1,200})</h3>", text)
253
+ or re.search(r'"(?:title|desc)"\s*:\s*"([^"]{1,200})"', text))
254
+ if mt:
255
+ title = _unescape(mt.group(1)).strip()
256
+ return links, title
257
+
258
+
259
+ def _probe_size(link: str) -> int:
260
+ """Dung lượng thật, đo bằng 1 byte (Range) nên gần như không tốn gì.
261
+
262
+ QUAN TRỌNG: chỉ đo trên link CDN GỐC. Token dl.snapcdn.app dùng được đúng MỘT
263
+ LẦN — hỏi trước là lúc tải thật nhận 500, bản local đã dính rồi.
264
+ """
265
+ try:
266
+ with _get_media(link, timeout=20, stream=True,
267
+ headers={"Range": "bytes=0-0"}) as r:
268
+ cr = r.headers.get("Content-Range", "")
269
+ if "/" in cr:
270
+ return int(cr.rsplit("/", 1)[1])
271
+ return int(r.headers.get("Content-Length") or 0)
272
+ except Exception:
273
+ return 0
274
+
275
+
276
+ def _measure(link: str) -> tuple[int, str]:
277
+ """(dung lượng, link dùng để đo) — link snapcdn thì đo trên URL gốc trong token.
278
+
279
+ Không tin nhãn chất lượng của site: đo thực tế thấy nhãn "MP4 HD" của savetik
280
+ trỏ file 576p nhỏ hơn cả link thường 720p.
281
+ """
282
+ origin = _snapcdn_origin(link)
283
+ probe = origin or link
284
+ return _probe_size(probe), probe
285
+
286
+
287
+ def _ask_url_for(url: str) -> str:
288
+ """Dạng link mà các site nhận chắc nhất (Douyin -> trang chia sẻ)."""
289
+ if "douyin.com" in url and "iesdouyin" not in url:
290
+ m = re.search(r"/video/(\d{6,})", url) or re.search(r"modal_id=(\d{6,})", url)
291
+ if m:
292
+ return f"https://www.iesdouyin.com/share/video/{m.group(1)}/"
293
+ return url
294
+
295
+
296
+ def _safe_name(title: str, fallback: str) -> str:
297
+ import html as _html
298
+ # Site trả tiêu đề dạng HTML (&#x1F60D;, &amp;) -> giải mã trước, không thì tên
299
+ # file đầy mã thực thể, nhìn như lỗi. Test thật trên savetik đã dính.
300
+ s = re.sub(r'[\\/:*?"<>|\n\r\t]+', "_", _html.unescape(title or ""))[:60].strip()
301
+ return s or fallback
302
+
303
+
304
+ def _unique_path(path: str) -> str:
305
+ if not os.path.exists(path):
306
+ return path
307
+ base, ext = os.path.splitext(path)
308
+ for i in range(2, 999):
309
+ cand = f"{base} ({i}){ext}"
310
+ if not os.path.exists(cand):
311
+ return cand
312
+ return path
313
+
314
+
315
+ def _stream_to_file(link: str, path: str) -> None:
316
+ """Tải link về file. Ghi ra .part rồi đổi tên -> không để lại file dở."""
317
+ tmp = path + ".part"
318
+ try:
319
+ with _get_media(link, timeout=120, stream=True) as r:
320
+ r.raise_for_status()
321
+ with open(tmp, "wb") as f:
322
+ for chunk in r.iter_content(1 << 17):
323
+ if chunk:
324
+ f.write(chunk)
325
+ except BaseException:
326
+ try:
327
+ os.remove(tmp)
328
+ except OSError:
329
+ pass
330
+ raise
331
+ os.replace(tmp, path)
332
+
333
+
334
+ def download_via_sites(url: str, out_dir: str) -> dict:
335
+ """Thử lần lượt các site tới khi ra VIDEO THẬT. Ném lỗi nếu hết site.
336
+
337
+ Trả dict: {path, title, ext, size, duration, url, via}.
338
+ """
339
+ from .ydl import _has_video_stream, clean_error
340
+
341
+ ask = _ask_url_for(url)
342
+ m = re.search(r"/(?:video|photo)/(\d{6,})", url) or re.search(r"(\d{15,})", url)
343
+ vid = m.group(1) if m else "video"
344
+ errors: list[str] = []
345
+
346
+ for site in get_sites():
347
+ try:
348
+ links, title = _ask_site(site, ask)
349
+ except RelayTooOld as e:
350
+ raise RuntimeError(str(e)) # site nào cũng sẽ hỏng như nhau
351
+ except Exception as e:
352
+ errors.append(f"{site['name']}: {clean_error(e)[:70]}")
353
+ continue
354
+ if not links:
355
+ errors.append(f"{site['name']}: không thấy link video")
356
+ continue
357
+
358
+ # Đo dung lượng thật rồi lấy bản TO NHẤT (= nét nhất). Với link proxy thì
359
+ # đo trên URL gốc bóc từ token, giữ token nguyên vẹn cho lúc tải.
360
+ measured = [(sz, probe, u) for u, (sz, probe) in
361
+ ((u, _measure(u)) for u in links[:6])]
362
+ measured.sort(key=lambda t: -t[0])
363
+ targets: list[str] = []
364
+ for _sz, probe, u in measured:
365
+ for cand in (probe, u): # ưu tiên CDN gốc, hỏng thì qua proxy
366
+ if cand and cand not in targets:
367
+ targets.append(cand)
368
+
369
+ for target in targets[:8]:
370
+ path = _unique_path(os.path.join(
371
+ out_dir, f"{_safe_name(title, vid)} [{vid}].mp4"))
372
+ try:
373
+ _stream_to_file(target, path)
374
+ if not _has_video_stream(path):
375
+ os.remove(path)
376
+ continue
377
+ from .ydl import _duration
378
+ return {"path": path, "title": title or vid, "ext": "mp4",
379
+ "size": os.path.getsize(path), "duration": _duration(path),
380
+ "url": url, "via": site["name"]}
381
+ except Exception as e:
382
+ errors.append(f"{site['name']}: tải lỗi {clean_error(e)[:50]}")
383
+
384
+ raise RuntimeError("mọi site tải hộ đều không ra video — " + " | ".join(errors[:4]))
backend/app/services/downloader/ydl.py CHANGED
@@ -153,6 +153,21 @@ def _has_video_stream(path: str) -> bool:
153
  return False
154
 
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  # Trình duyệt giả để nhiều site (Douyin/TikTok...) không chặn request như bot.
157
  _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
158
  "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
@@ -443,6 +458,18 @@ _UA_MOBILE = ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
443
  "Mobile/15E148 Safari/604.1")
444
 
445
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  def _douyin_direct(url: str, out_dir: str) -> DownloadResult:
447
  """Tải Douyin KHÔNG cần cookies.
448
 
@@ -826,16 +853,24 @@ def download_one(url: str, out_dir: str, cookies_file: str | None = None) -> Dow
826
  os.makedirs(out_dir, exist_ok=True)
827
  url = normalize_url(url)
828
  source = detect_source(url)
829
- # Douyin: dùng cách bóc link trực tiếp (không cookies) TRƯỚC; yt-dlp là dự phòng.
 
 
 
830
  if source == "douyin":
831
- try:
832
- return _douyin_direct(url, out_dir)
833
- except Exception as e_d:
834
  try:
835
- return _attempt(url, out_dir, _build_opts(out_dir, cookies_file, source=source))
836
- except Exception:
837
- return DownloadResult(url=url, ok=False,
838
- error=clean_error(f"douyin: {e_d}"))
 
 
 
 
 
839
  try:
840
  return _attempt(url, out_dir, _build_opts(out_dir, cookies_file, source=source))
841
  except Exception as e: # pragma: no cover - lỗi mạng/extractor
@@ -862,6 +897,12 @@ def download_one(url: str, out_dir: str, cookies_file: str | None = None) -> Dow
862
  try:
863
  return _tiktok_via_tikwm(url, out_dir)
864
  except Exception as e_tk:
 
 
 
 
 
 
865
  msg = f"{first_err} | tikwm: {e_tk}"
866
  # Ads nhắm mục tiêu (targeted Spark Ads) đôi khi chỉ hiện trong app
867
  # cho đúng tài khoản được target -> KHÔNG server nào (kể cả tikwm) có
@@ -882,14 +923,51 @@ def download_one(url: str, out_dir: str, cookies_file: str | None = None) -> Dow
882
  return DownloadResult(url=url, ok=False, error=first_err)
883
 
884
 
 
 
 
 
 
 
 
 
 
 
 
885
  def download_many(urls: list[str], out_dir: str,
886
  cookies_file: str | None = None,
887
  on_item: Callable[[int, DownloadResult], None] | None = None
888
  ) -> list[DownloadResult]:
889
- results = []
890
- for i, url in enumerate(urls):
891
- res = download_one(url, out_dir, cookies_file)
892
- results.append(res)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
893
  if on_item:
894
- on_item(i, res)
895
- return results
 
 
 
 
 
 
 
 
 
 
153
  return False
154
 
155
 
156
+ def _duration(path: str) -> float:
157
+ """Thời lượng file (giây). Không đọc được -> 0.0, không làm hỏng luồng tải."""
158
+ import shutil as _sh
159
+ import subprocess as _sp
160
+ ff = _sh.which("ffprobe") or "ffprobe"
161
+ try:
162
+ out = _sp.run(
163
+ [ff, "-v", "error", "-show_entries", "format=duration",
164
+ "-of", "csv=p=0", path],
165
+ capture_output=True, text=True).stdout.strip()
166
+ return float(out)
167
+ except Exception:
168
+ return 0.0
169
+
170
+
171
  # Trình duyệt giả để nhiều site (Douyin/TikTok...) không chặn request như bot.
172
  _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
173
  "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
 
458
  "Mobile/15E148 Safari/604.1")
459
 
460
 
461
+ def _via_sites(url: str, out_dir: str) -> DownloadResult:
462
+ """Tải qua các SITE TẢI HỘ (savetik/tiktokio/snapsave/lovetik).
463
+
464
+ Port từ tool local `down_ads`. Nhiều nguồn độc lập nhau nên một site chết
465
+ không kéo sập cả đường tải. Chi tiết xem services/downloader/webdl.py.
466
+ """
467
+ from . import webdl
468
+ d = webdl.download_via_sites(url, out_dir)
469
+ return DownloadResult(url=url, ok=True, path=d["path"], title=d["title"],
470
+ ext=d["ext"], size=d["size"], duration=d["duration"])
471
+
472
+
473
  def _douyin_direct(url: str, out_dir: str) -> DownloadResult:
474
  """Tải Douyin KHÔNG cần cookies.
475
 
 
853
  os.makedirs(out_dir, exist_ok=True)
854
  url = normalize_url(url)
855
  source = detect_source(url)
856
+ # Douyin: HỎI SITE TẢI HỘ trước. Trang chia sẻ iesdouyin đã bị ByteDance rút
857
+ # sạch dữ liệu (đo 2026-09: `_ROUTER_DATA` chỉ còn ua/webId/itemId) và API
858
+ # detail trả 403, nên `_douyin_direct` gần như luôn fail — nay nó tụt xuống
859
+ # làm lưới dự phòng, yt-dlp là lớp cuối.
860
  if source == "douyin":
861
+ errs = []
862
+ for name, fn in (("site tải hộ", lambda: _via_sites(url, out_dir)),
863
+ ("iesdouyin", lambda: _douyin_direct(url, out_dir))):
864
  try:
865
+ return fn()
866
+ except Exception as e_d:
867
+ errs.append(f"{name}: {clean_error(e_d)}")
868
+ try:
869
+ return _attempt(url, out_dir, _build_opts(out_dir, cookies_file, source=source))
870
+ except Exception as e_y:
871
+ errs.append(f"yt-dlp: {clean_error(e_y)}")
872
+ return DownloadResult(url=url, ok=False,
873
+ error=clean_error("douyin — " + " | ".join(errs[:3])))
874
  try:
875
  return _attempt(url, out_dir, _build_opts(out_dir, cookies_file, source=source))
876
  except Exception as e: # pragma: no cover - lỗi mạng/extractor
 
897
  try:
898
  return _tiktok_via_tikwm(url, out_dir)
899
  except Exception as e_tk:
900
+ # Lưới cuối: hỏi các site tải hộ (savetik/tiktokio/…) — cùng cách
901
+ # bản local down_ads dùng, nhiều nguồn thì khó chết cả loạt.
902
+ try:
903
+ return _via_sites(url, out_dir)
904
+ except Exception as e_site:
905
+ e_tk = RuntimeError(f"{clean_error(e_tk)} | {clean_error(e_site)}")
906
  msg = f"{first_err} | tikwm: {e_tk}"
907
  # Ads nhắm mục tiêu (targeted Spark Ads) đôi khi chỉ hiện trong app
908
  # cho đúng tài khoản được target -> KHÔNG server nào (kể cả tikwm) có
 
923
  return DownloadResult(url=url, ok=False, error=first_err)
924
 
925
 
926
+ def _dl_workers(n: int) -> int:
927
+ """Số link tải CÙNG LÚC. Tải là chờ MẠNG chứ không ăn CPU nên chạy song song
928
+ rất lợi; trần 4 để không dội request vào cùng một nguồn (savetik chỉ chịu
929
+ ~1 request/2.5s — webdl tự giữ nhịp, nhưng đông luồng quá thì chỉ xếp hàng
930
+ lâu hơn chứ không nhanh thêm)."""
931
+ env = (os.environ.get("DOWNLOAD_WORKERS") or "").strip()
932
+ if env.isdigit():
933
+ return max(1, min(8, int(env)))
934
+ return max(1, min(4, n))
935
+
936
+
937
  def download_many(urls: list[str], out_dir: str,
938
  cookies_file: str | None = None,
939
  on_item: Callable[[int, DownloadResult], None] | None = None
940
  ) -> list[DownloadResult]:
941
+ """Tải nhiều link SONG SONG, giữ nguyên thứ tự kết quả theo thứ tự link vào.
942
+
943
+ Trước đây chạy tuần tự từng link: 20 link mỗi link 5s = 100s dù phần lớn thời
944
+ gian chỉ ngồi chờ mạng. `on_item` được gọi NGAY khi từng link xong (không theo
945
+ thứ tự) để thanh tiến độ nhúc nhích liên tục.
946
+ """
947
+ from concurrent.futures import ThreadPoolExecutor
948
+ import threading as _th
949
+
950
+ n = len(urls)
951
+ results: list[DownloadResult | None] = [None] * n
952
+ if n == 0:
953
+ return []
954
+ lock = _th.Lock()
955
+
956
+ def one(i: int):
957
+ try:
958
+ res = download_one(urls[i], out_dir, cookies_file)
959
+ except Exception as e: # không để 1 link làm chết cả mẻ
960
+ res = DownloadResult(url=urls[i], ok=False, error=clean_error(e))
961
+ results[i] = res
962
  if on_item:
963
+ with lock: # on_item ghi tiến độ job -> gọi 1 luồng
964
+ on_item(i, res)
965
+
966
+ workers = _dl_workers(n)
967
+ if workers <= 1 or n == 1:
968
+ for i in range(n):
969
+ one(i)
970
+ else:
971
+ with ThreadPoolExecutor(max_workers=workers) as ex:
972
+ list(ex.map(one, range(n)))
973
+ return [r for r in results if r is not None]
backend/tests/test_webdl.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test đường tải qua SITE TẢI HỘ (savetik/tiktokio/…) và tải nhiều link song song.
2
+
3
+ Không gọi mạng thật: thay `requests` trong module bằng bản giả, để kiểm ĐÚNG
4
+ cái mình viết — chọn link to nhất, bóc link gốc trong token snapcdn, đi qua relay
5
+ khi có TIKTOK_RELAY_URL, và một link hỏng không kéo sập cả mẻ.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import tempfile
14
+ import time
15
+
16
+ from app.services.downloader import webdl, ydl
17
+ from tests._util import FFMPEG
18
+
19
+
20
+ class _Resp:
21
+ def __init__(self, text="", content=b"", status=200, headers=None):
22
+ self.text = text
23
+ self.content = content
24
+ self.status_code = status
25
+ self.headers = headers or {}
26
+
27
+ def raise_for_status(self):
28
+ if self.status_code >= 400:
29
+ raise RuntimeError(f"HTTP {self.status_code}")
30
+
31
+ def iter_content(self, _n=1 << 17):
32
+ yield self.content
33
+
34
+ def __enter__(self):
35
+ return self
36
+
37
+ def __exit__(self, *a):
38
+ return False
39
+
40
+
41
+ class _FakeRequests:
42
+ """requests giả — ghi lại mọi lời gọi để test soi được đi đường nào."""
43
+
44
+ def __init__(self, on_post, on_get):
45
+ self.on_post, self.on_get = on_post, on_get
46
+ self.posts, self.gets = [], []
47
+
48
+ def post(self, url, **kw):
49
+ self.posts.append((url, kw))
50
+ return self.on_post(url, kw)
51
+
52
+ def get(self, url, **kw):
53
+ self.gets.append((url, kw))
54
+ return self.on_get(url, kw)
55
+
56
+
57
+ def _install(monkey_requests):
58
+ """Nhét requests giả vào webdl (module import requests bên trong hàm)."""
59
+ import sys
60
+ sys.modules["requests"] = monkey_requests # type: ignore
61
+ return monkey_requests
62
+
63
+
64
+ def _restore():
65
+ import importlib
66
+ import sys
67
+ sys.modules.pop("requests", None)
68
+ try:
69
+ importlib.import_module("requests")
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def _snapcdn_link(origin: str) -> str:
75
+ """Dựng link dl.snapcdn.app/get?token=<JWT> có nhúng link gốc, như savetik trả."""
76
+ payload = base64.urlsafe_b64encode(
77
+ json.dumps({"url": origin}).encode()).decode().rstrip("=")
78
+ return f"https://dl.snapcdn.app/get?token=hdr.{payload}.sig"
79
+
80
+
81
+ def _real_mp4(path: str) -> bytes:
82
+ subprocess.run([FFMPEG, "-y", "-v", "error", "-f", "lavfi",
83
+ "-i", "color=c=red:s=64x64:d=1:r=10",
84
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", path],
85
+ check=True, capture_output=True)
86
+ return open(path, "rb").read()
87
+
88
+
89
+ def test_snapcdn_origin_boc_duoc_link_goc():
90
+ """Token snapcdn là JWT không mã hoá -> bóc ra link CDN gốc để đo/tải thẳng."""
91
+ origin = "https://v3-web.douyinvod.com/abc/video.mp4?a=1&b=2"
92
+ assert webdl._snapcdn_origin(_snapcdn_link(origin)) == origin
93
+ assert webdl._snapcdn_origin("https://khong-phai-token.mp4") == ""
94
+
95
+
96
+ def test_media_re_khong_cat_cut_token_dai():
97
+ """JWT của snapcdn dài >250 ký tự — regex cắt cụt là âm thầm tụt chất lượng."""
98
+ long_token = "https://dl.snapcdn.app/get?token=" + ("a" * 1200)
99
+ got = webdl.MEDIA_RE.search(f'html "{long_token}" html')
100
+ assert got and got.group(0) == long_token
101
+
102
+
103
+ def test_ask_url_for_doi_douyin_sang_trang_chia_se():
104
+ assert webdl._ask_url_for("https://www.douyin.com/video/7123456789012345678") == \
105
+ "https://www.iesdouyin.com/share/video/7123456789012345678/"
106
+ tt = "https://www.tiktok.com/@a/video/123456789012"
107
+ assert webdl._ask_url_for(tt) == tt # tiktok giữ nguyên
108
+
109
+
110
+ def test_download_via_sites_chon_ban_to_nhat():
111
+ """Site trả 2 link; phải ĐO dung lượng thật rồi lấy bản to hơn, không tin nhãn."""
112
+ d = tempfile.mkdtemp()
113
+ small = "https://v3.douyinvod.com/small.mp4"
114
+ big = "https://v3.douyinvod.com/big.mp4"
115
+ big_bytes = _real_mp4(os.path.join(d, "src.mp4"))
116
+ sizes = {small: 1000, big: 999999}
117
+ tai_ve = []
118
+
119
+ def on_post(url, kw):
120
+ # site trả nhãn ngược: "MP4 HD" gán cho link NHỎ (đúng như savetik thật)
121
+ return _Resp(text=f'<h3>Clip thu nghiem</h3> "MP4 HD" {small} ... {big} ' + "x" * 300)
122
+
123
+ def on_get(url, kw):
124
+ if kw.get("headers", {}).get("Range"): # đang ĐO dung lượng
125
+ return _Resp(headers={"Content-Range": f"bytes 0-0/{sizes.get(url, 0)}"})
126
+ tai_ve.append(url)
127
+ return _Resp(content=big_bytes)
128
+
129
+ fake = _FakeRequests(on_post, on_get)
130
+ _install(fake)
131
+ try:
132
+ res = webdl.download_via_sites("https://www.douyin.com/video/7123456789012345678", d)
133
+ finally:
134
+ _restore()
135
+
136
+ assert tai_ve[0] == big, f"phải tải bản to nhất, lại tải {tai_ve[0]}"
137
+ assert res["size"] == len(big_bytes) and res["duration"] > 0
138
+ assert res["title"] == "Clip thu nghiem" and res["via"] == "savetik"
139
+ assert os.path.exists(res["path"]) and not os.path.exists(res["path"] + ".part")
140
+
141
+
142
+ def test_download_via_sites_bo_file_khong_phai_video():
143
+ """Site trả trang HTML/ảnh rác -> phải bỏ, không nhận bừa là video."""
144
+ d = tempfile.mkdtemp()
145
+ link = "https://v3.douyinvod.com/rac.mp4"
146
+
147
+ def on_post(url, kw):
148
+ return _Resp(text=f'"{link}" ' + "x" * 300)
149
+
150
+ def on_get(url, kw):
151
+ if kw.get("headers", {}).get("Range"):
152
+ return _Resp(headers={"Content-Length": "500"})
153
+ return _Resp(content=b"<html>khong phai video</html>")
154
+
155
+ _install(_FakeRequests(on_post, on_get))
156
+ try:
157
+ err = ""
158
+ try:
159
+ webdl.download_via_sites("https://www.douyin.com/video/7123456789012345678", d)
160
+ except RuntimeError as e:
161
+ err = str(e)
162
+ finally:
163
+ _restore()
164
+ assert "không ra video" in err, err
165
+ assert not [f for f in os.listdir(d) if f.endswith((".mp4", ".part"))], os.listdir(d)
166
+
167
+
168
+ def test_di_qua_relay_khi_co_cau_hinh():
169
+ """Có TIKTOK_RELAY_URL -> KHÔNG được gọi thẳng savetik (Space từng bị HF khoá
170
+ vì gọi thẳng domain scraper); mọi request phải đi qua Worker."""
171
+ d = tempfile.mkdtemp()
172
+ link = "https://v3.douyinvod.com/a.mp4"
173
+ mp4 = _real_mp4(os.path.join(d, "src.mp4"))
174
+
175
+ def on_post(url, kw):
176
+ return _Resp(text=f'"{link}" ' + "x" * 300)
177
+
178
+ def on_get(url, kw):
179
+ if kw.get("headers", {}).get("Range"):
180
+ return _Resp(headers={"Content-Range": "bytes 0-0/123"})
181
+ return _Resp(content=mp4)
182
+
183
+ fake = _FakeRequests(on_post, on_get)
184
+ _install(fake)
185
+ old = os.environ.get("TIKTOK_RELAY_URL", "")
186
+ os.environ["TIKTOK_RELAY_URL"] = "https://relay.example.dev"
187
+ try:
188
+ webdl.download_via_sites("https://www.douyin.com/video/7123456789012345678", d)
189
+ finally:
190
+ os.environ["TIKTOK_RELAY_URL"] = old
191
+ _restore()
192
+
193
+ assert all(u.startswith("https://relay.example.dev") for u, _ in fake.posts), fake.posts
194
+ assert all(u.startswith("https://relay.example.dev") for u, _ in fake.gets), fake.gets
195
+ # POST phải kèm ?form=<api site> + ?ref=<home> để Worker gắn đúng Referer
196
+ params = fake.posts[0][1]["params"]
197
+ assert params["form"] == "https://savetik.co/api/ajaxSearch"
198
+ assert params["ref"] == "https://savetik.co/vi"
199
+ # GET media đi qua ?proxy= và có chuyển tiếp Range khi đo dung lượng
200
+ assert fake.gets[0][1]["params"]["proxy"] == link
201
+ assert fake.gets[0][1]["headers"].get("Range") == "bytes=0-0"
202
+
203
+
204
+ def test_giu_nhip_hoi_cung_mot_site():
205
+ """Nhiều luồng cùng hỏi 1 site phải xếp hàng theo `gap`, nếu không là 429 hàng loạt."""
206
+ site = {"name": "test_gap", "home": "https://x.dev", "api": "https://x.dev/a", "gap": 0.4}
207
+ webdl._LAST.pop("test_gap", None)
208
+ t0 = time.time()
209
+ webdl._wait(site)
210
+ webdl._wait(site)
211
+ assert time.time() - t0 >= 0.35
212
+
213
+
214
+ def test_download_many_chay_song_song_va_giu_thu_tu():
215
+ """Tải nhiều link cùng lúc; kết quả vẫn theo đúng thứ tự link vào."""
216
+ calls = []
217
+
218
+ def fake_one(url, out_dir, cookies_file=None):
219
+ calls.append(url)
220
+ time.sleep(0.3) # giả lập chờ mạng
221
+ return ydl.DownloadResult(url=url, ok=True, path=f"/tmp/{url[-1]}.mp4")
222
+
223
+ urls = [f"https://x.dev/v{i}" for i in range(4)]
224
+ orig = ydl.download_one
225
+ ydl.download_one = fake_one
226
+ try:
227
+ t0 = time.time()
228
+ res = ydl.download_many(urls, "/tmp")
229
+ el = time.time() - t0
230
+ finally:
231
+ ydl.download_one = orig
232
+
233
+ assert [r.url for r in res] == urls, "phải giữ nguyên thứ tự link vào"
234
+ assert el < 0.9, f"4 link x 0.3s mà mất {el:.2f}s -> vẫn đang chạy tuần tự"
235
+
236
+
237
+ def test_download_many_mot_link_hong_khong_giet_ca_me():
238
+ def fake_one(url, out_dir, cookies_file=None):
239
+ if url.endswith("2"):
240
+ raise RuntimeError("link nay hong")
241
+ return ydl.DownloadResult(url=url, ok=True)
242
+
243
+ urls = [f"https://x.dev/v{i}" for i in range(4)]
244
+ orig = ydl.download_one
245
+ ydl.download_one = fake_one
246
+ try:
247
+ res = ydl.download_many(urls, "/tmp")
248
+ finally:
249
+ ydl.download_one = orig
250
+
251
+ assert len(res) == 4
252
+ assert [r.ok for r in res] == [True, True, False, True]
253
+ assert "link nay hong" in res[2].error
254
+
255
+
256
+ def test_download_many_bao_tien_do_tung_link():
257
+ seen = []
258
+
259
+ def fake_one(url, out_dir, cookies_file=None):
260
+ return ydl.DownloadResult(url=url, ok=True)
261
+
262
+ orig = ydl.download_one
263
+ ydl.download_one = fake_one
264
+ try:
265
+ ydl.download_many([f"https://x.dev/v{i}" for i in range(3)], "/tmp",
266
+ on_item=lambda i, r: seen.append(i))
267
+ finally:
268
+ ydl.download_one = orig
269
+ assert sorted(seen) == [0, 1, 2]
270
+
271
+
272
+ def test_worker_cu_thi_dung_ngay_va_noi_ro_viec_can_lam():
273
+ """Worker chưa deploy bản mới -> không thử vô ích 4 site x 3 lần, báo thẳng."""
274
+ d = tempfile.mkdtemp()
275
+
276
+ def on_post(url, kw):
277
+ # đúng ph��n hồi của Worker bản cũ khi gặp tham số nó không biết
278
+ return _Resp(text='{"code":-1,"msg":"thiếu tham số ?url= hoặc ?proxy="}', status=400)
279
+
280
+ fake = _FakeRequests(on_post, lambda u, k: _Resp())
281
+ _install(fake)
282
+ old = os.environ.get("TIKTOK_RELAY_URL", "")
283
+ os.environ["TIKTOK_RELAY_URL"] = "https://relay.example.dev"
284
+ err = ""
285
+ t0 = time.time()
286
+ try:
287
+ webdl.download_via_sites("https://www.douyin.com/video/7123456789012345678", d)
288
+ except RuntimeError as e:
289
+ err = str(e)
290
+ finally:
291
+ os.environ["TIKTOK_RELAY_URL"] = old
292
+ _restore()
293
+ el = time.time() - t0
294
+
295
+ assert "?form=" in err and "Deploy" in err, err
296
+ assert len(fake.posts) == 1, f"phải dừng sau site đầu, lại hỏi {len(fake.posts)} lần"
297
+ assert el < 3, f"mất {el:.1f}s — vẫn đang chờ retry vô ích"
298
+
299
+
300
+ def test_ten_file_giai_ma_thuc_the_html():
301
+ """Site trả tiêu đề dạng HTML; tên file không được dính &#x...; hay &amp;."""
302
+ assert "&#x1F60D;" not in webdl._safe_name("Vui&#x1F60D; &amp; khoe", "x")
303
+ assert "&" in webdl._safe_name("Vui &amp; khoe", "x")
304
+ assert webdl._safe_name("", "du_phong") == "du_phong"
305
+ assert "/" not in webdl._safe_name("a/b\c", "x")
deploy/tiktok_relay_worker.js CHANGED
@@ -20,6 +20,8 @@
20
  * <URL>/?url=<link tiktok> -> JSON (xem code, _via)
21
  * <URL>/?url=<link>&only=ssstik -> ép chỉ thử ssstik
22
  * <URL>/?url=<link>&only=tikwm -> ép chỉ thử tikwm
 
 
23
  */
24
 
25
  const UA_MOBILE =
@@ -114,6 +116,7 @@ export default {
114
  const u = new URL(request.url);
115
  const target = u.searchParams.get("url");
116
  const proxy = u.searchParams.get("proxy");
 
117
  const resolve = u.searchParams.get("resolve"); // giải link rút gọn -> URL cuối
118
  const only = u.searchParams.get("only"); // "ssstik" | "tikwm" | null
119
 
@@ -128,21 +131,58 @@ export default {
128
  return jsonResp({ code: 0, url: r.url || resolve });
129
  }
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  // Tải hộ 1 file media (video/ảnh/nhạc) / trang share qua IP sạch.
132
  if (proxy) {
133
  // Referer PHẢI đúng theo nguồn: CDN Douyin (aweme.snssdk/douyinvod...)
134
- // chống hotlink — sai Referer -> 403. Douyin -> douyin.com; còn lại
135
- // (tikcdn/tikwm) -> tikwm.com.
136
- const ref = /douyin|snssdk|iesdouyin/i.test(proxy)
137
  ? "https://www.douyin.com/"
 
 
138
  : "https://www.tikwm.com/";
 
 
 
139
  const r = await fetch(proxy, {
140
- headers: { "User-Agent": UA_MOBILE, "Referer": ref },
141
- });
142
- return new Response(r.body, {
143
- status: r.status,
144
- headers: { ...CORS, "Content-Type": r.headers.get("content-type") || "application/octet-stream" },
145
  });
 
 
 
 
 
 
 
146
  }
147
 
148
  if (target) {
@@ -160,7 +200,7 @@ export default {
160
  return jsonResp({ code: -1, msg: errs.join(" | ") });
161
  }
162
 
163
- return jsonResp({ code: -1, msg: "thiếu tham số ?url= hoặc ?proxy=" }, 400);
164
  } catch (e) {
165
  return jsonResp({ code: -1, msg: String(e) }, 502);
166
  }
 
20
  * <URL>/?url=<link tiktok> -> JSON (xem code, _via)
21
  * <URL>/?url=<link>&only=ssstik -> ép chỉ thử ssstik
22
  * <URL>/?url=<link>&only=tikwm -> ép chỉ thử tikwm
23
+ * <URL>/?form=<api site>&ref=<home> -> POST hộ tới site tải hộ (savetik...)
24
+ * <URL>/?proxy=<url media> -> tải hộ file; chuyển tiếp cả header Range
25
  */
26
 
27
  const UA_MOBILE =
 
116
  const u = new URL(request.url);
117
  const target = u.searchParams.get("url");
118
  const proxy = u.searchParams.get("proxy");
119
+ const form = u.searchParams.get("form"); // POST hộ tới site tải hộ
120
  const resolve = u.searchParams.get("resolve"); // giải link rút gọn -> URL cuối
121
  const only = u.searchParams.get("only"); // "ssstik" | "tikwm" | null
122
 
 
131
  return jsonResp({ code: 0, url: r.url || resolve });
132
  }
133
 
134
+ // POST hộ tới API của các SITE TẢI HỘ (savetik, tiktokio, snapsave,
135
+ // lovetik). Backend không gọi thẳng được vì (a) IP Space bị Cloudflare của
136
+ // họ chặn 403, (b) HF từng khoá Space vì "Suspicious domain call" khi máy
137
+ // chủ gọi thẳng domain scraper. Mấy API này CHỈ nhận POST form-urlencoded
138
+ // (thử GET trả 404), nên phải có endpoint riêng chứ ?proxy= không thay được.
139
+ if (form) {
140
+ const home = u.searchParams.get("ref") || "";
141
+ const origin = home ? new URL(home).origin : "";
142
+ const body = await request.text();
143
+ const r = await fetch(form, {
144
+ method: "POST",
145
+ headers: {
146
+ "User-Agent": UA_DESKTOP,
147
+ "Content-Type": "application/x-www-form-urlencoded",
148
+ "X-Requested-With": "XMLHttpRequest",
149
+ ...(home ? { Referer: home } : {}),
150
+ ...(origin ? { Origin: origin } : {}),
151
+ },
152
+ body,
153
+ });
154
+ return new Response(await r.text(), {
155
+ status: r.status,
156
+ headers: { ...CORS, "Content-Type": r.headers.get("content-type") || "text/plain" },
157
+ });
158
+ }
159
+
160
  // Tải hộ 1 file media (video/ảnh/nhạc) / trang share qua IP sạch.
161
  if (proxy) {
162
  // Referer PHẢI đúng theo nguồn: CDN Douyin (aweme.snssdk/douyinvod...)
163
+ // chống hotlink — sai Referer -> 403. Douyin -> douyin.com; link proxy
164
+ // của site tải hộ -> chính site đó; còn lại (tikcdn/tikwm) -> tikwm.com.
165
+ const ref = /douyin|snssdk|iesdouyin|zjcdn|bytecdn/i.test(proxy)
166
  ? "https://www.douyin.com/"
167
+ : /snapcdn\.app/i.test(proxy)
168
+ ? "https://savetik.co/"
169
  : "https://www.tikwm.com/";
170
+ // Chuyển tiếp Range: backend đo dung lượng thật bằng 'bytes=0-0' để chọn
171
+ // bản nét nhất. Không chuyển tiếp thì mỗi lần đo là tải trọn cả file.
172
+ const range = request.headers.get("Range");
173
  const r = await fetch(proxy, {
174
+ headers: {
175
+ "User-Agent": UA_MOBILE, "Referer": ref,
176
+ ...(range ? { Range: range } : {}),
177
+ },
 
178
  });
179
+ const pass = { ...CORS,
180
+ "Content-Type": r.headers.get("content-type") || "application/octet-stream" };
181
+ for (const h of ["content-length", "content-range", "accept-ranges"]) {
182
+ const v = r.headers.get(h);
183
+ if (v) pass[h] = v;
184
+ }
185
+ return new Response(r.body, { status: r.status, headers: pass });
186
  }
187
 
188
  if (target) {
 
200
  return jsonResp({ code: -1, msg: errs.join(" | ") });
201
  }
202
 
203
+ return jsonResp({ code: -1, msg: "thiếu tham số ?url= / ?proxy= / ?form= / ?resolve=" }, 400);
204
  } catch (e) {
205
  return jsonResp({ code: -1, msg: String(e) }, 502);
206
  }