Files
openapi/source/bilibili/bilibili.py
T
RRRosmontis c6908b5441 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
2026-10-22 17:28:12 +08:00

353 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import sys
import base64
import os
import re
import shutil
import tempfile
import time
from pathlib import Path
import httpx
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)
CACHE_MAX_BYTES = 2 * 1024 ** 3 # 缓存总量上限 2 GiB
CACHE_TTL_SECONDS = 7 * 24 * 3600 # 7 天未访问即淘汰
VIDEO_EXTS = (".mp4", ".flv", ".mkv", ".webm")
CONTENT_TYPES = {
".mp4": "video/mp4",
".flv": "video/x-flv",
".mkv": "video/x-matroska",
".webm": "video/webm",
}
def _prepare_cookie_file():
"""Convert simple name=value cookies.txt to Netscape format for yt-dlp."""
try:
with open(COOKIE_FILE) as f:
cookies = [line.strip() for line in f if line.strip() and not line.startswith("#")]
except FileNotFoundError:
return None
if not cookies:
return None
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", prefix="bilibili_cookie_", delete=False
)
tmp.write("# Netscape HTTP Cookie File\n")
for c in cookies:
if "=" not in c:
continue
name, value = c.split("=", 1)
# Netscape format: domain flag path secure expires name value
tmp.write(f".bilibili.com\tTRUE\t/\tFALSE\t0\t{name}\t{value}\n")
tmp.close()
return tmp.name
def _cache_file(bvid):
"""缓存命中则返回文件路径(同时刷新 mtime 作为 LRU 依据),否则 None。"""
for f in CACHE_DIR.glob(f"{bvid}.*"):
if f.is_file() and f.suffix.lower() in VIDEO_EXTS:
try:
os.utime(f, None)
except OSError:
pass
return f
return None
def _cache_files():
out = []
for f in CACHE_DIR.iterdir():
if f.is_file() and f.suffix.lower() in VIDEO_EXTS:
out.append(f)
return out
def _cache_sweep():
"""先按 TTL 淘汰过期文件,再按总量上限从最旧(mtime 最小)开始淘汰。"""
now = time.time()
for f in _cache_files():
try:
if now - f.stat().st_mtime > CACHE_TTL_SECONDS:
f.unlink()
except OSError:
pass
files = _cache_files()
total = 0
for f in files:
try:
total += f.stat().st_size
except OSError:
pass
if total > CACHE_MAX_BYTES:
for f in sorted(files, key=lambda p: p.stat().st_mtime):
if total <= CACHE_MAX_BYTES:
break
try:
size = f.stat().st_size
f.unlink()
total -= size
except OSError:
pass
def _file_response(path, bvid):
return web.FileResponse(
path=path,
headers={
"Content-Type": CONTENT_TYPES.get(path.suffix.lower(), "application/octet-stream"),
"Content-Disposition": f'attachment; filename="{path.name}"',
},
)
async def resolve_bvid(link):
# 直接是 BV 号
m = re.search(r"(BV[a-zA-Z0-9]{10})", link)
if m:
return m.group(1)
# b23.tv 短链接,跟随跳转
if "b23.tv" in link:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
link.strip(),
follow_redirects=False,
headers={"User-Agent": UA},
)
location = resp.headers.get("Location", "")
if m := re.search(r"(BV[a-zA-Z0-9]{10})", location):
return m.group(1)
if m := re.search(r"(BV[a-zA-Z0-9]{10})", resp.text):
return m.group(1)
# 普通 bilibili.com URL
if m := re.search(r"(BV[a-zA-Z0-9]{10})", link):
return m.group(1)
raise ValueError(f"unable to resolve BV from: {link}")
def decode_url(url_b64):
try:
return base64.urlsafe_b64decode(url_b64.encode()).decode()
except Exception:
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
else:
try:
url = decode_url(url_b64)
except Exception:
return web.json_response({"error": "invalid base64 encoding"}, status=400)
if not url:
return web.json_response({"error": "empty url"}, status=400)
if "bilibili.com" not in url and "b23.tv" not in url and not url.startswith("BV"):
return web.json_response({"error": "not a bilibili url"}, status=400)
try:
bvid = await resolve_bvid(url)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
# 1) 缓存命中:直接回文件,不再调用 yt-dlp
cached = _cache_file(bvid)
if cached:
print(f"[bilibili] cache HIT {bvid} -> {cached.name} ({cached.stat().st_size} B)", flush=True)
return _file_response(cached, bvid)
print(f"[bilibili] cache MISS {bvid}, downloading...", flush=True)
# 2) 下载到缓存目录内的隐藏临时目录(同盘,才能原子 rename 落盘)
tmpdir = tempfile.mkdtemp(prefix=".dl-", dir=CACHE_DIR)
cookie_tmp = None
try:
# 2a) 主路径:官方 API 直取(绕开页面 WAF,不依赖 cookie/yt-dlp)
video_path = None
api_err = None
try:
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)
# 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)
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)
ext = video_path.suffix.lower()
final = CACHE_DIR / f"{bvid}{ext}"
try:
os.replace(video_path, final) # 同盘原子落盘
except OSError:
shutil.move(str(video_path), str(final))
_cache_sweep()
print(f"[bilibili] cache STORE {bvid} -> {final.name} ({final.stat().st_size} B)", flush=True)
return _file_response(final, bvid)
except Exception as e:
print(f"[bilibili] Error: {e}")
return web.json_response({"error": str(e)}, status=500)
finally:
# 关键修复:无论成功/失败/超时都回收临时目录(原实现会把整个视频永久留在 /tmp)
shutil.rmtree(tmpdir, ignore_errors=True)
if cookie_tmp and os.path.exists(cookie_tmp):
os.unlink(cookie_tmp)
async def handle_bilibili(request):
bv = request.query.get("bv", "")
if bv:
return await get_video(bv=bv)
url_b64 = request.query.get("url", "")
if not url_b64:
return web.json_response({"error": "missing url or bv parameter"}, status=400)
return await get_video(url_b64=url_b64)
async def handle_cache_info(request):
files = []
total = 0
for f in _cache_files():
try:
st = f.stat()
except OSError:
continue
files.append({"name": f.name, "size": st.st_size, "mtime": int(st.st_mtime)})
total += st.st_size
return web.json_response(
{
"cache_dir": str(CACHE_DIR),
"cache_max_bytes": CACHE_MAX_BYTES,
"cache_ttl_seconds": CACHE_TTL_SECONDS,
"count": len(files),
"total_bytes": total,
"free_bytes": shutil.disk_usage(CACHE_DIR).free,
"files": sorted(files, key=lambda x: -x["size"]),
}
)
app = web.Application()
app.router.add_get("/bilibili/get_video", handle_bilibili)
app.router.add_get("/bilibili/cache_info", handle_cache_info)
if __name__ == "__main__":
print(f"Cache directory: {CACHE_DIR}")
web.run_app(app, host="127.0.0.1", port=9006)