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

+53
View File
@@ -0,0 +1,53 @@
"""喜马拉雅(有声书/播客)平台模块(对应 sdk/xm/index.ts)。
匿名可用面(2026-10-02 实测):搜索 / 专辑曲目 / 免费曲目取流(PC baseInfo 通道 + 解密直链)/ 封面。
付费与 VIP 内容:绑定登录 Cookie(POST /login/xm,须含 1&_token)后 baseInfo 授权放行;
超清(XimiUhq)需会员。有声内容不参与聚合搜索与跨平台匹配(sdk 标记 noAggregate),
仅按 source=xm 直查。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.xm import album, music_search
from app.sdk.platforms.xm.music_info import cover_url, get_track_info
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.xm import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_pic(song_info: dict[str, Any]) -> str | None:
return cover_url(await get_track_info(str(song_info.get("songmid") or "")))
class _XmAlbum:
get_album_detail = staticmethod(album.get_album_detail)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"https://www.ximalaya.com/sound/{song_info.get('songmid') or ''}"
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断,无在线校验接口)。"""
cred = get_credentials("xm") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="xm",
name="喜马拉雅",
music_search=music_search,
album=_XmAlbum,
get_music_url=_get_music_url,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+71
View File
@@ -0,0 +1,71 @@
"""喜马拉雅专辑曲目列表(对应 sdk/xm/album.ts,免签匿名接口)。
接口: https://www.ximalaya.com/revision/album/getTracksList?albumId=&pageNum=&sort=0
实测(2026-10-02)匿名可翻全部页(固定每页 30 条);仅曲目元信息(trackId/title/isPaid),
播放地址需另走 baseInfo。注意 v1 版接口(/revision/album/v1/getTracksList)需要 webtk 签名且有风控。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.xm.sign import XM_UA
TRACKS_URL = "https://www.ximalaya.com/revision/album/getTracksList"
PAGE_SIZE = 30
async def get_tracks_page(album_id: str, page_num: int) -> dict[str, Any]:
"""抓取专辑第 page_num 页曲目(返回 {total, tracks})。"""
resp = await http_fetch(
f"{TRACKS_URL}?albumId={album_id}&pageNum={page_num}&sort=0",
headers={"User-Agent": XM_UA, "Referer": f"https://www.ximalaya.com/album/{album_id}"},
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("ret") != 200 or not body.get("data"):
raise RuntimeError(str(body.get("msg") or f"获取专辑曲目失败(ret {body.get('ret', resp.status_code)})"))
data = as_dict(body.get("data"))
return {
"total": int(data.get("trackTotalCount") or 0),
"tracks": [item for item in as_list(data.get("tracks")) if isinstance(item, dict)],
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
"""专辑详情 + 指定页曲目(/album 路由适配:{info, list},接口固定每页 30 条)。"""
result = await get_tracks_page(album_id, page)
total = result["total"]
album_name = ""
songs: list[dict[str, Any]] = []
for item in result["tracks"][:limit]:
if not album_name and item.get("albumTitle"):
album_name = str(item["albumTitle"])
songs.append(
{
"songmid": str(item.get("trackId")),
"name": item.get("title") or str(item.get("trackId")),
"singer": item.get("albumTitle") or "",
"albumId": str(album_id),
"albumName": item.get("albumTitle") or "",
"interval": 0,
"payPlay": bool(item.get("isPaid")),
"img": None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {
"info": {"name": album_name or str(album_id), "total": total},
"list": songs,
"total": total,
"limit": limit,
"allPage": math.ceil(total / PAGE_SIZE) or page,
"source": "xm",
}
+354
View File
@@ -0,0 +1,354 @@
"""喜马拉雅播放地址解码(对应 sdk/xm/decoder.ts,移植 Ximalaya-Downloader-Next adapters/decoder.py)。
算法:URL-safe Base64 → 尾 16 字节为 IV,正文先查 S-Box 置换表,
再按 16 字节块与 IV 异或、按 32 字节块与固定密钥异或,还原明文 URL。
PC 桌面端(device=win)另有 AES-ECB 变体(密钥取自官方客户端 4.0.14 asar)。
"""
from __future__ import annotations
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.platforms.kg.util import pkcs7_unpad
#: S-Box 置换表(www2/mweb2 设备类型)
PERMUTATION_TABLE_O = [
183,
174,
108,
16,
131,
159,
250,
5,
239,
110,
193,
202,
153,
137,
251,
176,
119,
150,
47,
204,
97,
237,
1,
71,
177,
42,
88,
218,
166,
82,
87,
94,
14,
195,
69,
127,
215,
240,
225,
197,
238,
142,
123,
44,
219,
50,
190,
29,
181,
186,
169,
98,
139,
185,
152,
13,
141,
76,
6,
157,
200,
132,
182,
49,
20,
116,
136,
43,
155,
194,
101,
231,
162,
242,
151,
213,
53,
60,
26,
134,
211,
56,
28,
223,
107,
161,
199,
15,
229,
61,
96,
41,
66,
158,
254,
21,
165,
253,
103,
89,
3,
168,
40,
246,
81,
95,
58,
31,
172,
78,
99,
45,
148,
187,
222,
124,
55,
203,
235,
64,
68,
149,
180,
35,
113,
207,
118,
111,
91,
38,
247,
214,
7,
212,
209,
189,
241,
18,
115,
173,
25,
236,
121,
249,
75,
57,
216,
10,
175,
112,
234,
164,
70,
206,
198,
255,
140,
230,
12,
32,
83,
46,
245,
0,
62,
227,
72,
191,
156,
138,
248,
114,
220,
90,
84,
170,
128,
19,
24,
122,
146,
80,
39,
37,
8,
34,
22,
11,
93,
130,
63,
154,
244,
160,
144,
79,
23,
133,
92,
54,
102,
210,
65,
67,
27,
196,
201,
106,
143,
52,
74,
100,
217,
179,
48,
233,
126,
117,
184,
226,
85,
171,
167,
86,
2,
147,
17,
135,
228,
252,
105,
30,
192,
129,
178,
120,
36,
145,
51,
163,
77,
205,
73,
4,
188,
125,
232,
33,
243,
109,
224,
104,
208,
221,
59,
9,
]
XOR_KEY_A = [
204,
53,
135,
197,
39,
73,
58,
160,
79,
24,
12,
83,
180,
250,
101,
60,
206,
30,
10,
227,
36,
95,
161,
16,
135,
150,
235,
116,
242,
116,
165,
171,
]
#: PC 桌面端 playUrlList 的 AES-ECB 密钥(客户端 4.0.14 asar Gt 函数提取)
WIN_AES_KEY = bytes.fromhex("aaad3e4fd540b0f79dca95606e72bf93")
def _b64url_to_buf(text: str) -> bytes:
cleaned = text.replace("_", "/").replace("-", "+")
cleaned += "=" * (-len(cleaned) % 4)
return base64.b64decode(cleaned)
def decode_www2_url(encrypted: str) -> str:
"""www2/mweb2 加密 URL → 明文 URL。"""
if not encrypted:
return ""
if encrypted.startswith("http"):
return encrypted
decoded = _b64url_to_buf(encrypted)
if len(decoded) < 16:
raise RuntimeError("密文长度不足(缺少 IV)")
body = bytearray(decoded[:-16])
iv = decoded[-16:]
for i in range(len(body)):
body[i] = PERMUTATION_TABLE_O[body[i]]
for i in range(0, len(body), 16):
count = min(16, len(body) - i)
for j in range(count):
body[i + j] ^= iv[j]
for i in range(0, len(body), 32):
count = min(32, len(body) - i)
for j in range(count):
body[i + j] ^= XOR_KEY_A[j]
return bytes(body).decode("utf-8", errors="replace")
def decode_win_url(encrypted: str) -> str:
"""PC 桌面端(device=win)AES-ECB 加密 URL → 明文 URL(解出的是带签名付费地址)。"""
if not encrypted:
return ""
if encrypted.startswith("http"):
return encrypted
decryptor = Cipher(algorithms.AES(WIN_AES_KEY), modes.ECB()).decryptor()
plain = decryptor.update(_b64url_to_buf(encrypted)) + decryptor.finalize()
return pkcs7_unpad(plain).decode("utf-8", errors="replace")
+260
View File
@@ -0,0 +1,260 @@
"""喜马拉雅 du_web_sdk 设备指纹模板(对应 sdk/xm/deviceInfo.ts)。
内置公共指纹,与 Ximalaya-Downloader-Next 的 device_info_default.json 同源;
Zf5 时间戳每次上报前刷新;UA 字段 yV2 与请求 UA 保持一致。
"""
from __future__ import annotations
from typing import Any
DEVICE_INFO: dict[str, Any] = {
"Zf5": 0,
"GF9": "2.0.0",
"HW5": "t6pfoml9679z52kqw93uqu75eflqdg1bykhl",
"uS7": "",
"KFp": "h5_goyxvzyohd",
"ew1": {
"Wg7": "Mozilla",
"lV1": "Google Inc.",
"Xt4": "Netscape",
"yV2": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 "
"Safari/537.36",
"KY1": "Win32",
"Le3": "Tmxhcm9vei81LjAgKERybXdsZGggTUcgMTAuMDsgRHJtNjQ7IGM2NCkgWmtrb3ZEdnlQcmcvNTM3LjM2IChQU0dOTywgb3JwdiBUdnhwbCkgWHNpbG52LzEyNS4wLjAuMCBIenV6aXIvNTM3LjM2",
"kH1": 900,
"ad5": 1440,
"Ua9": 24,
"TQ6": 900,
"kC7": 1440,
"me8": 24,
"eY9": True,
"Kn2": False,
"OM3": True,
"sw8": False,
"uW3": -1,
"iO8": "https://www.ximalaya.com/",
"By1": "www.ximalaya.com",
"Gv4": "/",
"ef2": "",
"tZ2": "https:",
"OG4": True,
"kx1": True,
"VD6": True,
"Ov6": False,
"lq3": "zh-CN",
"ef5": ["zh-CN"],
"OK3": 1,
"Fg5": True,
"qS2": [1440, 900, 1440, 900],
"Fc5": "light",
},
"HK3": {
"iI1": {"NF1": -1, "cA1": -1, "NK5": -1, "VP4": "-1.00", "RX5": -1, "VP6": -1, "tJ4": -1},
"ti4": {"xm9": True, "is3": 1},
"AV9": 8,
"aK8": "Google Inc. (0x00001D17)",
"df6": "ANGLE (0x00001D17, ZX C-960 (0x00003A04) Direct3D11 vs_5_0 ps_5_0, D3D11)",
"WB9": "8ba05e37b4b3f57bddb4904eb8f40204",
"pD7": "d41d8cd98f00b204e9800998ecf8427e",
"da2": "124.04347527516074",
"dt2": 16,
"Sy6": -480,
"MS3": "Asia/Shanghai",
"pi9": False,
"Ao1": [],
"BH5": 0,
"UG4": [
"Arial",
"Arial Black",
"Arial Narrow",
"Calibri",
"Cambria",
"Cambria Math",
"Comic Sans MS",
"Consolas",
"Courier",
"Courier New",
"Georgia",
"Helvetica",
"Impact",
"Lucida Console",
"Lucida Sans Unicode",
"Microsoft Sans Serif",
"MS Gothic",
"MS PGothic",
"MS Sans Serif",
"MS Serif",
"Palatino Linotype",
"Segoe Print",
"Segoe Script",
"Segoe UI",
"Segoe UI Light",
"Segoe UI Semibold",
"Segoe UI Symbol",
"Tahoma",
"Times",
"Times New Roman",
"Trebuchet MS",
"Verdana",
"Wingdings",
],
},
"fc9": {"cx4": "4g", "zY8": -1, "yj6": 1.5, "dV4": 200, "yX4": False},
"adi": "070BF8:016D89:6BDE99:1600",
"acd": "D2t6yNNzRqtSbN4GtZw4eGLn8tfc0Y1EYzqzMOCd9GiGMX17",
"bdi": None,
"bcd": None,
"fd2": {
"Pf5": 1783179255284,
"Ja5": "070BF8:016D89:6BDE99:1600",
"xz7": "2ccd7426-e999-efba-78c5-bbea1fc69729",
"av1": "D2t6yNNzRqtSbN4GtZw4eGLn8tfc0Y1EYzqzMOCd9GiGMX17",
"cp9": 0,
},
"exts": "",
"startime": 1783179258568,
"Zn6": {
"oe2": "0",
"EV9": "true",
"xu2": "1441A790908125E9682A828824A003E99783D166409BBC4DD782F461C2955BA7",
"CY8": "",
"nE4": 928,
"Tw1": [5799.099999904633, 6727.399999856949],
"Sb1": False,
},
"jm9": 2,
"dla": "",
"swp": "",
"ecm": "",
"emm": "",
"asu": "",
"asu1": 0,
"GJ2": "31b33ada-785b-efc7-cbad-c985880ba125-fcs011",
"slw": "",
"bds": "",
"MT7": "33-00000-0000-1111111-000000-0011-000000-0000-00000-0",
"bnd": "-0",
"kec": "000000",
"BG5": False,
"Fd8": "1",
"iq7": False,
"DP5": "1441A7909C087DBBE7CE59881B9DF8B9",
"lL1": "63649ded5da25cce1ae7d5f2a950c18a",
"uT8": "-1",
"sV5": 2,
"Vo6": "",
"infoF": {"lof": False, "baf": False, "auf": False, "iif": False},
"infoCallback": None,
"url_host": "hdaa.shuzilm.cn",
"_cidStorage": {
"_key": "cmci9xde",
"_value": "U2FsdGVkX19G9YbXMDClJDhN2IT4GtXDHuuLUlzrmuZDqisRyfuqlB9Im8cJ3oWqqi8rKpuwj6i0PFh2Ym6gmg==",
"_saveObject": True,
},
"_pkgStorage": {"_key": "pmck9xge", "_value": "U2FsdGVkX19GQAJ5RgJtjxT5ljFskRYgi6bFYacB4yM=", "_saveObject": True},
"_checkextensions": {
"_deviceInfoCollector": {
"Zf5": -1,
"GF9": "2.0.0",
"HW5": "",
"uS7": "",
"KFp": "",
"ew1": {
"Wg7": "",
"lV1": "",
"Xt4": "",
"yV2": "",
"KY1": "",
"Le3": "",
"kH1": -1,
"ad5": -1,
"Ua9": -1,
"TQ6": -1,
"kC7": -1,
"me8": -1,
"eY9": None,
"OM3": None,
"Kn2": None,
"OG4": None,
"kx1": None,
"VD6": None,
"Ov6": None,
"sw8": None,
"uW3": -1,
"lq3": "",
"ef5": "",
"iO8": "",
"By1": "",
"Gv4": "",
"ef2": "",
"tZ2": "",
"OK3": -1,
"Fg5": False,
"qS2": [],
"Fc5": "",
},
"HK3": {
"iI1": {"NF1": -1, "cA1": -1, "NK5": -1, "VP4": -1, "RX5": -1, "VP6": -1, "tJ4": -1},
"ti4": {"xm9": None, "is3": -1},
"AV9": -1,
"aK8": "",
"df6": "",
"WB9": "",
"pD7": "",
"da2": -1,
"dt2": -1,
"Sy6": -1,
"MS3": "",
"pi9": None,
"Ao1": [],
"BH5": -1,
"UG4": [],
},
"fc9": {"cx4": None, "ui9": None, "zY8": -1, "yj6": -1, "dV4": -1, "eN7": "", "yX4": False},
"adi": "",
"acd": "",
"bdi": "",
"bcd": "",
"fd2": {},
"exts": "",
"startime": 0,
"Zn6": {"oe2": "", "EV9": "", "xu2": "", "CY8": "", "nE4": -1, "Tw1": [], "Sb1": False},
"jm9": "",
"dla": "",
"swp": "",
"ecm": "",
"emm": "",
"asu": "",
"asu1": 0,
"GJ2": "",
"slw": "",
"bds": "",
"MT7": "",
"bnd": "",
"kec": "",
"BG5": "",
"Fd8": "0",
"iq7": False,
"DP5": "",
"lL1": "",
"uT8": "-1",
"sV5": 1,
"Vo6": "",
"infoF": {"lof": False, "baf": False, "auf": False, "iif": False},
"infoCallback": None,
"url_host": "hdaa.shuzilm.cn",
}
},
"_idstor": {"_value": None},
"_caddStorage": {"_key": "vmce9xdq", "_value": None, "_saveObject": True},
"_ipfStorage": {"_key": "assva6", "_value": "U2FsdGVkX18v3Du2ZHJsNvvtzU+I7D8WBNBlFpRxpNw=", "_saveObject": True},
"_ipflagStorage": {
"_key": "assva5",
"_value": "U2FsdGVkX1+PrpJkbR2dIWLeY/i1/2dGcYLQLh4eDtHqVRt/LkERQlZiSdem69zrRZOIHhv7pHPh3xL4SOxCsw==",
"_saveObject": True,
},
"_checkintacts": {},
"_checkdetects": {},
"_getmousetest": {},
}
+84
View File
@@ -0,0 +1,84 @@
"""喜马拉雅曲目信息(对应 sdk/xm/musicInfo.ts,PC 桌面端 baseInfo 通道)。
接口: https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/{ms_ts}?device=win&trackId=&trackQualityLevel=1
实测(2026-10-02)匿名免费曲目可用,但必须同时满足:
1) 携带 PC 设备 Cookie(install_id 与 1&_device 同一 uuid,channel=99&100001,客户端 4.0.14 抓包格式)
2) 携带 du_web_sdk 真实 xm-sign(hdaa 上报取 cadd/sid)
缺 Cookie 或换 device=www2 均返回 ret=1001 系统繁忙。
响应 trackInfo 顶层挂载(playUrlList/title/cover*/duration/isPaid/isAuthorized/isXimiUhqTrack),
playUrlList[].url 为 AES-ECB 加密地址(见 decoder.py)。
"""
from __future__ import annotations
import time
import uuid
from typing import Any
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.xm.sign import XM_UA, make_xm_sign
BASE_INFO_URL = "https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/"
#: 进程级设备标识(install_id 与 1&_device 共用同一 uuid,稳定复用)
DEVICE_UUID = str(uuid.uuid4())
PC_DEVICE_COOKIE = f"install_id={DEVICE_UUID}; channel=99&100001; 1&_device=win32&{DEVICE_UUID}&4.0.14"
def build_request_cookie() -> str:
"""登录态 Cookie(/login/xm 绑定,须含 1&_token);与 PC 设备 Cookie 合并后上送。"""
login_cookie = str((get_credentials("xm") or {}).get("cookie") or "")
return f"{login_cookie}; {PC_DEVICE_COOKIE}" if login_cookie else PC_DEVICE_COOKIE
def cover_url(track_info: Any) -> str | None:
info = as_dict(track_info)
path = info.get("coverMiddle") or info.get("coverLarge") or info.get("coverSmall")
if not path:
return None
text = str(path)
return text if text.startswith("http") else f"https://imagev2.xmcdn.com/{text}"
async def get_track_info(track_id: str, track_quality_level: int = 1) -> dict[str, Any]:
"""拉取曲目播放信息(免登免费曲可用;付费/VIP 曲 ret=1001 或 isAuthorized=false)。"""
xm_sign = await make_xm_sign()
resp = await http_fetch(
f"{BASE_INFO_URL}{int(time.time() * 1000)}?device=win&trackId={track_id}"
f"&trackQualityLevel={track_quality_level}",
headers={
"User-Agent": XM_UA,
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": f"https://www.ximalaya.com/sound/{track_id}",
"Origin": "https://www.ximalaya.com",
"Cookie": build_request_cookie(),
"xm-sign": xm_sign,
},
timeout=20_000,
)
if resp.status_code != 200:
raise RuntimeError(f"baseInfo HTTP {resp.status_code}")
body = as_dict(resp.body)
ret = body.get("ret")
if ret != 0:
# 1001:匿名访问付费内容与风控共用;未登录时优先提示绑定登录
logged = bool((get_credentials("xm") or {}).get("cookie"))
if ret == 1001 and not logged:
raise RuntimeError("该内容需要登录(POST /login/xm 绑定 cookie 后重试)")
raise RuntimeError(str(body.get("msg") or f"baseInfo ret {ret}"))
track_info = body.get("trackInfo")
if track_info is None:
track_info = as_dict(body.get("data")).get("trackInfo")
if not isinstance(track_info, dict):
raise RuntimeError("baseInfo 未返回 trackInfo")
return track_info
def play_url_list(track_info: dict[str, Any]) -> list[dict[str, Any]]:
"""trackInfo.playUrlList 的安全读取(供取流使用)。"""
return [item for item in as_list(track_info.get("playUrlList")) if isinstance(item, dict)]
+85
View File
@@ -0,0 +1,85 @@
"""喜马拉雅搜索(对应 sdk/xm/musicSearch.ts,web 通道)。
接口: https://www.ximalaya.com/revision/search?core=track&kw=&page=&rows=&spellchecker=true&condition=relation&device=web
实测(2026-10-02)匿名可用(注意:同参数的 /revision/search/main 与 /search/main 会命中
risk invalid 风控,本端点 + condition=relation&device=web 不受影响)。
返回 data.result.response.docs[],字段 id/title/duration(秒)/nickname/album_id/album_title/
album_cover_path/is_paid/is_trailer。
"""
from __future__ import annotations
import math
from typing import Any
from urllib.parse import urlencode
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.xm.sign import XM_UA
SEARCH_URL = "https://www.ximalaya.com/revision/search"
def _normalize_cover(path: Any) -> str | None:
if not path:
return None
text = str(path)
return text if text.startswith("http") else f"https://imagev2.xmcdn.com/{text}"
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,web 搜索无重试语义
params = urlencode(
{
"core": "track",
"kw": keyword,
"page": str(page),
"rows": str(limit),
"spellchecker": "true",
"condition": "relation",
"device": "web",
}
)
resp = await http_fetch(
f"{SEARCH_URL}?{params}",
headers={
"User-Agent": XM_UA,
"Accept": "application/json, text/plain, */*",
"Referer": "https://www.ximalaya.com/",
},
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("ret") != 200:
raise RuntimeError(str(body.get("msg") or "搜索失败"))
response = as_dict(as_dict(as_dict(as_dict(body.get("data")).get("result")).get("response")))
docs = [item for item in as_list(response.get("docs")) if isinstance(item, dict)]
total = int(response.get("numFound") or len(docs))
songs = []
for item in docs:
duration = item.get("duration")
songs.append(
{
"songmid": str(item.get("id")),
"name": item.get("title") or "",
"singer": item.get("nickname") or "",
"albumId": str(item["album_id"]) if item.get("album_id") else "",
"albumName": item.get("album_title") or "",
"interval": format_play_time(float(duration)) if duration else 0,
"payPlay": bool(item.get("is_paid")),
"img": _normalize_cover(item.get("album_cover_path")),
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {
"allPage": math.ceil(total / limit) or page,
"total": total,
"limit": limit,
"source": "xm",
"list": songs,
}
+70
View File
@@ -0,0 +1,70 @@
"""喜马拉雅 xm-sign 生成(对应 sdk/xm/sign.ts)—— du_web_sdk 签名链。
协议:device_info → JSON → URL 编码字节 → zlib(level 6) → AES-128-ECB(PKCS7, KEY) →
POST https://hdaa.shuzilm.cn/report?v=1.2.0&e=1&c=1&r=<uuid>(octet-stream)→
Base64 响应 → AES-ECB 解密 → JSON 取 cadd/sid → xm-sign = "{cadd}&&{sid}"
KEY 为 du_web_sdk _getDeviceKey(0) 的硬编码密钥;每次签名都重新上报(不缓存)。
模板为纯 ASCII(Base64/十六进制字段),latin1 取字节与参考实现的 charCode 语义一致。
"""
from __future__ import annotations
import base64
import json
import time
import uuid
import zlib
from typing import Any
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.util import pkcs7_pad, pkcs7_unpad
from app.sdk.platforms.xm.device_info import DEVICE_INFO
SIGN_KEY = b"m9ZtRrz:qujT8@da"
REPORT_URL = "https://hdaa.shuzilm.cn/report?v=1.2.0&e=1&c=1&r="
#: 与 device_info.ew1.yV2(Chrome 125 模板)保持一致
XM_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
def _aes_ecb(data: bytes, decrypt: bool) -> bytes:
cipher = Cipher(algorithms.AES(SIGN_KEY), modes.ECB())
if decrypt:
decryptor = cipher.decryptor()
return pkcs7_unpad(decryptor.update(data) + decryptor.finalize())
encryptor = cipher.encryptor()
return encryptor.update(pkcs7_pad(data)) + encryptor.finalize()
async def make_xm_sign() -> str:
"""生成 xm-sign(形如 "cadd&&sid");每次调用重新上报设备指纹。"""
info: dict[str, Any] = {**DEVICE_INFO, "Zf5": int(time.time() * 1000)}
raw = json.dumps(info, ensure_ascii=False, separators=(",", ":")).encode("latin-1")
payload = _aes_ecb(zlib.compress(raw, 6), False)
resp = await http_fetch(
f"{REPORT_URL}{uuid.uuid4()}",
method="POST",
headers={
"Content-Type": "application/octet-stream",
"User-Agent": XM_UA,
"Host": "hdaa.shuzilm.cn",
},
body=payload,
timeout=15_000,
)
text = resp.raw.decode("utf-8", errors="replace") if resp.raw else str(resp.body or "")
if resp.status_code != 200:
raise RuntimeError(f"xm-sign 上报失败: HTTP {resp.status_code}")
decrypted = _aes_ecb(base64.b64decode(text), True).decode("utf-8", errors="replace")
obj = json.loads(decrypted)
cadd = str(obj.get("cadd") or "")
sid = str(obj.get("sid") or "")
if not cadd or not sid:
raise RuntimeError("xm-sign 上报响应缺少 cadd/sid")
return f"{cadd}&&{sid}"