feat(xm): 喜马拉雅平台接入——匿名搜索/专辑/免费曲直链(hdaa xm-sign+AES-ECB 解密)、cookie 绑定解锁付费
This commit is contained in:
1 parent
44852864e2
commit
5a9915b74c
8 files changed
+717
No files matched your search
@@ -0,0 +1,58 @@
|
||||
// 喜马拉雅专辑曲目列表(免签匿名接口)
|
||||
// 接口: 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 签名且有风控。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { XM_UA } from './sign.ts'
|
||||
|
||||
const TRACKS_URL = 'https://www.ximalaya.com/revision/album/getTracksList'
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
/** 抓取专辑第 pageNum 页曲目(返回 { total, tracks }) */
|
||||
export const getTracksPage = async(albumId: string, pageNum: number) => {
|
||||
const { statusCode, body } = await httpFetch(`${TRACKS_URL}?albumId=${albumId}&pageNum=${pageNum}&sort=0`, {
|
||||
headers: { 'User-Agent': XM_UA, Referer: `https://www.ximalaya.com/album/${albumId}` },
|
||||
timeout: 15000,
|
||||
}).promise as any
|
||||
if (statusCode != 200 || body?.ret != 200 || !body?.data) {
|
||||
throw new Error(body?.msg || `获取专辑曲目失败(ret ${body?.ret ?? statusCode})`)
|
||||
}
|
||||
return {
|
||||
total: Number(body.data.trackTotalCount ?? 0),
|
||||
tracks: body.data.tracks ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑详情 + 指定页曲目(/album 路由适配:{ info, list },接口固定每页 30 条) */
|
||||
export const getAlbumDetail = async(albumId: string, page = 1, limit = 30) => {
|
||||
const { total, tracks } = await getTracksPage(albumId, page)
|
||||
let albumName = ''
|
||||
const list = tracks.slice(0, limit).map((item: any) => {
|
||||
if (!albumName && item.albumTitle) albumName = String(item.albumTitle)
|
||||
return {
|
||||
songmid: String(item.trackId),
|
||||
name: item.title || String(item.trackId),
|
||||
singer: item.albumTitle || '',
|
||||
albumId: String(albumId),
|
||||
albumName: item.albumTitle || '',
|
||||
interval: 0,
|
||||
payPlay: Boolean(item.isPaid),
|
||||
img: null,
|
||||
lrc: '',
|
||||
lrcx: '',
|
||||
types: [],
|
||||
_types: {},
|
||||
disabled: false,
|
||||
}
|
||||
})
|
||||
return {
|
||||
info: { name: albumName || String(albumId), total },
|
||||
list,
|
||||
total,
|
||||
limit,
|
||||
allPage: Math.ceil(total / PAGE_SIZE) || page,
|
||||
source: 'xm',
|
||||
}
|
||||
}
|
||||
|
||||
export default { getTracksPage, getAlbumDetail }
|
||||
@@ -0,0 +1,71 @@
|
||||
// 喜马拉雅 www2 播放地址解码 —— 移植 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)。
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// S-Box 置换表(www2/mweb2 设备类型)
|
||||
const 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,
|
||||
]
|
||||
|
||||
const 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 函数提取)
|
||||
const WIN_AES_KEY = Buffer.from('aaad3e4fd540b0f79dca95606e72bf93', 'hex')
|
||||
|
||||
const b64urlToBuf = (s: string) => {
|
||||
const cleaned = s.replace(/_/g, '/').replace(/-/g, '+')
|
||||
return Buffer.from(cleaned + '='.repeat((4 - cleaned.length % 4) % 4), 'base64')
|
||||
}
|
||||
|
||||
/** www2/mweb2 加密 URL → 明文 URL */
|
||||
export const decodeWww2Url = (encrypted: string): string => {
|
||||
if (!encrypted) return ''
|
||||
if (encrypted.startsWith('http')) return encrypted
|
||||
const decoded = b64urlToBuf(encrypted)
|
||||
if (decoded.length < 16) throw new Error('密文长度不足(缺少 IV)')
|
||||
const body = Uint8Array.from(decoded.subarray(0, decoded.length - 16))
|
||||
const iv = decoded.subarray(decoded.length - 16)
|
||||
|
||||
for (let i = 0; i < body.length; i++) body[i] = PERMUTATION_TABLE_O[body[i]]
|
||||
for (let i = 0; i < body.length; i += 16) {
|
||||
const n = Math.min(16, body.length - i)
|
||||
for (let j = 0; j < n; j++) body[i + j] ^= iv[j]
|
||||
}
|
||||
for (let i = 0; i < body.length; i += 32) {
|
||||
const n = Math.min(32, body.length - i)
|
||||
for (let j = 0; j < n; j++) body[i + j] ^= XOR_KEY_A[j]
|
||||
}
|
||||
return Buffer.from(body).toString('utf8')
|
||||
}
|
||||
|
||||
/** PC 桌面端(device=win)AES-ECB 加密 URL → 明文 URL(解出的是带签名付费地址) */
|
||||
export const decodeWinUrl = (encrypted: string): string => {
|
||||
if (!encrypted) return ''
|
||||
if (encrypted.startsWith('http')) return encrypted
|
||||
const d = crypto.createDecipheriv('aes-128-ecb', WIN_AES_KEY, null)
|
||||
const plain = Buffer.concat([d.update(b64urlToBuf(encrypted)), d.final()])
|
||||
return plain.toString('utf8')
|
||||
}
|
||||
|
||||
export default { decodeWww2Url, decodeWinUrl }
|
||||
@@ -0,0 +1,329 @@
|
||||
// 喜马拉雅 du_web_sdk 设备指纹模板(内置公共指纹,与 Ximalaya-Downloader-Next 的 device_info_default.json 同源)
|
||||
// Zf5 时间戳每次上报前刷新;UA 字段 yV2 与请求 UA 保持一致
|
||||
export const DEVICE_INFO = {
|
||||
"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": null,
|
||||
"bcd": null,
|
||||
"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": null,
|
||||
"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": null,
|
||||
"OM3": null,
|
||||
"Kn2": null,
|
||||
"OG4": null,
|
||||
"kx1": null,
|
||||
"VD6": null,
|
||||
"Ov6": null,
|
||||
"sw8": null,
|
||||
"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": null,
|
||||
"is3": -1
|
||||
},
|
||||
"AV9": -1,
|
||||
"aK8": "",
|
||||
"df6": "",
|
||||
"WB9": "",
|
||||
"pD7": "",
|
||||
"da2": -1,
|
||||
"dt2": -1,
|
||||
"Sy6": -1,
|
||||
"MS3": "",
|
||||
"pi9": null,
|
||||
"Ao1": [],
|
||||
"BH5": -1,
|
||||
"UG4": []
|
||||
},
|
||||
"fc9": {
|
||||
"cx4": null,
|
||||
"ui9": null,
|
||||
"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": null,
|
||||
"url_host": "hdaa.shuzilm.cn"
|
||||
}
|
||||
},
|
||||
"_idstor": {
|
||||
"_value": null
|
||||
},
|
||||
"_caddStorage": {
|
||||
"_key": "vmce9xdq",
|
||||
"_value": null,
|
||||
"_saveObject": true
|
||||
},
|
||||
"_ipfStorage": {
|
||||
"_key": "assva6",
|
||||
"_value": "U2FsdGVkX18v3Du2ZHJsNvvtzU+I7D8WBNBlFpRxpNw=",
|
||||
"_saveObject": true
|
||||
},
|
||||
"_ipflagStorage": {
|
||||
"_key": "assva5",
|
||||
"_value": "U2FsdGVkX1+PrpJkbR2dIWLeY/i1/2dGcYLQLh4eDtHqVRt/LkERQlZiSdem69zrRZOIHhv7pHPh3xL4SOxCsw==",
|
||||
"_saveObject": true
|
||||
},
|
||||
"_checkintacts": {},
|
||||
"_checkdetects": {},
|
||||
"_getmousetest": {}
|
||||
} as Record<string, any>
|
||||
@@ -0,0 +1,34 @@
|
||||
// 喜马拉雅(有声书/播客)平台模块
|
||||
// 匿名可用面(2026-10-02 实测):搜索 / 专辑曲目 / 免费曲目取流(PC baseInfo 通道 + 解密直链)/ 封面。
|
||||
// 付费与 VIP 内容:绑定登录 Cookie(POST /login/xm,须含 1&_token)后 baseInfo 授权放行;
|
||||
// 超清(XimiUhq)需会员。有声内容不参与聚合搜索与跨平台匹配(sdk/index.ts 标记 noAggregate),
|
||||
// 仅按 source=xm 直查。
|
||||
import xmApi from '../apis/xm.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import { getTrackInfo, coverUrl } from './musicInfo.ts'
|
||||
import { getAlbumDetail } from './album.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
const xm = {
|
||||
musicSearch,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return xmApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
getPic(songInfo) {
|
||||
return getTrackInfo(songInfo.songmid).then(coverUrl)
|
||||
},
|
||||
album: { getAlbumDetail },
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
return `https://www.ximalaya.com/sound/${songInfo.songmid}`
|
||||
},
|
||||
}
|
||||
|
||||
/** 登录状态(凭据存在性判断,无在线校验接口) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('xm') as { cookie?: string, updatedAt?: string } | null
|
||||
return cred?.cookie
|
||||
? { logged: true, updatedAt: cred.updatedAt }
|
||||
: { logged: false }
|
||||
}
|
||||
|
||||
export default xm
|
||||
@@ -0,0 +1,64 @@
|
||||
// 喜马拉雅曲目信息(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 加密地址(key 取自官方客户端 4.0.14 asar),见 decoder.ts
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { makeXmSign, XM_UA } from './sign.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
const BASE_INFO_URL = 'https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/'
|
||||
|
||||
// 进程级设备标识(install_id 与 1&_device 共用同一 uuid,稳定复用)
|
||||
const deviceUuid = crypto.randomUUID()
|
||||
const PC_DEVICE_COOKIE = `install_id=${deviceUuid}; channel=99&100001; 1&_device=win32&${deviceUuid}&4.0.14`
|
||||
|
||||
/** 登录态 Cookie(/login/xm 绑定,须含 1&_token);与 PC 设备 Cookie 合并后上送 */
|
||||
export const buildRequestCookie = (): string => {
|
||||
const loginCookie = (getCredentials('xm') as any)?.cookie || ''
|
||||
return loginCookie ? `${loginCookie}; ${PC_DEVICE_COOKIE}` : PC_DEVICE_COOKIE
|
||||
}
|
||||
|
||||
export const coverUrl = (trackInfo: any): string | null => {
|
||||
const path = trackInfo?.coverMiddle || trackInfo?.coverLarge || trackInfo?.coverSmall
|
||||
if (!path) return null
|
||||
const s = String(path)
|
||||
return s.startsWith('http') ? s : `https://imagev2.xmcdn.com/${s}`
|
||||
}
|
||||
|
||||
/** 拉取曲目播放信息(免登免费曲可用;付费/VIP 曲 ret=1001 或 isAuthorized=false) */
|
||||
export const getTrackInfo = async(trackId: string, trackQualityLevel = 1): Promise<any> => {
|
||||
const xmSign = await makeXmSign()
|
||||
const { statusCode, body } = await httpFetch(
|
||||
`${BASE_INFO_URL}${Date.now()}?device=win&trackId=${trackId}&trackQualityLevel=${trackQualityLevel}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': XM_UA,
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
Referer: `https://www.ximalaya.com/sound/${trackId}`,
|
||||
Origin: 'https://www.ximalaya.com',
|
||||
Cookie: buildRequestCookie(),
|
||||
'xm-sign': xmSign,
|
||||
},
|
||||
timeout: 20000,
|
||||
},
|
||||
).promise as any
|
||||
if (statusCode != 200) throw new Error(`baseInfo HTTP ${statusCode}`)
|
||||
const b = body ?? {}
|
||||
if (b.ret != 0) {
|
||||
// 1001:匿名访问付费内容与风控共用;未登录时优先提示绑定登录
|
||||
const logged = Boolean((getCredentials('xm') as any)?.cookie)
|
||||
if (b.ret == 1001 && !logged) throw new Error('该内容需要登录(POST /login/xm 绑定 cookie 后重试)')
|
||||
throw new Error(b.msg || `baseInfo ret ${b.ret}`)
|
||||
}
|
||||
const trackInfo = b.trackInfo ?? b.data?.trackInfo
|
||||
if (!trackInfo) throw new Error('baseInfo 未返回 trackInfo')
|
||||
return trackInfo
|
||||
}
|
||||
|
||||
export default { getTrackInfo, coverUrl }
|
||||
@@ -0,0 +1,56 @@
|
||||
// 喜马拉雅搜索(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
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime } from '../index.ts'
|
||||
import { XM_UA } from './sign.ts'
|
||||
|
||||
const SEARCH_URL = 'https://www.ximalaya.com/revision/search'
|
||||
|
||||
const normalizeCover = (path: unknown): string | null => {
|
||||
if (!path) return null
|
||||
const s = String(path)
|
||||
return s.startsWith('http') ? s : `https://imagev2.xmcdn.com/${s}`
|
||||
}
|
||||
|
||||
export const search = (keyword: string, page = 1, limit = 20) => {
|
||||
const params = new URLSearchParams({
|
||||
core: 'track', kw: keyword, page: String(page), rows: String(limit),
|
||||
spellchecker: 'true', condition: 'relation', device: 'web',
|
||||
})
|
||||
return httpFetch(`${SEARCH_URL}?${params.toString()}`, {
|
||||
headers: { 'User-Agent': XM_UA, Accept: 'application/json, text/plain, */*', Referer: 'https://www.ximalaya.com/' },
|
||||
timeout: 15000,
|
||||
}).promise.then(({ statusCode, body }: any) => {
|
||||
if (statusCode != 200 || body?.ret != 200) throw new Error(body?.msg || '搜索失败')
|
||||
const docs = body?.data?.result?.response?.docs ?? []
|
||||
const total = Number(body?.data?.result?.response?.numFound ?? docs.length)
|
||||
const allPage = Math.ceil(total / limit) || page
|
||||
return {
|
||||
allPage,
|
||||
total,
|
||||
limit,
|
||||
source: 'xm' as const,
|
||||
list: docs.map((item: any) => ({
|
||||
songmid: String(item.id),
|
||||
name: item.title || '',
|
||||
singer: item.nickname || '',
|
||||
albumId: item.album_id ? String(item.album_id) : '',
|
||||
albumName: item.album_title || '',
|
||||
interval: item.duration ? formatPlayTime(Number(item.duration)) : 0,
|
||||
payPlay: Boolean(item.is_paid),
|
||||
img: normalizeCover(item.album_cover_path),
|
||||
lrc: '',
|
||||
lrcx: '',
|
||||
types: [],
|
||||
_types: {},
|
||||
disabled: false,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -0,0 +1,53 @@
|
||||
// 喜马拉雅 xm-sign 生成 —— 移植 Ximalaya-Downloader-Next / easy-sign 的 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 语义一致。
|
||||
|
||||
import zlib from 'node:zlib'
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { DEVICE_INFO } from './deviceInfo.ts'
|
||||
|
||||
const SIGN_KEY = 'm9ZtRrz:qujT8@da'
|
||||
const REPORT_URL = 'https://hdaa.shuzilm.cn/report?v=1.2.0&e=1&c=1&r='
|
||||
// 与 device_info.ew1.yV2(Chrome 125 模板)保持一致
|
||||
export const 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'
|
||||
|
||||
const aesEcb = (data: Buffer, decrypt: boolean) => {
|
||||
const key = Buffer.from(SIGN_KEY, 'utf8')
|
||||
const cipher = decrypt
|
||||
? crypto.createDecipheriv('aes-128-ecb', key, null)
|
||||
: crypto.createCipheriv('aes-128-ecb', key, null)
|
||||
return Buffer.concat([cipher.update(data), cipher.final()])
|
||||
}
|
||||
|
||||
/** 生成 xm-sign(形如 "cadd&&sid");每次调用重新上报设备指纹 */
|
||||
export const makeXmSign = async(): Promise<string> => {
|
||||
const info = { ...DEVICE_INFO, Zf5: Date.now() }
|
||||
const compressed = zlib.deflateSync(Buffer.from(JSON.stringify(info), 'latin1'), { level: 6 })
|
||||
const payload = aesEcb(compressed, false)
|
||||
|
||||
const { statusCode, body, raw } = await httpFetch(`${REPORT_URL}${crypto.randomUUID()}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'User-Agent': XM_UA,
|
||||
Host: 'hdaa.shuzilm.cn',
|
||||
},
|
||||
body: payload,
|
||||
timeout: 15000,
|
||||
}).promise as any
|
||||
const respText = raw ? raw.toString('utf8') : String(body ?? '')
|
||||
if (statusCode != 200) throw new Error(`xm-sign 上报失败: HTTP ${statusCode}`)
|
||||
|
||||
const decrypted = aesEcb(Buffer.from(respText, 'base64'), true).toString('utf8')
|
||||
const obj = JSON.parse(decrypted)
|
||||
const cadd = String(obj.cadd || '')
|
||||
const sid = String(obj.sid || '')
|
||||
if (!cadd || !sid) throw new Error('xm-sign 上报响应缺少 cadd/sid')
|
||||
return `${cadd}&&${sid}`
|
||||
}
|
||||
|
||||
export default { makeXmSign, XM_UA }
|
||||
Reference in new issue
Block a user