feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 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 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
"""哔哩哔哩(bili)平台模块(对应 sdk/bili/index.ts)。
上游 music-lib/bilibili 的移植,能力位:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单
(合集 ugc_season + 多分P稿件)/ 链接解析(BV/av、分P)/ 封面 / 扫码登录。
不实现(上游即未实现):专辑、歌词(空实现)、榜单、热搜、歌手、评论、个人歌单。
取流入口在 ``app/sdk/apis/bili.py``,songmid 约定 ``<bvid>|<cid>``。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bili import lyric, music_search, parse, pic, playlist
from app.sdk.platforms.bili.util import parse_song_id
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:apis 模块反向依赖本包的 util,顶层导入不必要地增加耦合。"""
from app.sdk.apis.bili import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str:
return await pic.get_pic(song_info)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return "https://www.bilibili.com/"
try:
page = int(song_info.get("page") or 0)
except (TypeError, ValueError):
page = 0
return f"https://www.bilibili.com/video/{bvid}{f'?p={page}' if page > 1 else ''}"
MODULE = Platform(
id="bili",
name="哔哩哔哩",
music_search=music_search,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+14
View File
@@ -0,0 +1,14 @@
"""Bilibili 歌词(对应 sdk/bili/lyric.ts)。
上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串。
B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
返回统一结构 ``{"lyric": "", "tlyric": ""}``,让 /lyric/bili 有响应而不是 404。
"""
from __future__ import annotations
from typing import Any
async def get_lyric(_song_info: dict[str, Any] | None = None) -> dict[str, str]:
return {"lyric": "", "tlyric": ""}
+83
View File
@@ -0,0 +1,83 @@
"""Bilibili 搜索(对应 sdk/bili/musicSearch.ts,上游 bilibili/song.go 的 ``Search``)。
接口: x/web-interface/search/type?search_type=video
上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),
本移植保持一致,只是把逐个串行改成 4 并发;单条 view 失败按上游语义跳过该条。
实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;
风控时返回 -412/-352,会抛错由路由兜底。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
clean_title,
encode_uri_component,
fetch_view,
make_song,
map_limit,
to_number,
)
SEARCH_URL = "https://api.bilibili.com/x/web-interface/search/type"
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,B 站搜索无重试语义
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站搜索失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站搜索失败({body.get('code')}{f': {message}' if message else ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
pages = as_list(view.get("pages"))
if not pages:
return None
first = pages[0] if isinstance(pages[0], dict) else {}
root_title = clean_title(item.get("title"))
display_title = str(first.get("part") or "")
if not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
return make_song(
bvid=str(item["bvid"]),
cid=first.get("cid"),
name=display_title,
artist=str(item.get("author") or owner.get("name") or ""),
album=str(item["bvid"]),
duration=first.get("duration"),
cover=str(item.get("pic") or view.get("pic") or ""),
page=1,
)
songs = await map_limit(items, 4, one)
result = [song for song in songs if song]
total = int(to_number(data.get("numResults"))) or len(result)
num_pages = int(to_number(data.get("numPages")))
return {
"list": result,
"allPage": num_pages or p + (1 if len(result) >= size else 0),
"total": total,
"limit": size,
"source": "bili",
}
+93
View File
@@ -0,0 +1,93 @@
"""Bilibili 链接解析(对应 sdk/bili/parse.ts,上游 bilibili/song.go 的 ``Parse``)。
支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parse_playlist_link。
上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.playlist import parse_playlist_link
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
extract_aid,
extract_bvid,
extract_page,
fetch_page_list,
fetch_view,
make_song,
normalize_cover,
)
async def parse_song(link: str) -> dict[str, Any]:
raw = str(link or "").strip()
bvid = extract_bvid(raw)
aid = extract_aid(raw)
if not bvid and not aid:
raise RuntimeError("无效的B站链接:未找到 BV/av 号")
view = await fetch_view(bvid or aid)
pages = as_list(view.get("pages"))
# 上游:合集或多分P视为歌单链接(Parse 只处理单P)
if view.get("ugc_season") or len(pages) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
if len(pages) <= 1:
try:
page_list = await fetch_page_list(view.get("bvid") or bvid)
if len(page_list) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
except Exception as err:
if "歌单解析" in str(err):
raise
# 分P列表接口失败时按上游语义忽略
if not pages:
raise RuntimeError("未找到视频分P")
page = extract_page(raw)
if page > len(pages):
page = 1
target = pages[page - 1] if isinstance(pages[page - 1], dict) else {}
root_title = str(view.get("title") or "")
display_title = str(target.get("part") or "")
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
cid = str(target.get("cid") if target.get("cid") is not None else "")
song = make_song(
bvid=str(view.get("bvid") or bvid),
cid=cid,
name=display_title,
artist=str(owner.get("name") or ""),
album=str(view.get("bvid") or bvid),
duration=target.get("duration"),
cover=normalize_cover(view.get("pic") or ""),
page=page,
)
# 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if cid:
try:
from app.sdk.apis.bili import get_audio_url
audio = await get_audio_url(str(song["bvid"]), cid)
urls = audio.get("urls") or []
if urls:
song["url"] = urls[0]
song["type"] = audio.get("type")
except Exception: # noqa: BLE001 - 忽略:元数据仍可返回
pass
return song
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单/合集链接解析(上游 bilibili/playlist.go 的 ``ParsePlaylist``)。"""
return await parse_playlist_link(link)
+21
View File
@@ -0,0 +1,21 @@
"""Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import fetch_view, normalize_cover, parse_song_id
async def get_pic(song_info: dict[str, Any]) -> str:
direct = str(song_info.get("img") or "")
if direct.startswith(("http://", "https://")):
return direct
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return ""
try:
view = await fetch_view(bvid)
return normalize_cover(view.get("pic") or "")
except Exception: # noqa: BLE001 - 与原文一致:封面取不到返回空串,不影响调用方
return ""
+318
View File
@@ -0,0 +1,318 @@
"""Bilibili 歌单(对应 sdk/bili/playlist.ts,上游 bilibili/playlist.go)。
B 站的「歌单」= 合集 ugc_season / 多分P稿件:
* search_playlist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
* get_playlist_detail / parse_playlist_link: id 约定 ``season:<season_id>:<mid>:<bvid>`` 或 ``bvid:<bvid>``;
* get_categories / get_category_playlists: 上游显式返回 ErrPlaylistCategoriesUnsupported,保持不支持(抛错)。
注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.bili.music_search import SEARCH_URL
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
build_songs_from_pages,
build_songs_from_season_sections,
clean_title,
encode_uri_component,
extract_aid,
extract_bvid,
fetch_page_list,
fetch_season_archive_index,
fetch_season_songs,
fetch_view,
map_limit,
normalize_cover,
to_number,
)
def _count_season_episodes(sections: list[Any]) -> int:
count = 0
for sec in sections or []:
if isinstance(sec, dict):
episodes = sec.get("episodes")
count += len(episodes) if isinstance(episodes, list) else 0
return count
def _season_key(season_id: Any, mid: Any, bvid: str) -> str:
return f"season:{season_id}:{mid}:{bvid}"
def _bvid_key(bvid: str) -> str:
return f"bvid:{bvid}"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力)。"""
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站歌单搜索失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站歌单搜索失败({body.get('code')}{f': {message}' if message else ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
seen: set[str] = set()
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
cover = normalize_cover(item.get("pic") or view.get("pic") or "")
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count and season.get("id") and owner.get("mid"):
try:
track_count = len(await fetch_season_songs(owner.get("mid"), season.get("id")))
except Exception: # noqa: BLE001 - 上游 fetchSeasonSongs 失败同样保持 trackCount 0
pass
stat = as_dict(season.get("stat"))
return {
"id": playlist_id,
"name": str(season.get("title") or clean_title(item.get("title"))),
"cover": normalize_cover(season.get("cover")) or cover,
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
"creator": str(owner.get("name") or item.get("author") or ""),
"description": str(season.get("intro") or ""),
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
pages = as_list(view.get("pages"))
if len(pages) > 1:
playlist_id = _bvid_key(str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
return {
"id": playlist_id,
"name": clean_title(item.get("title")),
"cover": cover,
"trackCount": len(pages),
"creator": str(owner.get("name") or item.get("author") or ""),
"description": "",
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
return None
result = await map_limit(items, 4, one)
result_list = [item for item in result if item]
return {"list": result_list, "page": p, "limit": size, "total": len(result_list), "source": "bili"}
async def _detail_from_bvid(bvid: str) -> dict[str, Any]:
"""由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据)。"""
view = await fetch_view(bvid)
real_bvid = str(view.get("bvid") or bvid)
pages = as_list(view.get("pages"))
if len(pages) <= 1:
try:
page_list = await fetch_page_list(real_bvid)
except Exception: # noqa: BLE001 - 分P列表接口失败按上游语义忽略
page_list = []
if page_list:
pages = page_list
if not pages:
raise RuntimeError("未找到视频分P")
owner = as_dict(view.get("owner"))
songs = build_songs_from_pages(
real_bvid, str(view.get("title") or ""), str(owner.get("name") or ""), str(view.get("pic") or ""), pages
)
return {
"info": {
"name": str(view.get("title") or real_bvid),
"img": normalize_cover(view.get("pic") or ""),
"desc": str(view.get("desc") or ""),
"author": str(owner.get("name") or ""),
},
"songs": songs,
}
async def _detail_from_season(season_id: str, mid: str, bvid: str) -> dict[str, Any]:
"""由 ``season:<season_id>:<mid>:<bvid>`` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支)。"""
info: dict[str, Any] = {"name": "", "img": "", "desc": "", "author": ""}
songs: list[dict[str, Any]] = []
if bvid:
view = await fetch_view(bvid)
season = as_dict(view.get("ugc_season")) or None
if season:
archive_index: dict[str, dict[str, Any]] = {}
season_title = str(season.get("title") or "")
season_cover = str(season.get("cover") or "")
# 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
archive_index = idx["index"]
season_title = season_title or idx["title"]
season_cover = season_cover or idx["cover"]
owner = as_dict(view.get("owner"))
songs = build_songs_from_season_sections(
as_list(season.get("sections")),
season_title,
season_cover,
str(owner.get("name") or ""),
archive_index,
)
info = {
"name": season_title or str(view.get("title") or ""),
"img": normalize_cover(season_cover or view.get("pic") or ""),
"desc": str(season.get("intro") or ""),
"author": str(owner.get("name") or ""),
}
if not songs:
songs = await fetch_season_songs(mid, season_id)
if not info["name"]:
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
info = {**info, "name": idx["title"] or "", "img": normalize_cover(idx["cover"] or "")}
if not songs:
raise RuntimeError("合集为空或合集接口不可用")
return {"info": info, "songs": songs}
async def _resolve_id(playlist_id: str) -> dict[str, Any]:
"""解析 id(兼容链接/裸 BV/av/bvid: 前缀)。"""
raw = str(playlist_id or "").strip()
if re.match(r"^https?:", raw, re.IGNORECASE):
return {"kind": "link", "value": raw}
if re.match(r"^season:", raw, re.IGNORECASE):
return {"kind": "season", "value": raw, "parts": raw.split(":")}
# 注意先去 `bvid:` 前缀再提 BV:大小写不敏感的 BV 匹配会把前缀 "bvid" 本身当成 BV 号
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if bvid:
return {"kind": "bvid", "value": bvid}
aid = extract_aid(raw)
if aid:
view = await fetch_view(aid)
real_bvid = str(view.get("bvid") or "")
if not real_bvid:
raise RuntimeError("未找到视频 BV 号")
return {"kind": "bvid", "value": real_bvid}
raise RuntimeError("无效的歌单 id(支持 season:<season_id>:<mid>:<bvid>、bvid:<bvid> 或 BV 号/链接)")
def _page_slice(detail: dict[str, Any], page: Any, limit: Any) -> dict[str, Any]:
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 100), 10000)
songs = detail["songs"]
return {
"source": "bili",
"info": detail["info"],
"list": songs[(p - 1) * size : p * size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(契约键 playlist.get_playlist_detail)。"""
target = await _resolve_id(playlist_id)
if target["kind"] == "link":
return await parse_playlist_link(target["value"])
if target["kind"] == "season":
parts = target.get("parts") or []
if len(parts) < 3:
raise RuntimeError("无效的合集 id")
bvid = parts[3] if len(parts) > 3 else ""
return _page_slice(await _detail_from_season(parts[1], parts[2], bvid), page, limit)
return _page_slice(await _detail_from_bvid(target["value"]), page, limit)
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs 的等价导出(只返回歌曲数组)。"""
detail = await get_playlist_detail(playlist_id, 1, 10000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量)。"""
raw = str(link or "").strip()
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if not bvid:
aid = extract_aid(raw)
if not aid:
raise RuntimeError("无效的B站歌单链接:未找到 BV/av 号")
view = await fetch_view(aid)
return await _parse_playlist_view(str(view.get("bvid") or ""), view)
view = await fetch_view(bvid)
return await _parse_playlist_view(bvid, view)
async def _parse_playlist_view(bvid: str, view: dict[str, Any]) -> dict[str, Any]:
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), bvid)
detail = await _detail_from_season(
str(season.get("id")), str(owner.get("mid") if owner.get("mid") is not None else ""), bvid
)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count:
track_count = len(detail["songs"])
stat = as_dict(season.get("stat"))
return {
"source": "bili",
"id": playlist_id,
"info": {
**detail["info"],
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
detail = await _detail_from_bvid(bvid)
return {
"source": "bili",
"id": _bvid_key(bvid),
"info": {**detail["info"], "trackCount": len(detail["songs"])},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
+425
View File
@@ -0,0 +1,425 @@
"""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