openapi 源码 + token 模板 + nginx 统一入口配置

This commit is contained in:
RRRosmontis
2026-08-27 21:31:10 +08:00
commit 00f8678d75
9 changed files with 801 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
import asyncio
import sys
import base64
import os
import re
import tempfile
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"
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
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()
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)
cookie_tmp = _prepare_cookie_file()
if not cookie_tmp:
return web.json_response({"error": "missing bilibili cookie"}, status=500)
tmpdir = tempfile.mkdtemp(prefix="bilibili_")
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}",
]
try:
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)
finally:
if cookie_tmp and os.path.exists(cookie_tmp):
os.unlink(cookie_tmp)
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,
)
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()
content_types = {
".mp4": "video/mp4",
".flv": "video/x-flv",
".mkv": "video/x-matroska",
".webm": "video/webm",
}
content_type = content_types.get(ext, "application/octet-stream")
return web.FileResponse(
path=video_path,
headers={
"Content-Type": content_type,
"Content-Disposition": f'attachment; filename="{video_path.name}"',
},
)
except Exception as e:
print(f"[bilibili] Error: {e}")
return web.json_response({"error": str(e)}, status=500)
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)
app = web.Application()
app.router.add_get("/bilibili/get_video", handle_bilibili)
if __name__ == "__main__":
web.run_app(app, host="127.0.0.1", port=19990)
+58
View File
@@ -0,0 +1,58 @@
import asyncio
import sys
import time
from pathlib import Path
from bilibili_api.login_v2 import QrCodeLogin, QrCodeLoginEvents
QR_PATH = Path("/home/bilibili/qrcode.png")
COOKIE_FILE = Path("/home/bilibili/cookies.txt")
CF_QR_PATH = "/mnt/E/temp/bilibili_qrcode.png"
async def main():
login = QrCodeLogin()
await login.generate_qrcode()
pic = login.get_qrcode_picture()
QR_PATH.write_bytes(pic.content)
print(f"QR saved to {QR_PATH}")
print("Please scan the QR code with Bilibili App")
# copy to cf
import subprocess
try:
subprocess.run([
"sshpass", "-p", "ftA406u@all#2309",
"scp", "-P", "10526", str(QR_PATH), "root@rrrosmontis.icu:/mnt/E/temp/bilibili_qrcode.png"
], check=True, timeout=30)
print(f"QR copied to cf:{CF_QR_PATH}")
except Exception as e:
print(f"Failed to copy QR to cf: {e}")
# wait up to 5 minutes
for i in range(60):
await asyncio.sleep(5)
state = await login.check_state()
print(f"[{i*5}s] state: {state}")
if state == QrCodeLoginEvents.DONE:
cred = login.get_credential()
lines = []
if cred.sessdata:
lines.append(f"SESSDATA={cred.sessdata}")
if cred.bili_jct:
lines.append(f"bili_jct={cred.bili_jct}")
if cred.buvid3:
lines.append(f"buvid3={cred.buvid3}")
if cred.dedeuserid:
lines.append(f"dedeuserid={cred.dedeuserid}")
if cred.ac_time_value:
lines.append(f"ac_time_value={cred.ac_time_value}")
COOKIE_FILE.write_text("\n".join(lines) + "\n")
print(f"Login success. Cookie saved to {COOKIE_FILE}")
print(f"ac_time_value present: {bool(cred.ac_time_value)}")
return
elif state == QrCodeLoginEvents.TIMEOUT:
print("QR code timeout")
sys.exit(1)
print("QR login timeout after 5 minutes")
sys.exit(1)
asyncio.run(main())
+52
View File
@@ -0,0 +1,52 @@
import asyncio
import sys
import subprocess
from pathlib import Path
from bilibili_api.login_v2 import QrCodeLogin, QrCodeLoginEvents, QrCodeLoginChannel
QR_PATH = Path("/home/bilibili/qrcode.png")
COOKIE_FILE = Path("/home/bilibili/cookies.txt")
CF_QR_PATH = "root@rrrosmontis.icu:/mnt/E/temp/bilibili_qrcode.png"
async def main():
# try TV channel which may have longer expiration
login = QrCodeLogin(platform=QrCodeLoginChannel.TV)
await login.generate_qrcode()
pic = login.get_qrcode_picture()
QR_PATH.write_bytes(pic.content)
print(f"QR regenerated: {QR_PATH}")
# sync to cf immediately
try:
subprocess.run([
"sshpass", "-p", "ftA406u@all#2309",
"scp", "-P", "10526", str(QR_PATH), CF_QR_PATH
], check=True, timeout=10)
print("QR synced to cf")
except Exception as e:
print(f"Sync failed: {e}")
print("Scan now! Polling every 3s for 3 minutes...")
for i in range(60):
await asyncio.sleep(3)
state = await login.check_state()
print(f"[{i*3}s] {state}")
if state == QrCodeLoginEvents.DONE:
cred = login.get_credential()
lines = []
if cred.sessdata: lines.append(f"SESSDATA={cred.sessdata}")
if cred.bili_jct: lines.append(f"bili_jct={cred.bili_jct}")
if cred.buvid3: lines.append(f"buvid3={cred.buvid3}")
if cred.dedeuserid: lines.append(f"dedeuserid={cred.dedeuserid}")
if cred.ac_time_value: lines.append(f"ac_time_value={cred.ac_time_value}")
COOKIE_FILE.write_text("\n".join(lines) + "\n")
print("LOGIN SUCCESS")
print(f"ac_time_value: {bool(cred.ac_time_value)}")
return
elif state == QrCodeLoginEvents.TIMEOUT:
print("QR TIMEOUT")
sys.exit(1)
print("POLLING TIMEOUT")
sys.exit(1)
asyncio.run(main())
+113
View File
@@ -0,0 +1,113 @@
import asyncio
import json
import logging
import random
import sys
from pathlib import Path
from datetime import datetime
from bilibili_api import Credential, sync
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
COOKIE_FILE = Path("cookies.txt")
def load_credential() -> Credential:
"""从 KEY=VALUE 格式的 txt 文件加载 Credential 对象"""
if not COOKIE_FILE.exists():
logger.error("Cookie file not found: %s", COOKIE_FILE)
sys.exit(1)
try:
data = {}
with open(COOKIE_FILE, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
if "=" in line:
key, _, value = line.partition("=")
data[key.strip()] = value.strip()
# 构建 Credential 对象(key 名大小写不敏感)
cred = Credential(
sessdata=data.get("SESSDATA") or data.get("sessdata"),
bili_jct=data.get("bili_jct") or data.get("BILI_JCT"),
buvid3=data.get("buvid3") or data.get("BUVID3"),
dedeuserid=data.get("dedeuserid") or data.get("DEDEUSERID"),
ac_time_value=data.get("ac_time_value") or data.get("AC_TIME_VALUE"),
)
logger.info("Credential loaded from %s", COOKIE_FILE)
return cred
except Exception as e:
logger.error("Failed to load credential: %s", e)
sys.exit(1)
async def check_and_refresh(cred: Credential) -> bool:
"""检查有效性,如果有效则刷新 token。返回是否成功刷新(或仍有效)"""
try:
valid = await cred.check_valid()
if not valid:
logger.warning("Credential is invalid (maybe expired). Refresh may fail.")
else:
logger.info("Credential is valid, proceeding to refresh...")
# 无论 valid 状态如何,都尝试 refresh(refresh 接口本身会处理)
await cred.refresh()
logger.info("Token refresh successful. New SESSDATA: %s...", cred.sessdata[:20])
# 保存更新后的凭证到文件
save_credential(cred)
return True
except Exception as e:
logger.error("Refresh failed: %s", e)
return False
def save_credential(cred: Credential):
"""将 credential 的关键字段保存回 txt 文件(KEY=VALUE 格式)"""
lines = []
if cred.sessdata:
lines.append(f"SESSDATA={cred.sessdata}")
if cred.bili_jct:
lines.append(f"bili_jct={cred.bili_jct}")
if cred.buvid3:
lines.append(f"buvid3={cred.buvid3}")
if cred.dedeuserid:
lines.append(f"dedeuserid={cred.dedeuserid}")
if cred.ac_time_value:
lines.append(f"ac_time_value={cred.ac_time_value}")
with open(COOKIE_FILE, "w") as f:
f.write("\n".join(lines) + "\n")
logger.info("Credential saved to %s", COOKIE_FILE)
async def main_loop():
logger.info("Starting token refresh daemon...")
cred = load_credential()
while True:
# 随机间隔 50~70 分钟(单位:秒)
interval = random.randint(50 * 60, 70 * 60)
next_refresh = datetime.now().timestamp() + interval
logger.info("Next refresh in %d minutes (at %s)", interval // 60, datetime.fromtimestamp(next_refresh).strftime("%Y-%m-%d %H:%M:%S"))
await asyncio.sleep(interval)
logger.info("Refreshing token...")
success = await check_and_refresh(cred)
if not success:
logger.error("Refresh failed, will retry in next cycle.")
else:
logger.info("Refresh cycle completed.")
if __name__ == "__main__":
try:
asyncio.run(main_loop())
except KeyboardInterrupt:
logger.info("Daemon stopped by user.")
except Exception as e:
logger.exception("Unexpected error: %s", e)
sys.exit(1)