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:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -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,
|
||||
)
|
||||
@@ -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": ""}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 ""
|
||||
@@ -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站不支持歌单分类(上游未实现)")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user