fix(bilibili): hk 出口 WAF 412 改用官方 API 直取 + 重建文档补齐
- bilibili.py: view -> playurl -> 媒体段 的 API 直取主路径,绕开页面 WAF/JS 挑战;yt-dlp 保留为后备;cookie 仅后备路径需要 - docs: 迁移步骤补 /var/cache/nginx 与 ffmpeg 两个重建坑位 - 实测: 公网 200/1.3MB/6.5s
This commit is contained in:
+112
-34
@@ -14,6 +14,14 @@ from aiohttp import web
|
||||
COOKIE_FILE = str(Path(__file__).parent / "cookies.txt")
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||||
|
||||
# 2026-10-22: hk 出口被 bilibili WAF 下发 412 / JS 挑战页(浏览器 UA 触发),
|
||||
# 页面里没有可用的 __INITIAL_STATE__,yt-dlp 的页面提取必然失败。
|
||||
# 实测 API 客户端 UA(yt-dlp/x、curl/x)可通过,故主路径改为官方 API 直取;
|
||||
# yt-dlp 保留为后备(其他出口/未来 WAF 策略变化时仍可工作)。
|
||||
API_UA = "yt-dlp/2026.10.21"
|
||||
VIEW_API = "https://api.bilibili.com/x/web-interface/view"
|
||||
PLAYURL_API = "https://api.bilibili.com/x/player/playurl"
|
||||
|
||||
# ---------------- 本地缓存(与 pixiv 同级:source/cache/bilibili) ----------------
|
||||
CACHE_DIR = (Path(__file__).resolve().parent / "../cache/bilibili").resolve()
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -144,6 +152,60 @@ def decode_url(url_b64):
|
||||
return base64.b64decode(url_b64.encode()).decode()
|
||||
|
||||
|
||||
def _api_headers():
|
||||
return {"User-Agent": API_UA, "Referer": "https://www.bilibili.com/"}
|
||||
|
||||
|
||||
def _api_get(url, params, what="bilibili api"):
|
||||
import requests
|
||||
r = requests.get(url, params=params, headers=_api_headers(), timeout=30)
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
if not isinstance(j, dict) or j.get("code") != 0:
|
||||
raise RuntimeError("%s failed: code=%s message=%s" % (
|
||||
what, (j or {}).get("code"), (j or {}).get("message")))
|
||||
return j.get("data") or {}
|
||||
|
||||
|
||||
def _download_via_api(bvid, work_dir):
|
||||
"""官方 API 直取(view -> playurl -> 媒体段),返回落盘路径。
|
||||
|
||||
hk 出口被页面 WAF 挑战时 yt-dlp 无法工作,此路径只走 api.bilibili.com
|
||||
与媒体 CDN,实测不受该挑战影响。"""
|
||||
import requests
|
||||
|
||||
data = _api_get(VIEW_API, {"bvid": bvid}, "view")
|
||||
cid = data.get("cid")
|
||||
if not cid:
|
||||
raise RuntimeError("view api returned no cid")
|
||||
play = _api_get(PLAYURL_API, {
|
||||
"bvid": bvid, "cid": cid, "qn": 80, "fnval": 0, "fourk": 1,
|
||||
}, "playurl")
|
||||
durls = play.get("durl") or []
|
||||
if not durls:
|
||||
raise RuntimeError("playurl api returned no durl segments")
|
||||
|
||||
out = Path(work_dir) / f"{bvid}.mp4"
|
||||
try:
|
||||
with open(out, "wb") as f:
|
||||
for seg in durls:
|
||||
with requests.get(seg["url"], headers=_api_headers(),
|
||||
stream=True, timeout=120) as r:
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_content(chunk_size=256 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
if out.stat().st_size == 0:
|
||||
raise RuntimeError("empty media file")
|
||||
return out
|
||||
except Exception:
|
||||
try:
|
||||
out.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
async def get_video(url_b64=None, bv=None):
|
||||
if bv:
|
||||
bvid = bv
|
||||
@@ -170,49 +232,65 @@ async def get_video(url_b64=None, bv=None):
|
||||
print(f"[bilibili] cache HIT {bvid} -> {cached.name} ({cached.stat().st_size} B)", flush=True)
|
||||
return _file_response(cached, bvid)
|
||||
|
||||
cookie_tmp = _prepare_cookie_file()
|
||||
if not cookie_tmp:
|
||||
return web.json_response({"error": "missing bilibili cookie"}, status=500)
|
||||
|
||||
print(f"[bilibili] cache MISS {bvid}, downloading...", flush=True)
|
||||
# 2) 下载到缓存目录内的隐藏临时目录(同盘,才能原子 rename 落盘)
|
||||
tmpdir = tempfile.mkdtemp(prefix=".dl-", dir=CACHE_DIR)
|
||||
output_template = os.path.join(tmpdir, "video.%(ext)s")
|
||||
cmd = [
|
||||
os.path.join(os.path.dirname(sys.executable), "yt-dlp"),
|
||||
"--cookies", cookie_tmp,
|
||||
"--add-header", f"User-Agent:{UA}",
|
||||
"--add-header", "Referer:https://www.bilibili.com/",
|
||||
"-f", "bestvideo*+bestaudio/best",
|
||||
"--merge-output-format", "mp4",
|
||||
"-o", output_template,
|
||||
f"https://www.bilibili.com/video/{bvid}",
|
||||
]
|
||||
cookie_tmp = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# 2a) 主路径:官方 API 直取(绕开页面 WAF,不依赖 cookie/yt-dlp)
|
||||
video_path = None
|
||||
api_err = None
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return web.json_response({"error": "download timeout"}, status=504)
|
||||
video_path = await asyncio.to_thread(_download_via_api, bvid, tmpdir)
|
||||
print(f"[bilibili] api download OK {bvid}", flush=True)
|
||||
except Exception as e:
|
||||
api_err = e
|
||||
print(f"[bilibili] api download failed {bvid}: {e}; fallback yt-dlp", flush=True)
|
||||
|
||||
video_files = [p for p in Path(tmpdir).glob("*") if p.is_file()]
|
||||
if proc.returncode != 0 and not video_files:
|
||||
err_msg = stderr.decode()[-1000:] if stderr else "unknown error"
|
||||
return web.json_response(
|
||||
{"error": "download failed", "detail": err_msg},
|
||||
status=502,
|
||||
# 2b) 后备:yt-dlp(需要 cookie 文件)
|
||||
if video_path is None:
|
||||
cookie_tmp = _prepare_cookie_file()
|
||||
if not cookie_tmp:
|
||||
return web.json_response(
|
||||
{"error": "download failed", "detail": "api path failed and no cookie for yt-dlp fallback: %s" % api_err},
|
||||
status=502,
|
||||
)
|
||||
output_template = os.path.join(tmpdir, "video.%(ext)s")
|
||||
cmd = [
|
||||
os.path.join(os.path.dirname(sys.executable), "yt-dlp"),
|
||||
"--cookies", cookie_tmp,
|
||||
"--add-header", f"User-Agent:{UA}",
|
||||
"--add-header", "Referer:https://www.bilibili.com/",
|
||||
"-f", "bestvideo*+bestaudio/best",
|
||||
"--merge-output-format", "mp4",
|
||||
"-o", output_template,
|
||||
f"https://www.bilibili.com/video/{bvid}",
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return web.json_response({"error": "download timeout"}, status=504)
|
||||
|
||||
if not video_files:
|
||||
return web.json_response({"error": "no output file"}, status=502)
|
||||
video_files = [p for p in Path(tmpdir).glob("*") if p.is_file()]
|
||||
if proc.returncode != 0 and not video_files:
|
||||
err_msg = stderr.decode()[-1000:] if stderr else "unknown error"
|
||||
return web.json_response(
|
||||
{"error": "download failed", "detail": "api: %s | yt-dlp: %s" % (api_err, err_msg)},
|
||||
status=502,
|
||||
)
|
||||
|
||||
if not video_files:
|
||||
return web.json_response({"error": "no output file"}, status=502)
|
||||
|
||||
video_path = max(video_files, key=lambda p: p.stat().st_size)
|
||||
|
||||
video_path = max(video_files, key=lambda p: p.stat().st_size)
|
||||
ext = video_path.suffix.lower()
|
||||
final = CACHE_DIR / f"{bvid}{ext}"
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user