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

+85
View File
@@ -0,0 +1,85 @@
"""JOOX(joox)平台模块(对应 sdk/joox/index.ts)。
匿名可用面(2026-10 实测,www 站点需 X-Forwarded-For 东南亚 IP 绕过地区拦截):
搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+链接解析)/ 歌词 / 链接解析(歌曲)/
取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
通道:
* 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(**无签名**)
* 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(**需 md5 签名**,见 util.joox_sign)
* 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
* 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
⚠ 网络连通性实况(本机出口 IP 非东南亚):
* cache.api.joox.com 与 api.joox.com 的 JSON 接口全部可直连;
* www.joox.com 的 SSR 页面返回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__,
因此 ``playlist.get_categories`` / ``playlist.get_category_playlists`` 与专辑·歌单的页面兜底
在当前网络下会如实抛错(不返回假数据)。这与 TS 注释一致:TS 也依赖伪造 X-Forwarded-For,
本移植带的是同一个头(``util.JOOX_XFF``),能否生效取决于上游是否采信,实测本机未被采信。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.apis.joox import get_music_url
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.joox import album, lyric, music_search, parse, playlist
from app.sdk.platforms.joox.music_info import get_track_detail
from app.sdk.platforms.joox.util import joox_song_link, normalize_joox_id, pick_joox_image
class JOOXSongList:
"""契约别名 ``songList``(对应 TS index.ts 里的内联 shim)。
JOOX 没有独立的「排行榜/歌曲列表」接口:``getTags`` 即歌单分类,``getList`` 即某分类下的歌单。
``sort_list`` 只有「全部」一项,因此路由透传的 ``sort_id`` 恒为空串,``tag_id`` 才是分类号。
TS 的 ``getList(sortId, tagId, page, limit=30)`` 中 ``limit`` 固定在内部,路由不透传。
"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "全部", "id": ""}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: str = "", tag_id: str = "", page: int = 1) -> dict[str, Any]:
return await playlist.get_category_playlists(str(tag_id or sort_id or ""), page, 30)
async def _get_pic(song_info: dict[str, Any]) -> str | None:
"""封面(对应 TS index.ts 的 getPic):走 openjoox2 track 详情后挑图,取不到返回 None。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or "")
detail = await get_track_detail(song_id)
return pick_joox_image(detail.get("images")) or None
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return joox_song_link(song_info.get("songmid") or song_info.get("id") or "")
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断;无在线校验接口)。"""
cred = get_credentials("joox")
if cred and cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="joox",
name="JOOX",
music_search=music_search,
song_list=JOOXSongList,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=_get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
View File
Whitespace-only changes.
+221
View File
@@ -0,0 +1,221 @@
"""JOOX 专辑(对应 sdk/joox/album.ts,上游 joox/album.go 移植 + 新版 openjoox2 通道)。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``section_type=1`` 专辑区,``item.type=2``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
(web 播放器通道,需 md5 签名;实测 2026-10 HTTP 200)
兜底:www.joox.com/<region>/album/<id> SSR ``__NEXT_DATA__.props.pageProps.albumData``
⚠ 该兜底依赖 X-Forwarded-For 伪装的东南亚 IP;本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此该路径在当前网络环境下不可用,仅在 openjoox2 失败时尝试,失败即如实抛出。
链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
路径里的 album id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径,
故路径写法不影响 secret 校验。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_album_link,
joox_artists,
joox_headers,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
to_int,
)
async def fetch_album_tracks(album_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 专辑曲目(含专辑元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/album/{album_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")), album_id)
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), album_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": joox_artists(body.get("artist_list")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def fetch_album_page_data(album_id: str) -> dict[str, Any]:
"""SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分)。"""
page = await fetch_joox_page(joox_album_link(album_id))
next_data = extract_joox_next_data(page["body"])
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
album_data = page_props.get("albumData")
if isinstance(album_data, dict) and normalize_joox_id(album_data.get("id")):
return album_data
nested = ((page_props.get("content") or {}).get("page") or {}).get("albumData")
if isinstance(nested, dict) and normalize_joox_id(nested.get("id")):
return nested
raise RuntimeError("joox 专辑页数据不存在")
async def get_album_detail_from_page(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径)。"""
album_id = normalize_joox_id(album_id)
if not album_id:
raise RuntimeError("joox 专辑 id 无效")
album_data = await fetch_album_page_data(album_id)
track_list = album_data.get("trackList")
items = track_list.get("items") if isinstance(track_list, dict) else None
items = items if isinstance(items, list) else []
page_start = max(0, (page - 1) * limit)
page_items = items[page_start : page_start + limit]
cover = str(first_non_empty(pick_joox_image(album_data.get("images")), album_data.get("imgSrc")))
songs = [
song
for song in (
joox_song_from_track(item, str(album_data.get("title") or ""), cover, album_id) for item in page_items
)
if song
]
if not songs:
raise RuntimeError("joox 专辑无歌曲")
total = first_non_zero(
track_list.get("total_count") if isinstance(track_list, dict) else None,
track_list.get("list_count") if isinstance(track_list, dict) else None,
len(items),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(album_data.get("title"), album_id)),
"img": cover or None,
"desc": str(first_non_empty(album_data.get("description"), album_data.get("publishDate"))),
"author": joox_artists(album_data.get("artistList")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(openjoox2 优先,SSR 页兜底)。"""
normalized = normalize_joox_id(album_id)
if not normalized:
raise RuntimeError("joox 专辑 id 无效")
try:
detail = await fetch_album_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走页面兜底
pass
return await get_album_detail_from_page(normalized, page, limit)
async def get_album_songs(album_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""专辑曲目(上游 GetAlbumSongs:仅曲目列表)。"""
detail = await get_album_detail(album_id, page, limit)
return detail["list"]
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""专辑搜索(上游 SearchAlbum)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
albums: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict) or to_int(section.get("section_type")) != 1:
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or to_int(item.get("type")) != 2:
continue
raw_album = item.get("album")
album = raw_album if isinstance(raw_album, dict) else {}
album_id = normalize_joox_id(album.get("id"))
if not album_id or album_id in seen:
continue
seen.add(album_id)
publish_date = str(album.get("publish_date") or "").strip()
albums.append(
{
"id": album_id,
"name": str(album.get("name") or ""),
"cover": pick_joox_image(album.get("images")) or None,
"creator": joox_artists(album.get("artist_list")),
"description": publish_date,
"publishDate": publish_date,
"trackCount": 0,
"link": joox_album_link(album_id),
"source": "joox",
}
)
return {"list": albums[:limit], "total": len(albums), "page": page, "limit": limit, "source": "joox"}
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接解析(上游 ParseAlbum)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/album/([^/?#]+)",
r"h_activity_id=([^&]+)",
r"albumid=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 10 and "/" not in stripped:
return await get_album_detail(stripped)
raise RuntimeError("invalid joox album link")
+92
View File
@@ -0,0 +1,92 @@
"""JOOX 歌词(对应 sdk/joox/lyric.ts,上游 joox/lyric.go 移植)。
主通道:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
响应形如 ``MusicJsonCallback({"code":0,"lyric":"<base64>"})``,lyric 为 base64 UTF-8 歌词。
实测(2026-10):该旧接口仍可用(同域的 web_get_songinfo 已 404,但 web_lyric 未下线)。
兜底:openjoox2 /track 详情的 ``lrc_content``(同为 base64)。
解码:base64 → UTF-8 → HTML 实体(&amp; 等)→ ``{ lyric, tlyric: "" }``。
QRC(``qrc_content``,XML 格式)上游与 TS 均未解析,此处同样不解析,避免引入未验证逻辑。
"""
from __future__ import annotations
import json
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import build_track_url
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_lyric,
encode_uri_component,
first_non_empty,
joox_headers,
normalize_joox_id,
)
LEGACY_LYRIC_URL = "https://api.joox.com/web-fcgi-bin/web_lyric"
def parse_lyric_payload(body: Any) -> dict[str, Any] | None:
"""解析 MusicJsonCallback(...) / 裸 JSON 包裹。"""
if isinstance(body, dict):
return body
text = str(body if body is not None else "")
index = text.find("MusicJsonCallback(")
if index >= 0:
text = text[index + len("MusicJsonCallback(") :]
text = text.rstrip()
if text.endswith(";"):
text = text[:-1].rstrip()
if text.endswith(")"):
text = text[:-1].rstrip()
try:
parsed = json.loads(text)
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def parse_legacy_lyric(status_code: int, body: Any) -> str:
"""上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本。"""
if status_code != 200:
raise RuntimeError(f"joox 歌词获取失败(HTTP {status_code})")
payload = parse_lyric_payload(body)
if not payload:
raise RuntimeError("joox 歌词解析失败")
raw = str(first_non_empty(payload.get("lyric"), ""))
if int(payload.get("code") or 0) != 0 or not raw:
raise RuntimeError("joox 歌词不存在或为空")
return decode_joox_lyric(raw)
async def get_legacy_lyric(song_id: str) -> str:
"""上游 GetLyrics 通道(仅旧接口,测试/内部用)。"""
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return parse_legacy_lyric(res.status_code, res.body)
async def get_lyric(song_info: dict[str, Any], *_rest: Any) -> dict[str, str]:
"""歌词:旧接口优先,失败回退 openjoox2 详情的 lrc_content。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
if not song_id:
raise RuntimeError("joox 歌曲 id 无效")
# 1) 上游 web_lyric 通道
legacy_err: Exception | None = None
try:
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return {"lyric": parse_legacy_lyric(res.status_code, res.body), "tlyric": ""}
except Exception as err: # noqa: BLE001 - 降级链:任一失败都转 openjoox2 兜底
legacy_err = err
# 2) 兜底:openjoox2 详情 lrc_content(base64)
res = await http_fetch(build_track_url(song_id), headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body if isinstance(res.body, dict) else {}
raw = str(first_non_empty(body.get("lrc_content"), ""))
if res.status_code != 200 or not raw:
raise legacy_err or RuntimeError("joox 歌词不存在或为空")
return {"lyric": decode_joox_lyric(raw), "tlyric": ""}
+103
View File
@@ -0,0 +1,103 @@
"""JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)。
对应 sdk/joox/musicInfo.ts。
端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
* 参数按**插入序**参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
* 签名串与真正发出的参数来自同一次序列化(同一份 params dict + 同一 encoder),不做二次拼接。
* 实测(2026-10):不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};
按上式 → 200,返回 play_url_list / refrain_url / lrc_content / qrc_content / vip_flag 等。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 前),本模块为其替代。
``fs=1`` 请求完整曲目(游客仍可能拿到 preview/refrain),``im`` 为 mobile 标记。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
cookie_value,
encode_uri_component,
joox_headers,
joox_login_cookie,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
)
JOOX_OPENJOOX2 = "https://cache.api.joox.com/openjoox2/v1"
def build_track_url(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> str:
"""构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk)。"""
track_id = str(normalize_joox_id(raw_id)).replace("/", "_")
params: dict[str, Any] = {
"country": country or "hk",
"lang": normalize_joox_lang(lang or "zh_TW"),
"lyric": 1,
"fs": 1 if full_song else 0,
"im": 1 if is_mobile else 0,
}
# 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
login_cookie = joox_login_cookie()
uid = cookie_value(login_cookie, "wmid") if login_cookie else ""
usk = cookie_value(login_cookie, "session_key") if login_cookie else ""
if uid:
params["uid"] = uid
if usk:
params["usk"] = usk
# 签名时包含 id(与 web 播放器一致),发布 query 时 id 走路径、不进 query
secret = joox_sign({**params, "id": track_id})
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in {**params, "secret": secret}.items())
return f"{JOOX_OPENJOOX2}/track/{track_id}?{query}"
async def get_track_detail(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> dict[str, Any]:
"""歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现)。"""
track_id = normalize_joox_id(raw_id)
if not track_id:
raise RuntimeError("joox 歌曲 id 无效")
url = build_track_url(track_id.replace("/", "_"), country, lang, full_song, is_mobile)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
msg = ""
if isinstance(body, dict):
msg = str(body.get("msg") or body.get("message") or "")
raise RuntimeError(f"joox 歌曲详情获取失败:{msg or f'HTTP {res.status_code}'}")
return body
def pick_track_url(detail: Any) -> str:
"""从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 &amp; 实体。"""
if not isinstance(detail, dict):
return ""
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = detail.get("play_url_list")
for url in urls if isinstance(urls, list) else []:
text = clean(url)
if text.startswith("http"):
return text
refrain = clean(detail.get("refrain_url"))
return refrain if refrain.startswith("http") else ""
+57
View File
@@ -0,0 +1,57 @@
"""JOOX 搜索(对应 sdk/joox/musicSearch.ts,上游 joox/song.go Search 移植)。
端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
返回 ``section_list``(0 歌曲 / 1 专辑 / 2 歌手 / 3 歌单 / 5 最佳结果 / 7 用户),
歌曲在 ``item_list[].song[].song_info``。
该接口**无分页参数**(上游也未分页),``page`` 仅为统一契约保留;``allPage`` 恒为 1,
``total`` 是去重后的本次结果条数(不是上游分页总数)。
实测(2026-10):中文/英文关键词均 HTTP 200。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.song import joox_song_from_search
from app.sdk.platforms.joox.util import JOOX_TIMEOUT, encode_uri_component, joox_headers
SEARCH_URL = "https://cache.api.joox.com/openjoox/v3/search"
def build_search_url(keyword: str) -> str:
return f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(keyword or '')}"
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索歌曲。``page`` 不参与请求(上游无分页),保留以对齐契约。"""
url = build_search_url(str(keyword or ""))
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "allPage": 0, "total": 0, "limit": limit, "source": "joox"}
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict):
continue
for entry in item.get("song") or []:
info = entry.get("song_info") if isinstance(entry, dict) else None
song = joox_song_from_search(info)
if not song or song["songmid"] in seen:
continue
seen.add(song["songmid"])
songs.append(song)
return {
"list": songs[:limit],
"allPage": 1,
"total": len(songs),
"limit": limit,
"source": "joox",
}
+43
View File
@@ -0,0 +1,43 @@
"""JOOX 链接解析(对应 sdk/joox/parse.ts)。
歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.get_album_detail
歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.get_playlist_detail
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.joox.album import parse_album as _parse_album
from app.sdk.platforms.joox.music_info import get_track_detail, pick_track_url
from app.sdk.platforms.joox.playlist import parse_playlist as _parse_playlist
from app.sdk.platforms.joox.song import joox_song_from_detail
from app.sdk.platforms.joox.util import normalize_joox_id
_SINGLE_RE = re.compile(r"joox\.com/.*/single/([^/?#]+)")
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则)。"""
raw = str(link or "").strip()
match = _SINGLE_RE.search(raw)
if match:
song_id = normalize_joox_id(match.group(1))
elif len(raw) > 10 and "/" not in raw:
song_id = normalize_joox_id(raw)
else:
raise RuntimeError("invalid joox link")
detail = await get_track_detail(song_id)
return joox_song_from_detail(detail, pick_track_url(detail))
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接 → 专辑详情(上游 ParseAlbum)。"""
return await _parse_album(link)
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接 → 歌单详情(上游 ParsePlaylist)。"""
return await _parse_playlist(link)
+420
View File
@@ -0,0 +1,420 @@
"""JOOX 歌单(对应 sdk/joox/playlist.ts,上游 joox/playlist.go 移植)。
两条路:OpenJOOX 接口 + 网页数据兜底。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``item.type=1`` → ``editor_playlist``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret=
(上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR ``__NEXT_DATA__`` 的
props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
分类:www.joox.com/sg/playlist(302 → /<region> 首页)``__NEXT_DATA__.props.pageProps.mlList``
⚠ 分类与页面兜底都依赖 X-Forwarded-For 伪装的东南亚 IP。本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此 get_categories / get_category_playlists 在当前网络环境下会如实抛错,不返回假数据。
链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID。
路径里的歌单 id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_base64_text,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_headers,
joox_playlist_link,
joox_playlist_links,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
)
CATEGORY_PAGE = "https://www.joox.com/sg/playlist"
def map_playlist_summary(item: Any) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
playlist_id = normalize_joox_id(item.get("id"))
if not playlist_id:
return None
return {
"id": playlist_id,
"name": str(item.get("name") or ""),
"cover": pick_joox_image(item.get("images")) or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(playlist_id),
"source": "joox",
}
async def fetch_playlist_tracks(playlist_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 歌单曲目(含歌单元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/playlist/{playlist_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")))
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
body.get("track_count"),
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), playlist_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": "",
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
def _track_items(tracks: Any) -> list[Any]:
if not isinstance(tracks, dict):
return []
items = tracks.get("items")
return items if isinstance(items, list) else []
def _track_total(*candidates: Any) -> Any:
return first_non_zero(*candidates)
def _nested_name(detail: dict[str, Any], key: str) -> Any:
"""TS 里 ``detail?.user?.name`` 这类嵌套取值的兑底链(name → userName → nickName → nick)。"""
node = detail.get(key)
if not isinstance(node, dict):
return None
return first_non_empty(node.get("name"), node.get("userName"), node.get("nickName"), node.get("nick"))
def parse_playlist_page_data(next_data: Any, page_url: str) -> dict[str, Any] | None:
"""网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植)。"""
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
detail: dict[str, Any] = page_props.get("playlistDetailList") or {}
tracks = _track_items((page_props.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
nested = (page_props.get("content") or {}).get("page") or {}
detail = nested.get("playlistDetailList") or {}
tracks = _track_items((nested.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
track_count,
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
return None
playlist_id = normalize_joox_id(first_non_empty(detail.get("id"), detail.get("listId"), detail.get("playlistId")))
name = str(first_non_empty(detail.get("name"), detail.get("title"), detail.get("playlistName"), playlist_id))
cover = str(first_non_empty(detail.get("imgSrc"), detail.get("cover"), detail.get("image"), detail.get("pic")))
creator = first_non_empty(
detail.get("creator"),
detail.get("creatorName"),
detail.get("userName"),
detail.get("nickName"),
detail.get("ownerName"),
detail.get("author"),
detail.get("authorName"),
_nested_name(detail, "user"),
_nested_name(detail, "owner"),
_nested_name(detail, "creatorInfo"),
)
description = first_non_empty(detail.get("description"), detail.get("intro"), detail.get("desc"))
return {
"id": playlist_id,
"name": name,
"cover": cover,
"creator": creator,
"description": description,
"trackCount": track_count,
"tracks": tracks,
"pageUrl": page_url,
}
async def get_playlist_detail_from_page(raw_id: str) -> dict[str, Any]:
"""网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径)。"""
playlist_id = normalize_joox_id(raw_id)
if not playlist_id:
raise RuntimeError("joox 歌单 id 无效")
last_err: Exception | None = None
for page_url in joox_playlist_links(playlist_id):
try:
page = await fetch_joox_page(page_url)
parsed = parse_playlist_page_data(extract_joox_next_data(page["body"]), page_url)
if not parsed:
raise RuntimeError("joox 歌单页数据不存在")
page_id = normalize_joox_id(parsed["id"]) or playlist_id
cover = str(parsed["cover"] or "")
songs = [
song
for song in (joox_song_from_track(item, parsed["name"], cover) for item in parsed["tracks"])
if song
]
if not songs:
raise RuntimeError("joox 歌单无歌曲")
return {
"source": "joox",
"info": {
"name": str(parsed["name"] or page_id),
"img": cover or None,
"desc": str(parsed["description"] or ""),
"author": str(parsed["creator"] or ""),
},
"list": songs,
"page": 1,
"limit": len(songs),
"total": first_non_zero(parsed["trackCount"], len(songs)),
}
except Exception as err: # noqa: BLE001 - 逐个区域尝试,全部失败才抛最后一个错误
last_err = err
raise last_err or RuntimeError("joox 歌单页数据不存在")
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留)。"""
normalized = normalize_joox_id(playlist_id)
if not normalized:
raise RuntimeError("joox 歌单 id 无效")
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走网页兜底
pass
return await get_playlist_detail_from_page(normalized)
async def get_playlist_songs(playlist_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底)。"""
normalized = normalize_joox_id(playlist_id)
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail["list"]
except Exception: # noqa: BLE001 - 走网页兜底
pass
from_page = await get_playlist_detail_from_page(normalized)
return from_page["list"]
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(上游 SearchPlaylist)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
playlists: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or _as_int(item.get("type")) != 1:
continue
summary = map_playlist_summary(item.get("editor_playlist"))
if not summary or summary["id"] in seen:
continue
seen.add(summary["id"])
playlists.append(summary)
return {"list": playlists[:limit], "total": len(playlists), "page": page, "limit": limit, "source": "joox"}
async def fetch_playlist_categories_page() -> dict[str, Any]:
"""歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage)。"""
page = await fetch_joox_page(CATEGORY_PAGE)
next_data = extract_joox_next_data(page["body"])
ml_list = ((next_data or {}).get("props") or {}).get("pageProps", {})
ml_list = (ml_list or {}).get("mlList")
if not isinstance(ml_list, dict) or not ml_list.get("category"):
raise RuntimeError("joox 歌单分类不存在")
return ml_list
def category_playlist_id(value: Any) -> str:
"""分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一)。"""
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(int(value))
return normalize_joox_id(value)
def _categories(ml_list: dict[str, Any]) -> list[Any]:
categories = ml_list.get("category")
return categories if isinstance(categories, list) else []
async def get_categories() -> dict[str, Any]:
"""分类列表(上游 GetPlaylistCategories;首个为「全部」)。"""
data = await fetch_playlist_categories_page()
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部", "hot": False}]
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
entries = category.get("itemlist")
entries = entries if isinstance(entries, list) else []
if not name or not entries:
continue
items.append({"id": str(index), "name": name, "group": "JOOX", "hot": False, "count": len(entries)})
if len(items) == 1:
raise RuntimeError("joox 歌单分类不存在")
return {"source": "joox", "list": items}
async def get_category_playlists(category_id: str = "", page: int = 1, limit: int = 20) -> dict[str, Any]:
"""分类下歌单(上游 GetCategoryPlaylists)。"""
data = await fetch_playlist_categories_page()
cid = str(category_id or "").strip()
items: list[Any] = []
category_name = "全部"
if not cid:
seen: set[str] = set()
for category in _categories(data):
if not isinstance(category, dict):
continue
for item in category.get("itemlist") or []:
entry_id = category_playlist_id(item.get("id") if isinstance(item, dict) else None)
if not entry_id or entry_id in seen:
continue
seen.add(entry_id)
items.append(item)
else:
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
if str(index) == cid or (name and name.lower() == cid.lower()):
entries = category.get("itemlist")
items = entries if isinstance(entries, list) else []
category_name = name
break
if not items:
raise RuntimeError("joox 歌单分类不存在")
start = max(0, (page - 1) * limit)
if start >= len(items):
raise RuntimeError("joox 分类下无歌单")
page_items = items[start : start + limit]
playlists: list[dict[str, Any]] = []
for item in page_items:
if not isinstance(item, dict):
continue
entry_id = category_playlist_id(item.get("id"))
name = decode_joox_base64_text(item.get("title"))
if not entry_id or not name:
continue
playlists.append(
{
"id": entry_id,
"name": name,
"cover": str(item.get("picurl") or "").replace("%d", "300") or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(entry_id),
"categoryId": cid,
"categoryName": category_name,
"source": "joox",
}
)
if not playlists:
raise RuntimeError("joox 分类下无歌单")
return {"list": playlists, "total": len(items), "page": page, "limit": limit, "source": "joox"}
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接解析(上游 ParsePlaylist)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/playlist/([^/?#]+)",
r"(?:playlistid|playlist_id|id)=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_playlist_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 8 and "/" not in stripped:
return await get_playlist_detail(stripped)
raise RuntimeError("invalid joox playlist link")
def _as_int(value: Any) -> int | None:
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
+96
View File
@@ -0,0 +1,96 @@
"""JOOX 歌曲形状映射(对应 sdk/joox/song.ts)。
统一 Song 形状(见 tmp/port-spec.md);兼容来源:openjoox2 track/album/playlist 条目、
openjoox v3 search 的 song_info、以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,
上游 Go 也两套都兜底)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import format_play_time
from app.sdk.platforms.joox.util import (
first_non_empty,
first_non_zero,
joox_artists,
normalize_joox_id,
pick_joox_image,
to_int,
)
def joox_song_from_track(
item: Any,
fallback_album: str = "",
fallback_cover: str = "",
fallback_album_id: str = "",
) -> dict[str, Any] | None:
"""详情/列表 track 条目 → 统一 Song 形状。"""
if not isinstance(item, dict):
item = {}
songmid = normalize_joox_id(item.get("id"))
if not songmid:
return None
artists = item.get("artist_list")
if not (isinstance(artists, list) and artists):
artists = item.get("artistList")
album_id = normalize_joox_id(first_non_empty(item.get("album_id"), item.get("albumId"), fallback_album_id))
return {
"songmid": songmid,
"name": str(first_non_empty(item.get("name"), item.get("title"), "Unknown")),
"singer": str(first_non_empty(joox_artists(artists), "Unknown")),
"albumId": str(album_id or ""),
"albumName": str(first_non_empty(item.get("album_name"), item.get("albumName"), fallback_album)),
"interval": format_play_time(
first_non_zero(item.get("play_duration"), item.get("playDuration"), item.get("duration"))
),
"img": str(
first_non_empty(
pick_joox_image(item.get("images")),
item.get("imgSrc"),
item.get("cover"),
item.get("image"),
item.get("pic"),
fallback_cover,
)
)
or None,
"lrc": None,
"vip": to_int(item.get("vip_flag") if item.get("vip_flag") is not None else 0) == 1,
"types": [],
"_types": {},
"source": "joox",
}
def joox_song_from_search(info: Any) -> dict[str, Any] | None:
"""openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状。"""
return joox_song_from_track(info)
def joox_song_from_detail(detail: Any, play_url: str = "") -> dict[str, Any]:
"""详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状。"""
if not isinstance(detail, dict):
detail = {}
song = joox_song_from_track(detail)
if song is None:
song = {
"songmid": normalize_joox_id(detail.get("id")),
"name": str(detail.get("name") or ""),
"singer": str(joox_artists(detail.get("artist_list"))),
"albumId": str(normalize_joox_id(detail.get("album_id"))),
"albumName": str(detail.get("album_name") or ""),
"interval": format_play_time(first_non_zero(detail.get("play_duration"))),
"img": str(pick_joox_image(detail.get("images"))) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "joox",
}
if play_url:
song["url"] = play_url
song["vip"] = to_int(detail.get("vip_flag") if detail.get("vip_flag") is not None else 0) == 1
return song
+267
View File
@@ -0,0 +1,267 @@
"""JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取(对应 sdk/joox/util.ts)。
背景(与 TS 注释一致,2026-10 实测):
* JOOX 是东南亚服务,www.joox.com 会按来源 IP 返回「Sorry, JOOX is not available in this
location」;上游通过伪造 ``X-Forwarded-For`` 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
* 新版 web 播放器走 ``cache.api.joox.com/openjoox2/v1/*``,**必须**带 secret 签名:
secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent)
密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
实测:不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};按上式 → 200。
* 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
但 web_lyric 仍可用(歌词首选通道)。
* 登录 cookie 里的 wmid/session_key 在签名参数里作为 uid/usk 参与(VIP 完整曲目依赖它)。
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
JOOX_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
"""上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie)。"""
JOOX_DEFAULT_COOKIE = "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;"
"""上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区拦截)。"""
JOOX_XFF = "36.73.34.109"
"""openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey)。"""
JOOX_SECRET_KEY = "Jo0x@t3Nc3nT"
"""平台统一超时(毫秒),与 TS 各处 timeout: 20000 一致。"""
JOOX_TIMEOUT = 20000
def md5_hex(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def joox_sign(params: dict[str, Any]) -> str:
"""openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)。
**键序即 dict 插入序**(服务端按同序校验),不要重排参数 —— 任何 sorted() 都会破坏签名。
"""
pairs = [f"{key}={encode_uri_component(value)}" for key, value in params.items()]
return md5_hex(JOOX_SECRET_KEY + "&".join(pairs))
def joox_login_cookie() -> str:
"""绑定到 auth_store 的登录 cookie(未登录返回空串,不回退游客 cookie)。"""
cred = get_credentials("joox")
if not cred:
return ""
return str(cred.get("cookie") or "")
def joox_cookie() -> str:
"""请求用 cookie(登录态优先;未登录回退上游游客 cookie)。"""
return joox_login_cookie() or JOOX_DEFAULT_COOKIE
def cookie_value(cookie: str, name: str) -> str:
"""从 cookie 串里取一个字段。"""
match = re.search(rf"(?:^|;\s*){re.escape(name)}=([^;]*)", str(cookie or ""))
return match.group(1).strip() if match else ""
def joox_headers(extra: dict[str, Any] | None = None) -> dict[str, str]:
"""平台公共请求头(含地区伪装与 Cookie)。"""
headers = {
"User-Agent": JOOX_UA,
"Cookie": joox_cookie(),
"X-Forwarded-For": JOOX_XFF,
"Referer": "https://www.joox.com/",
"Accept": "application/json, text/plain, */*",
}
if extra:
headers.update({str(key): str(value) for key, value in extra.items()})
return headers
def normalize_joox_lang(lang: str | None) -> str:
"""语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN)。"""
if lang in ("zh_hk", "zh_mo"):
return "zh_TW"
if lang == "zh_cn":
return "zh_CN"
return lang or "zh_TW"
def normalize_joox_id(raw: Any) -> str:
"""ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +)。"""
normalized = str(raw if raw is not None else "").strip()
if not normalized:
return ""
if "%" in normalized:
try:
normalized = unquote(normalized, errors="strict")
except (UnicodeDecodeError, ValueError):
# 上游 PathUnescape/QueryUnescape 失败时保留原值
pass
normalized = normalized.replace(" ", "+")
return normalized
def joox_song_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/single/{normalize_joox_id(raw_id)}"
def joox_album_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/album/{normalize_joox_id(raw_id)}"
def joox_regional_playlist_link(region: str, raw_id: Any) -> str:
return f"https://www.joox.com/{region}/playlist/{normalize_joox_id(raw_id)}"
def joox_playlist_link(raw_id: Any) -> str:
return joox_regional_playlist_link("id", raw_id)
def joox_playlist_links(raw_id: Any) -> list[str]:
"""歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用)。"""
return [joox_regional_playlist_link(region, raw_id) for region in ("id", "sg", "hk")]
def pick_joox_image(images: Any) -> str:
"""封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空)。"""
items = images if isinstance(images, list) else []
for width in (300, 1000, 100):
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if to_int(image.get("width")) == width and url:
return url
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if url:
return url
return ""
def joox_artists(items: Any) -> str:
"""歌手拼接(上游 joinJooxArtists,分隔符 ' / ')。"""
listing = items if isinstance(items, list) else []
names = [str(entry.get("name") or "").strip() for entry in listing if isinstance(entry, dict)]
return " / ".join(name for name in names if name)
def first_non_empty(*values: Any) -> Any:
for value in values:
if value is None:
continue
if str(value).strip() != "":
return value
return ""
def first_non_zero(*values: Any) -> int | float:
for value in values:
number = to_number(value)
if number is not None and number > 0:
return number
return 0
def to_number(value: Any) -> float | None:
"""对齐 JS Number():有限数值返回 float,其余返回 None。"""
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
number = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return 0.0
try:
number = float(text)
except ValueError:
return None
else:
return None
if number != number or number in (float("inf"), float("-inf")):
return None
return number
def to_int(value: Any) -> int | None:
number = to_number(value)
return None if number is None else int(number)
def decode_joox_base64_text(value: Any) -> str:
"""base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充)。"""
raw = str(value if value is not None else "").strip()
if not raw:
return ""
normalized = raw.replace("-", "+").replace("_", "/")
padded = normalized + "=" * ((4 - len(normalized) % 4) % 4)
try:
text = base64.b64decode(padded, validate=False).decode("utf-8", errors="replace")
except (ValueError, TypeError):
return raw
# 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
return raw if "\ufffd" in text else text.strip()
def decode_html_entities(text: Any) -> str:
"""HTML 实体解码(歌词文本兜底)。"""
return (
str(text if text is not None else "")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&apos;", "'")
.replace("&#0*39;", "'")
.replace("&nbsp;", " ")
)
def decode_joox_lyric(value: Any) -> str:
"""歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底)。"""
return decode_html_entities(decode_joox_base64_text(value))
_NEXT_DATA_RE = re.compile(r'<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)</script>')
def extract_joox_next_data(html: Any) -> Any:
"""从 SSR 页面提取 __NEXT_DATA__ JSON。"""
match = _NEXT_DATA_RE.search(str(html if html is not None else ""))
if not match:
return None
try:
return json.loads(match.group(1))
except ValueError:
return None
async def fetch_joox_page(url: str, max_redirect: int = 3) -> dict[str, Any]:
"""拉取页面并手动跟随 3xx(request.py 不自动跟跳;Go 客户端会自动跟)。
返回 ``{"statusCode", "body", "finalUrl"}``(非字符串 body 视为空串)。
"""
current = url
for _ in range(max_redirect + 1):
res = await http_fetch(current, headers=joox_headers(), timeout=JOOX_TIMEOUT)
location = res.header("location")
if 300 <= res.status_code < 400 and location:
current = urljoin(current, location)
continue
body = res.body if isinstance(res.body, str) else ""
return {"statusCode": res.status_code, "body": body, "finalUrl": current}
raise RuntimeError("joox 页面重定向次数过多")