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:
RRRosmontis
2026-10-22 17:28:12 +08:00
parent 4dee7a3c0c
commit c6908b5441
3 changed files with 129 additions and 42 deletions
+112 -34
View File
@@ -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: