Spaces:
Running
feat(down list): port đường tải qua site tải hộ từ tool local down_ads
Browse filesBả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 +384 -0
- backend/app/services/downloader/ydl.py +92 -14
- backend/tests/test_webdl.py +305 -0
- deploy/tiktok_relay_worker.js +49 -9
|
@@ -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("&", "&"))
|
| 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 (😍, &) -> 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]))
|
|
@@ -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:
|
|
|
|
|
|
|
|
|
|
| 830 |
if source == "douyin":
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
try:
|
| 835 |
-
return
|
| 836 |
-
except Exception:
|
| 837 |
-
|
| 838 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 893 |
if on_item:
|
| 894 |
-
on_item
|
| 895 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
@@ -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 &."""
|
| 302 |
+
assert "😍" not in webdl._safe_name("Vui😍 & khoe", "x")
|
| 303 |
+
assert "&" in webdl._safe_name("Vui & khoe", "x")
|
| 304 |
+
assert webdl._safe_name("", "du_phong") == "du_phong"
|
| 305 |
+
assert "/" not in webdl._safe_name("a/b\c", "x")
|
|
@@ -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;
|
| 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: {
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 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=
|
| 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 |
}
|