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
+4 -3
View File
@@ -22,7 +22,7 @@
|---|---|---|---| |---|---|---|---|
| openapi | `127.0.0.1:9008` | `source/main.py`(aiohttp) | 聚合层,纯转发,**唯一入口** | | openapi | `127.0.0.1:9008` | `source/main.py`(aiohttp) | 聚合层,纯转发,**唯一入口** |
| pixiv | `127.0.0.1:9007` | `source/pixiv/pixiv.py`(Flask + waitress) | 图片代理 | | pixiv | `127.0.0.1:9007` | `source/pixiv/pixiv.py`(Flask + waitress) | 图片代理 |
| bilibili | `127.0.0.1:9006` | `source/bilibili/bilibili.py`(aiohttp + yt-dlp) | 视频代理 | | bilibili | `127.0.0.1:9006` | `source/bilibili/bilibili.py`(aiohttp,官方 API 直取 + yt-dlp 后备) | 视频代理 |
| dufe 签到 | `127.0.0.1:9005` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`(Flask + waitress 16 线程) | 银杏(DUFE)签到码查询,`GET\|POST /dufe/ginkgostu_sign_in`;`&mode=auto` 时拉取通知列表自动签到(与 bot ginkgo 同逻辑,连续重试,结果列表聚合返回) | | dufe 签到 | `127.0.0.1:9005` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`(Flask + waitress 16 线程) | 银杏(DUFE)签到码查询,`GET\|POST /dufe/ginkgostu_sign_in`;`&mode=auto` 时拉取通知列表自动签到(与 bot ginkgo 同逻辑,连续重试,结果列表聚合返回) |
| jm | `127.0.0.1:9004` | `source/jm/jm.py`(aiohttp + jmcomic) | 漫画下载打包(移植自 qqbot jm 模块),`GET /jm/get_comic?id=xxx[&range=起-止]` | | jm | `127.0.0.1:9004` | `source/jm/jm.py`(aiohttp + jmcomic) | 漫画下载打包(移植自 qqbot jm 模块),`GET /jm/get_comic?id=xxx[&range=起-止]` |
| pixiv-token-refresh | — | `source/pixiv/refresh_token.py` | token 刷新守护(每 30–60 分钟随机) | | pixiv-token-refresh | — | `source/pixiv/refresh_token.py` | token 刷新守护(每 30–60 分钟随机) |
@@ -85,7 +85,8 @@ IMAGE_DIR.mkdir(parents=True, exist_ok=True)
**bilibili(视频)**:以 **BV 号为键**缓存到 `source/cache/bilibili/{BV}.mp4` **bilibili(视频)**:以 **BV 号为键**缓存到 `source/cache/bilibili/{BV}.mp4`
- 命中即直接 `FileResponse`,**不再调用 yt-dlp** - 命中即直接 `FileResponse`,**不再下载**
- 未命中时优先走 **官方 API 直取**(view → playurl → 媒体段;2026-10-22 起 hk 出口被页面 WAF 挑战,yt-dlp 页面提取失效),失败再回退 yt-dlp
- 未命中才下载,且先落到缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后 `os.replace` 原子落盘 - 未命中才下载,且先落到缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后 `os.replace` 原子落盘
- **无论成功 / 失败 / 超时都会 `shutil.rmtree(tmpdir)`** —— 早期实现只清理 cookie 临时文件、从不清理视频暂存目录,导致每次取视频都把完整视频永久留在 `/tmp`(无上限磁盘泄漏),已修复 - **无论成功 / 失败 / 超时都会 `shutil.rmtree(tmpdir)`** —— 早期实现只清理 cookie 临时文件、从不清理视频暂存目录,导致每次取视频都把完整视频永久留在 `/tmp`(无上限磁盘泄漏),已修复
- 淘汰策略:单文件 `CACHE_TTL_SECONDS`(默认 7 天)未访问即过期;总量超过 `CACHE_MAX_BYTES`(默认 2 GiB)时按 mtime 从最旧开始删除 - 淘汰策略:单文件 `CACHE_TTL_SECONDS`(默认 7 天)未访问即过期;总量超过 `CACHE_MAX_BYTES`(默认 2 GiB)时按 mtime 从最旧开始删除
@@ -108,7 +109,7 @@ curl -s 127.0.0.1:9006/bilibili/cache_info
## 部署 ## 部署
1. **Python 环境**:各服务共用一个环境,需含 `flask`、`waitress`、`aiohttp`、`httpx`、`yt-dlp`、`jmcomic`、`pillow` 1. **Python 环境**:各服务共用一个环境,需含 `flask`、`waitress`、`aiohttp`、`httpx`、`yt-dlp`、`jmcomic`、`pillow`;系统包另需 **ffmpeg**(yt-dlp 合流用,API 直取路径不需要)
2. **凭据**: 2. **凭据**:
- 复制 `source/pixiv/tokens.template.json` 为 `source/pixiv/tokens.json`(`chmod 600`)并填入实际 pixiv token - 复制 `source/pixiv/tokens.template.json` 为 `source/pixiv/tokens.json`(`chmod 600`)并填入实际 pixiv token
- 放置 `source/bilibili/cookies.txt`(`name=value` 格式),或运行 `qr_login.py` 扫码生成 - 放置 `source/bilibili/cookies.txt`(`name=value` 格式),或运行 `qr_login.py` 扫码生成
+13 -5
View File
@@ -28,7 +28,7 @@
|---|---|---|---| |---|---|---|---|
| `openapi.service` | `source/main.py` | `:9008` | 聚合层(aiohttp,纯转发,唯一入口) | | `openapi.service` | `source/main.py` | `:9008` | 聚合层(aiohttp,纯转发,唯一入口) |
| `pixiv.service` | `source/pixiv/pixiv.py` | `:9007` | Flask,由 waitress(8 线程)承载 | | `pixiv.service` | `source/pixiv/pixiv.py` | `:9007` | Flask,由 waitress(8 线程)承载 |
| `bilibili.service` | `source/bilibili/bilibili.py` | `:9006` | aiohttp + yt-dlp | | `bilibili.service` | `source/bilibili/bilibili.py` | `:9006` | aiohttp,官方 API 直取 + yt-dlp 后备 |
| `ginkgo_sign_in.service` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py` | `:9005` | 银杏(DUFE)签到码查询,Flask + waitress(16 线程),`GET\|POST` | | `ginkgo_sign_in.service` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py` | `:9005` | 银杏(DUFE)签到码查询,Flask + waitress(16 线程),`GET\|POST` |
| `jm.service` | `source/jm/jm.py` | `:9004` | JM 漫画下载打包,aiohttp + jmcomic | | `jm.service` | `source/jm/jm.py` | `:9004` | JM 漫画下载打包,aiohttp + jmcomic |
| `pixiv-token-refresh.service` | `source/pixiv/refresh_token.py` | — | 常驻刷新 Pixiv OAuth token | | `pixiv-token-refresh.service` | `source/pixiv/refresh_token.py` | — | 常驻刷新 Pixiv OAuth token |
@@ -45,7 +45,7 @@
│ │ ├── refresh_token.py token 刷新守护 │ │ ├── refresh_token.py token 刷新守护
│ │ └── tokens.json Pixiv OAuth token(敏感,权限 600,不入库) │ │ └── tokens.json Pixiv OAuth token(敏感,权限 600,不入库)
│ ├── bilibili/ │ ├── bilibili/
│ │ ├── bilibili.py 视频代理(aiohttp + yt-dlp) │ │ ├── bilibili.py 视频代理(aiohttp,API 直取 + yt-dlp 后备)
│ │ ├── cookies.txt bilibili cookie(敏感,不入库) │ │ ├── cookies.txt bilibili cookie(敏感,不入库)
│ │ ├── refresh_cookie.py cookie 刷新守护 │ │ ├── refresh_cookie.py cookie 刷新守护
│ │ └── qr_login*.py 扫码登录工具(cookie 失效时人工重登用) │ │ └── qr_login*.py 扫码登录工具(cookie 失效时人工重登用)
@@ -74,7 +74,7 @@
**bilibili(视频)**:以 BV 号为键缓存到 `source/cache/bilibili/{BV}.mp4` **bilibili(视频)**:以 BV 号为键缓存到 `source/cache/bilibili/{BV}.mp4`
- 命中即复用、不再下载 - 命中即复用、不再下载
- 未命中才调 yt-dlp,先下载到缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后原子 rename 落盘 - 未命中时优先走 **官方 API 直取**(view → playurl → 媒体段,不受页面 WAF 影响);失败回退 yt-dlp,均写入缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后原子 rename 落盘
- 无论成功 / 失败 / 超时都 `rmtree` 该暂存目录 - 无论成功 / 失败 / 超时都 `rmtree` 该暂存目录
(2026-09-12 修复:原实现从不清理 tmpdir,每次取视频都会把完整视频永久留在 `/tmp`,属无上限磁盘泄漏) (2026-09-12 修复:原实现从不清理 tmpdir,每次取视频都会把完整视频永久留在 `/tmp`,属无上限磁盘泄漏)
- 淘汰策略:单文件 7 天未访问过期 + 总量超过 2 GiB 时按 mtime 从最旧开始删 - 淘汰策略:单文件 7 天未访问过期 + 总量超过 2 GiB 时按 mtime 从最旧开始删
@@ -110,10 +110,10 @@
## 迁移到新服务器 ## 迁移到新服务器
1. **环境**:解压 `env/env_general.tar.gz`,直接使用其 `bin/python`(依赖齐全,含 waitress);若整包过旧需补 `pip install jmcomic` 1. **环境**:解压 `env/env_general.tar.gz`,直接使用其 `bin/python`(依赖齐全,含 waitress);若整包过旧需补 `pip install jmcomic`;另装系统包 `ffmpeg`(yt-dlp 合流)
2. **源码**:恢复 `source/{main.py,pixiv/,bilibili/,dufe/,jm/}` 到 `/home/openapi/source/` 2. **源码**:恢复 `source/{main.py,pixiv/,bilibili/,dufe/,jm/}` 到 `/home/openapi/source/`
3. **systemd**:恢复 `systemd/*.service` 到 `/etc/systemd/system/`,`daemon-reload` + `enable` + `start` 3. **systemd**:恢复 `systemd/*.service` 到 `/etc/systemd/system/`,`daemon-reload` + `enable` + `start`
4. **nginx**:套用 `nginx/` 下三个文件到 `/etc/nginx/conf.d/`:`openapi.rrrosmontis.icu.conf` + `00-websocket.conf`(`$connection_upgrade` map,**必须**)+ `00-limits.conf`(限流 zone,**必须**,否则站点 conf 的 `limit_req zone=perip_req` 会使 `nginx -t` 失败),再 `nginx -t && systemctl reload nginx` 4. **nginx**:先建缓存目录 `mkdir -p /var/cache/nginx`(站点 conf 的 `proxy_cache_path` 需要父目录存在,否则 `nginx -t` 报 mkdir 失败),再套用 `nginx/` 下三个文件到 `/etc/nginx/conf.d/`:`openapi.rrrosmontis.icu.conf` + `00-websocket.conf`(`$connection_upgrade` map,**必须**)+ `00-limits.conf`(限流 zone,**必须**,否则站点 conf 的 `limit_req zone=perip_req` 会使 `nginx -t` 失败),再 `nginx -t && systemctl reload nginx`
5. **SSL**:恢复 `cert/<域名>/` 到 `/etc/nginx/cert/<域名>/` 5. **SSL**:恢复 `cert/<域名>/` 到 `/etc/nginx/cert/<域名>/`
6. **凭据**:放回 `source/pixiv/tokens.json`(600) 与 `source/bilibili/cookies.txt` 6. **凭据**:放回 `source/pixiv/tokens.json`(600) 与 `source/bilibili/cookies.txt`
7. **软链**:`ln -s /home/openapi/source/cache/pixiv/images /home/images` 7. **软链**:`ln -s /home/openapi/source/cache/pixiv/images /home/images`
@@ -137,6 +137,14 @@
## 变更记录 ## 变更记录
**2026-10-22(服务器重装重建 + bilibili 出口 WAF 修复)**
- hk 重装为 Ubuntu 22.04,按本文件从零重建:nginx + acme.sh(Let's Encrypt ECC)+ Python venv + 7 个 systemd 单元 + 凭据恢复;补充恢复时的两个坑位:
- `proxy_cache_path /var/cache/nginx/...` 要求父目录存在 → 迁移步骤补 `mkdir -p /var/cache/nginx`
- `yt-dlp` 合流需要系统包 `ffmpeg`
- **bilibili 修复**:hk 出口访问 `www.bilibili.com` 视频页被 WAF 下发 412/JS 挑战(浏览器 UA 触发,API 客户端 UA 可过),页面无 `__INITIAL_STATE__`,yt-dlp 页面提取必然失败;改为**官方 API 直取**(`x/web-interface/view` → `x/player/playurl` → 媒体段下载),yt-dlp 保留后备;实测公网 200/1.3MB/6.5s
- jm/dufe/pixiv/openapi 重建后实测正常;证书由 acme.sh 自动续期(cron 每 6h 检查)
**2026-09-23(仓库与线上一致性核对 + 完整性补齐)** **2026-09-23(仓库与线上一致性核对 + 完整性补齐)**
- 逐文件 md5 核对:源码/main.py、systemd 全部 7 个单元、ginkgo/jm/bilibili/refresh 各模块、站点 nginx conf 均与仓库一致 - 逐文件 md5 核对:源码/main.py、systemd 全部 7 个单元、ginkgo/jm/bilibili/refresh 各模块、站点 nginx conf 均与仓库一致
+112 -34
View File
@@ -14,6 +14,14 @@ from aiohttp import web
COOKIE_FILE = str(Path(__file__).parent / "cookies.txt") 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" 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) ---------------- # ---------------- 本地缓存(与 pixiv 同级:source/cache/bilibili) ----------------
CACHE_DIR = (Path(__file__).resolve().parent / "../cache/bilibili").resolve() CACHE_DIR = (Path(__file__).resolve().parent / "../cache/bilibili").resolve()
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -144,6 +152,60 @@ def decode_url(url_b64):
return base64.b64decode(url_b64.encode()).decode() 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): async def get_video(url_b64=None, bv=None):
if bv: if bv:
bvid = 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) print(f"[bilibili] cache HIT {bvid} -> {cached.name} ({cached.stat().st_size} B)", flush=True)
return _file_response(cached, bvid) 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) print(f"[bilibili] cache MISS {bvid}, downloading...", flush=True)
# 2) 下载到缓存目录内的隐藏临时目录(同盘,才能原子 rename 落盘) # 2) 下载到缓存目录内的隐藏临时目录(同盘,才能原子 rename 落盘)
tmpdir = tempfile.mkdtemp(prefix=".dl-", dir=CACHE_DIR) tmpdir = tempfile.mkdtemp(prefix=".dl-", dir=CACHE_DIR)
output_template = os.path.join(tmpdir, "video.%(ext)s") cookie_tmp = None
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}",
]
try: try:
proc = await asyncio.create_subprocess_exec( # 2a) 主路径:官方 API 直取(绕开页面 WAF,不依赖 cookie/yt-dlp)
*cmd, video_path = None
stdout=asyncio.subprocess.PIPE, api_err = None
stderr=asyncio.subprocess.PIPE,
)
try: try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300) video_path = await asyncio.to_thread(_download_via_api, bvid, tmpdir)
except asyncio.TimeoutError: print(f"[bilibili] api download OK {bvid}", flush=True)
proc.kill() except Exception as e:
await proc.wait() api_err = e
return web.json_response({"error": "download timeout"}, status=504) 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()] # 2b) 后备:yt-dlp(需要 cookie 文件)
if proc.returncode != 0 and not video_files: if video_path is None:
err_msg = stderr.decode()[-1000:] if stderr else "unknown error" cookie_tmp = _prepare_cookie_file()
return web.json_response( if not cookie_tmp:
{"error": "download failed", "detail": err_msg}, return web.json_response(
status=502, {"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: video_files = [p for p in Path(tmpdir).glob("*") if p.is_file()]
return web.json_response({"error": "no output file"}, status=502) 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() ext = video_path.suffix.lower()
final = CACHE_DIR / f"{bvid}{ext}" final = CACHE_DIR / f"{bvid}{ext}"
try: try: