diff --git a/README.md b/README.md index 0565b69..2c59530 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ |---|---|---|---| | openapi | `127.0.0.1:9008` | `source/main.py`(aiohttp) | 聚合层,纯转发,**唯一入口** | | 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 同逻辑,连续重试,结果列表聚合返回) | | 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 分钟随机) | @@ -85,7 +85,8 @@ IMAGE_DIR.mkdir(parents=True, exist_ok=True) **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` 原子落盘 - **无论成功 / 失败 / 超时都会 `shutil.rmtree(tmpdir)`** —— 早期实现只清理 cookie 临时文件、从不清理视频暂存目录,导致每次取视频都把完整视频永久留在 `/tmp`(无上限磁盘泄漏),已修复 - 淘汰策略:单文件 `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. **凭据**: - 复制 `source/pixiv/tokens.template.json` 为 `source/pixiv/tokens.json`(`chmod 600`)并填入实际 pixiv token - 放置 `source/bilibili/cookies.txt`(`name=value` 格式),或运行 `qr_login.py` 扫码生成 diff --git a/docs/部署与运维.md b/docs/部署与运维.md index 7e91e05..a283a11 100644 --- a/docs/部署与运维.md +++ b/docs/部署与运维.md @@ -28,7 +28,7 @@ |---|---|---|---| | `openapi.service` | `source/main.py` | `:9008` | 聚合层(aiohttp,纯转发,唯一入口) | | `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` | | `jm.service` | `source/jm/jm.py` | `:9004` | JM 漫画下载打包,aiohttp + jmcomic | | `pixiv-token-refresh.service` | `source/pixiv/refresh_token.py` | — | 常驻刷新 Pixiv OAuth token | @@ -45,7 +45,7 @@ │ │ ├── refresh_token.py token 刷新守护 │ │ └── tokens.json Pixiv OAuth token(敏感,权限 600,不入库) │ ├── bilibili/ -│ │ ├── bilibili.py 视频代理(aiohttp + yt-dlp) +│ │ ├── bilibili.py 视频代理(aiohttp,API 直取 + yt-dlp 后备) │ │ ├── cookies.txt bilibili cookie(敏感,不入库) │ │ ├── refresh_cookie.py cookie 刷新守护 │ │ └── qr_login*.py 扫码登录工具(cookie 失效时人工重登用) @@ -74,7 +74,7 @@ **bilibili(视频)**:以 BV 号为键缓存到 `source/cache/bilibili/{BV}.mp4` - 命中即复用、不再下载 -- 未命中才调 yt-dlp,先下载到缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后原子 rename 落盘 +- 未命中时优先走 **官方 API 直取**(view → playurl → 媒体段,不受页面 WAF 影响);失败回退 yt-dlp,均写入缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后原子 rename 落盘 - 无论成功 / 失败 / 超时都 `rmtree` 该暂存目录 (2026-09-12 修复:原实现从不清理 tmpdir,每次取视频都会把完整视频永久留在 `/tmp`,属无上限磁盘泄漏) - 淘汰策略:单文件 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/` 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/<域名>/` 6. **凭据**:放回 `source/pixiv/tokens.json`(600) 与 `source/bilibili/cookies.txt` 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(仓库与线上一致性核对 + 完整性补齐)** - 逐文件 md5 核对:源码/main.py、systemd 全部 7 个单元、ginkgo/jm/bilibili/refresh 各模块、站点 nginx conf 均与仓库一致 diff --git a/source/bilibili/bilibili.py b/source/bilibili/bilibili.py index 66471dd..e790d29 100644 --- a/source/bilibili/bilibili.py +++ b/source/bilibili/bilibili.py @@ -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: