"""Bilibili 公共请求助手(对应 sdk/bili/util.ts,上游 music-lib/bilibili 的共享逻辑)。
与上游的差异(2026-10-02 实测):
1) 上游硬编码的默认 cookie(buvid3=...; SESSDATA=your_sessdata;)当前会被 B 站风控:
带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。
因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的
buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。
2) 请求层不跟随 302,取流的手动跟跳在 ``app/sdk/apis/bili.py`` 内处理。
3) 登录凭据来自 ``app/sdk/auth_store.py`` 的 bili 记录(扫码登录写入)。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0"
)
REFERER = "https://www.bilibili.com/"
HOME_URL = "https://www.bilibili.com/"
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def bili_headers(cookie: str = "") -> dict[str, str]:
"""上游 UserAgent/Referer 组合(所有接口统一携带)。"""
headers = {
"User-Agent": UA,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
if cookie:
headers["Cookie"] = cookie
return headers
def get_login_cookie() -> str:
"""已保存的登录 Cookie(未登录返回空串)。"""
cred = get_credentials("bili") or {}
return str(cred.get("cookie") or "")
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
async def _fetch_home_cookies() -> str:
"""首页下发的新鲜 Cookie(http_fetch 保留了原始多值 Set-Cookie,同名头不会互相覆盖)。"""
resp = await http_fetch(HOME_URL, headers=bili_headers(), timeout=15_000)
jar = _cookie_map(resp.set_cookies)
return "; ".join(f"{key}={value}" for key, value in jar.items())
_anon_cookie = ""
_anon_lock = asyncio.Lock()
async def get_cookie() -> str:
"""请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘)。"""
saved = get_login_cookie()
if saved:
return saved
global _anon_cookie
if _anon_cookie:
return _anon_cookie
async with _anon_lock:
if _anon_cookie:
return _anon_cookie
try:
_anon_cookie = await _fetch_home_cookies()
except Exception: # noqa: BLE001 - 拿不到就用无 Cookie 请求(实测同样可用)
_anon_cookie = ""
return _anon_cookie
async def api_get(url: str, cookie: str | None = None) -> HttpResponse:
"""带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名)。"""
ck = await get_cookie() if cookie is None else cookie
return await http_fetch(url, headers=bili_headers(ck), timeout=15_000)
def normalize_cover(cover: Any) -> str:
"""封面协议补全(上游 normalizeCover)。"""
text = str(cover or "")
if not text:
return ""
return f"https:{text}" if text.startswith("//") else text
def clean_title(title: Any) -> str:
"""搜索标题去高亮标签(上游 cleanTitle)。"""
if not title:
return ""
return re.sub(r'|', "", str(title))
def parse_song_id(songmid: Any) -> dict[str, str]:
"""songmid 约定:``|``(与上游 ID 格式一致)。"""
raw = str(songmid if songmid is not None else "")
idx = raw.find("|")
if idx < 0:
return {"bvid": raw, "cid": ""}
return {"bvid": raw[:idx], "cid": raw[idx + 1 :]}
def extract_bvid(link: str) -> str:
"""从链接/id 提取 BV 号(兼容小写 bv)。"""
match = re.search(r"BV[0-9A-Za-z]+", str(link or ""), re.IGNORECASE)
return match.group(0) if match else ""
def extract_page(link: str) -> int:
"""从链接提取分P(上游 ``[?&]p=(\\d+)``)。"""
match = re.search(r"[?&]p=(\d+)", str(link or ""))
page = int(match.group(1)) if match else 0
return page if page > 0 else 1
def extract_aid(link: str) -> str:
"""从链接提取 av 号(上游未支持,任务要求兼容)。"""
match = re.search(r"\bav(\d+)\b", str(link or ""), re.IGNORECASE)
return match.group(1) if match else ""
def to_number(value: Any) -> float:
"""JS 的 Number(value):空值/非数字一律 0。"""
try:
return float(value)
except (TypeError, ValueError):
return 0.0
async def map_limit(items: list[Any], limit: int, fn: Any) -> list[Any]:
"""并发映射(限制并发数,单条失败返回 None)。"""
source = list(items or [])
out: list[Any] = [None] * len(source)
if not source:
return out
size = max(1, min(int(limit or 1), len(source)))
cursor = 0
async def worker() -> None:
nonlocal cursor
while True:
index = cursor
cursor += 1
if index >= len(source):
return
try:
out[index] = await fn(source[index], index)
except Exception: # noqa: BLE001 - 单条失败按上游语义跳过
out[index] = None
await asyncio.gather(*(worker() for _ in range(size)))
return out
def make_song(
*,
bvid: str,
cid: Any,
name: str = "",
artist: str = "",
album: str = "",
duration: Any = 0,
cover: str = "",
page: int = 1,
) -> dict[str, Any]:
"""统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid)。"""
seconds = to_number(duration)
link = f"https://www.bilibili.com/video/{bvid}" + (f"?p={page}" if page > 1 else "")
return {
"songmid": f"{bvid}|{cid}",
"name": name or "",
"singer": artist or "",
"albumId": bvid or "",
"albumName": album or bvid or "",
"interval": format_play_time(seconds) if seconds else 0,
"img": normalize_cover(cover) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "bili",
"bvid": bvid,
"cid": str(cid if cid is not None else ""),
"page": page,
"link": link,
}
def assert_api_ok(what: str, status_code: int, body: Any) -> None:
"""B 站 API 统一校验:HTTP 200 且业务码 code=0,否则抛带上下文的错误。"""
if status_code != 200 or not isinstance(body, dict) or body.get("code") != 0:
code = body.get("code") if isinstance(body, dict) else None
message = body.get("message") if isinstance(body, dict) else None
reason = code if code is not None else f"HTTP {status_code}"
raise RuntimeError(f"B站{what}获取失败({reason}{f': {message}' if message else ''})")
async def fetch_view(video_id: Any) -> dict[str, Any]:
"""视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点。"""
raw = str(video_id or "").strip()
if not raw:
raise RuntimeError("B站视频 id 为空")
if re.match(r"^BV", raw, re.IGNORECASE):
query = f"bvid={encode_uri_component(raw)}"
else:
query = f"aid={encode_uri_component(re.sub('^av', '', raw, flags=re.IGNORECASE))}"
resp = await api_get(f"https://api.bilibili.com/x/web-interface/view?{query}")
assert_api_ok("视频信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
if not isinstance(data, dict):
raise RuntimeError("B站视频信息获取失败(data 为空)")
return data
async def fetch_page_list(bvid: str) -> list[Any]:
"""分P列表(x/player/pagelist),返回 data 数组。"""
resp = await api_get(f"https://api.bilibili.com/x/player/pagelist?bvid={encode_uri_component(bvid)}")
assert_api_ok("分P列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
return data if isinstance(data, list) else []
def _season_page_url(mid: Any, season_id: Any, page_num: int, page_size: int) -> str:
return (
"https://api.bilibili.com/x/space/ugc/season?"
f"mid={mid}&season_id={season_id}&page_num={page_num}&page_size={page_size}"
)
async def fetch_season_archive_index(mid: Any, season_id: Any) -> dict[str, Any]:
"""合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex)。
该接口现已 404,调用方需容错;返回 ``{"index", "title", "cover"}``,
index 以 ``bvid:`` / ``cid:`` 为键。
"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
index: dict[str, dict[str, Any]] = {}
season_title = ""
season_cover = ""
processed = 0
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict):
continue
meta = {
"title": str(arc.get("title") or ""),
"cover": normalize_cover(arc.get("cover") or ""),
"duration": to_number(arc.get("duration")),
}
if arc.get("bvid"):
index[f"bvid:{arc['bvid']}"] = meta
if arc.get("cid"):
index[f"cid:{arc['cid']}"] = meta
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return {"index": index, "title": season_title, "cover": season_cover}
def build_songs_from_pages(
bvid: str, root_title: str, author: str, cover: str, pages: list[Any]
) -> list[dict[str, Any]]:
"""由分P数组构造歌曲(上游 buildSongsFromPages)。"""
songs: list[dict[str, Any]] = []
for i, page in enumerate(pages or []):
part = str(page.get("part") or "") if isinstance(page, dict) else ""
display_title = part
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
songs.append(
make_song(
bvid=bvid,
cid=page.get("cid") if isinstance(page, dict) else None,
name=display_title,
artist=author,
album=bvid,
duration=page.get("duration") if isinstance(page, dict) else 0,
cover=cover,
page=i + 1,
)
)
return songs
def build_songs_from_season_sections(
sections: list[Any],
season_title: str,
season_cover: str,
artist_name: str,
archive_index: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections)。"""
index = archive_index or {}
songs: list[dict[str, Any]] = []
artist = artist_name or season_title
for sec in sections or []:
if not isinstance(sec, dict):
continue
for ep in sec.get("episodes") or []:
if not isinstance(ep, dict):
continue
cover = str(ep.get("cover") or "")
meta = index.get(f"bvid:{ep.get('bvid')}") or (index.get(f"cid:{ep.get('cid')}") if ep.get("cid") else None)
if not cover:
arc = as_dict(ep.get("arc"))
cover = str(arc.get("pic") or "")
if not cover:
cover = season_cover or (meta or {}).get("cover", "")
duration = to_number(ep.get("duration"))
if not duration:
page = as_dict(ep.get("page"))
duration = to_number(page.get("duration"))
if not duration:
arc = as_dict(ep.get("arc"))
duration = to_number(arc.get("duration"))
if not duration:
duration = to_number((meta or {}).get("duration"))
page = as_dict(ep.get("page"))
arc = as_dict(ep.get("arc"))
name = str(ep.get("title") or arc.get("title") or page.get("part") or (meta or {}).get("title") or "")
songs.append(
make_song(
bvid=str(ep.get("bvid") or ""),
cid=ep.get("cid"),
name=name,
artist=artist,
album=str(ep.get("bvid") or ""),
duration=duration,
cover=cover,
page=1,
)
)
return songs
async def fetch_season_songs(mid: Any, season_id: Any) -> list[dict[str, Any]]:
"""合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错)。"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
all_songs: list[dict[str, Any]] = []
processed = 0
season_title = ""
season_cover = ""
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict) or not arc.get("cid"):
continue
all_songs.append(
make_song(
bvid=str(arc.get("bvid") or ""),
cid=arc.get("cid"),
name=str(arc.get("title") or ""),
artist="",
album=season_title or str(arc.get("bvid") or ""),
duration=arc.get("duration"),
cover=normalize_cover(arc.get("cover") or season_cover),
page=1,
)
)
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return all_songs