feat(xm): 喜马拉雅平台接入——匿名搜索/专辑/免费曲直链(hdaa xm-sign+AES-ECB 解密)、cookie 绑定解锁付费

This commit is contained in:
snowy-yang committed 2026-10-02 19:43:09 +08:00
1 parent 44852864e2
commit 5a9915b74c
8 files changed
+717

No files matched your search

+58
View File
@@ -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 }
+71
View File
@@ -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 }
+329
View File
@@ -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>
+34
View File
@@ -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
+64
View File
@@ -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 }
+56
View File
@@ -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 }
+53
View File
@@ -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 }