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,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,
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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": {},
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}"
|
||||
Reference in new issue
Block a user