From 5a9915b74c57a10fc3066a4f90f93f581c9d2ec7 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:43:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(xm):=20=E5=96=9C=E9=A9=AC=E6=8B=89?= =?UTF-8?q?=E9=9B=85=E5=B9=B3=E5=8F=B0=E6=8E=A5=E5=85=A5=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8C=BF=E5=90=8D=E6=90=9C=E7=B4=A2/=E4=B8=93=E8=BE=91/?= =?UTF-8?q?=E5=85=8D=E8=B4=B9=E6=9B=B2=E7=9B=B4=E9=93=BE(hdaa=20xm-sign+AE?= =?UTF-8?q?S-ECB=20=E8=A7=A3=E5=AF=86)=E3=80=81cookie=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E8=A7=A3=E9=94=81=E4=BB=98=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/apis/xm.ts | 52 +++++++ sdk/xm/album.ts | 58 ++++++++ sdk/xm/decoder.ts | 71 +++++++++ sdk/xm/deviceInfo.ts | 329 ++++++++++++++++++++++++++++++++++++++++++ sdk/xm/index.ts | 34 +++++ sdk/xm/musicInfo.ts | 64 ++++++++ sdk/xm/musicSearch.ts | 56 +++++++ sdk/xm/sign.ts | 53 +++++++ 8 files changed, 717 insertions(+) create mode 100644 sdk/apis/xm.ts create mode 100644 sdk/xm/album.ts create mode 100644 sdk/xm/decoder.ts create mode 100644 sdk/xm/deviceInfo.ts create mode 100644 sdk/xm/index.ts create mode 100644 sdk/xm/musicInfo.ts create mode 100644 sdk/xm/musicSearch.ts create mode 100644 sdk/xm/sign.ts diff --git a/sdk/apis/xm.ts b/sdk/apis/xm.ts new file mode 100644 index 0000000..53cb96b --- /dev/null +++ b/sdk/apis/xm.ts @@ -0,0 +1,52 @@ +// 喜马拉雅 URL 获取(PC 桌面端 baseInfo 通道) +// 链路:makeXmSign(hdaa 上报) → baseInfo device=win(带 PC 设备 Cookie) → playUrlList +// → decodeWinUrl(AES-ECB, 客户端 4.0.14 asar 密钥) → a.xmcdn.com 带签名直链 +// 实测(2026-10-02)匿名免费曲目 HTTP 200 audio/mp4 可播;付费/VIP 曲 ret=1001(需登录,暂未接) +// 档位命名如 M4A_64/MP3_64/M4A_24/MP3_32/AAC_24(音乐内容另有 MP3_128/M4A_128); +// 喜马拉雅以有声内容为主,码率低于音乐平台,音质映射到 '64k'/'128k' 桶 +// 统一接口约定:apis('xm').getMusicUrl(songInfo, type) → { promise, cancelHttp } +// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值) + +import { getTrackInfo } from '../xm/musicInfo.ts' +import { decodeWinUrl } from '../xm/decoder.ts' + +// 优先 M4A(同码率体积更小),128 档优先 MP3_128 +const QUALITY_PREFERENCE: Record = { + '128k': ['MP3_128', 'M4A_128', 'M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'], + '64k': ['M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'], +} +export const supportQualitys = ['64k', '128k'] + +const bitrateOf = (type: string): number => Number(/(\d+)/.exec(type)?.[1] ?? 0) || 0 +const bucketOf = (bitrate: number): string => (bitrate >= 128 ? '128k' : '64k') + +export const getMusicUrl = (songInfo, type) => { + const requestObj = { cancelHttp() {}, promise: null as any } + const trackId = String(songInfo.songmid ?? '') + + requestObj.promise = (async() => { + const trackInfo = await getTrackInfo(trackId) + const playList: any[] = trackInfo.playUrlList ?? [] + if (!playList.length) throw new Error('无可用播放资源(付费/版权内容需登录)') + + const preference = QUALITY_PREFERENCE[type] ?? QUALITY_PREFERENCE['128k'] + let picked: { type: string, url: string } | null = null + for (const want of preference) { + const hit = playList.find(item => item?.type === want && item.url) + if (hit) { + picked = { type: hit.type, url: hit.url.startsWith('http') ? hit.url : decodeWinUrl(hit.url) } + break + } + } + if (!picked) { + // 兜底:未知档位命名时取码率最高的一条 + const best = [...playList].filter(item => item?.url).sort((a, b) => bitrateOf(b.type) - bitrateOf(a.type))[0] + if (!best) throw new Error('无可用播放资源') + picked = { type: best.type, url: best.url.startsWith('http') ? best.url : decodeWinUrl(best.url) } + } + return { type: bucketOf(bitrateOf(picked.type)), url: picked.url } + })() + return requestObj +} + +export default { getMusicUrl } diff --git a/sdk/xm/album.ts b/sdk/xm/album.ts new file mode 100644 index 0000000..1399076 --- /dev/null +++ b/sdk/xm/album.ts @@ -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 } diff --git a/sdk/xm/decoder.ts b/sdk/xm/decoder.ts new file mode 100644 index 0000000..5534fa8 --- /dev/null +++ b/sdk/xm/decoder.ts @@ -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 } diff --git a/sdk/xm/deviceInfo.ts b/sdk/xm/deviceInfo.ts new file mode 100644 index 0000000..a693d4b --- /dev/null +++ b/sdk/xm/deviceInfo.ts @@ -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 diff --git a/sdk/xm/index.ts b/sdk/xm/index.ts new file mode 100644 index 0000000..4220610 --- /dev/null +++ b/sdk/xm/index.ts @@ -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 diff --git a/sdk/xm/musicInfo.ts b/sdk/xm/musicInfo.ts new file mode 100644 index 0000000..ab3805a --- /dev/null +++ b/sdk/xm/musicInfo.ts @@ -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 => { + 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 } diff --git a/sdk/xm/musicSearch.ts b/sdk/xm/musicSearch.ts new file mode 100644 index 0000000..65507b8 --- /dev/null +++ b/sdk/xm/musicSearch.ts @@ -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 } diff --git a/sdk/xm/sign.ts b/sdk/xm/sign.ts new file mode 100644 index 0000000..37bb956 --- /dev/null +++ b/sdk/xm/sign.ts @@ -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=(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 => { + 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 }