- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""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'<em class="keyword">|</em>', "", str(title))
|
||
|
||
|
||
def parse_song_id(songmid: Any) -> dict[str, str]:
|
||
"""songmid 约定:``<bvid>|<cid>``(与上游 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:<bvid>`` / ``cid:<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
|