mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-08-12 08:50:38 +00:00
159 lines
4.8 KiB
JavaScript
159 lines
4.8 KiB
JavaScript
// 易盾反作弊 Token 注册端点
|
||
// 通过易盾官方 Watchman SDK(Web 版,跑在 jsdom 模拟的浏览器环境里)
|
||
// 实时调用 getToken(businessId) 获取反作弊 token,供后续带 checkToken 的请求使用
|
||
//
|
||
// GET /register/checktoken/v2 → 实时获取新 token(不缓存)
|
||
// POST /register/checktoken/v2 → 实时获取新 token
|
||
//
|
||
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token,
|
||
// 避免反作弊 token 复用触发风控。
|
||
//
|
||
// 注:jsdom 固定用 v24(engines >=18),其依赖链为纯 CJS,可被 pkg 静态
|
||
// 分析自动打包;v30+ 引入纯 ESM 依赖且要求 Node >=22,无法在 CI(18-24) 与
|
||
// pkg(node18) 目标下运行。
|
||
//
|
||
const { JSDOM, VirtualConsole } = require('jsdom')
|
||
const { default: axios } = require('axios')
|
||
const { APP_CONF } = require('../util/config.json')
|
||
const logger = require('../util/logger')
|
||
|
||
// 网易云音乐在易盾的 productNumber 与 businessId
|
||
const PRODUCT_NUMBER = 'YD00000558929251'
|
||
const BUSINESS_ID = 'bd5d2f973ef74cd2a61325a412ae54d9'
|
||
const TOOL_JS_URL = `${APP_CONF.dunStaticDomain}/tool.min.js`
|
||
|
||
// 最小 HTML 外壳,模拟网页环境
|
||
const HTML =
|
||
'<!doctype html><html><head><meta charset="UTF-8"></head><body></body></html>'
|
||
|
||
let toolJs = ''
|
||
let wm = null // Watchman 实例(进程内复用,可反复 getToken)
|
||
let dom = null // jsdom 实例(失败时 close 释放活动句柄,避免泄漏)
|
||
let initPromise = null
|
||
|
||
// 获取 tool.min.js(内存缓存,避免每次初始化重复下载)
|
||
async function getToolJs() {
|
||
if (toolJs) return toolJs
|
||
const res = await axios.get(TOOL_JS_URL, { timeout: 10000 })
|
||
toolJs = String(res.data)
|
||
return toolJs
|
||
}
|
||
|
||
// 初始化 Watchman(进程内只初始化一次,实例可反复 getToken)
|
||
async function ensureWatchman() {
|
||
if (wm) return wm
|
||
if (initPromise) return initPromise
|
||
|
||
initPromise = (async () => {
|
||
const js = await getToolJs()
|
||
const virtualConsole = new VirtualConsole()
|
||
virtualConsole.on('jsdomError', () => {})
|
||
dom = new JSDOM(HTML, {
|
||
url: 'https://music.163.com/',
|
||
referrer: 'https://music.163.com/',
|
||
contentType: 'text/html',
|
||
runScripts: 'dangerously',
|
||
resources: 'usable', // 允许动态加载 watchman.min.js / JSONP
|
||
pretendToBeVisual: true,
|
||
virtualConsole,
|
||
beforeParse(window) {
|
||
// 抹掉 headless 特征,避免易盾风控误判
|
||
Object.defineProperty(window.navigator, 'webdriver', {
|
||
get: () => undefined,
|
||
})
|
||
window.chrome = { runtime: {} }
|
||
window.navigator.languages = ['zh-CN', 'zh']
|
||
window.navigator.plugins = [1, 2, 3, 4, 5]
|
||
},
|
||
})
|
||
const script = dom.window.document.createElement('script')
|
||
script.textContent = js
|
||
dom.window.document.body.appendChild(script)
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let settled = false
|
||
const timer = setTimeout(() => {
|
||
if (settled) return
|
||
settled = true
|
||
reject(new Error('watchman 初始化超时'))
|
||
}, 15000)
|
||
dom.window.initWatchman({
|
||
auto: true,
|
||
productNumber: PRODUCT_NUMBER,
|
||
onload(instance) {
|
||
if (settled) return
|
||
settled = true
|
||
clearTimeout(timer)
|
||
wm = instance
|
||
resolve(instance)
|
||
},
|
||
onerror(...args) {
|
||
if (settled) return
|
||
settled = true
|
||
clearTimeout(timer)
|
||
reject(new Error('watchman 初始化失败'))
|
||
},
|
||
})
|
||
})
|
||
})()
|
||
|
||
try {
|
||
return await initPromise
|
||
} catch (e) {
|
||
// 失败路径:关闭 jsdom 释放 rAF/子资源/定时器等活动句柄,防止每次失败泄漏约 30MB
|
||
if (dom) {
|
||
dom.window.close()
|
||
dom = null
|
||
}
|
||
initPromise = null
|
||
wm = null
|
||
throw e
|
||
}
|
||
}
|
||
|
||
// 获取新 token
|
||
async function fetchToken() {
|
||
const instance = await ensureWatchman()
|
||
const raw = instance.getInstance()
|
||
|
||
await new Promise((resolve) => {
|
||
const timer = setTimeout(() => resolve(), 15000)
|
||
raw.I(() => {
|
||
clearTimeout(timer)
|
||
resolve()
|
||
})
|
||
})
|
||
|
||
return new Promise((resolve) => {
|
||
const timer = setTimeout(() => resolve(''), 15000)
|
||
instance.getToken(BUSINESS_ID, (tk) => {
|
||
clearTimeout(timer)
|
||
resolve(typeof tk === 'string' ? tk : '')
|
||
})
|
||
})
|
||
}
|
||
|
||
// 端点处理:每次实时获取新 token
|
||
module.exports = async () => {
|
||
let token = ''
|
||
try {
|
||
token = await fetchToken()
|
||
} catch (e) {
|
||
logger.warn('[checkToken v2]', e.message)
|
||
}
|
||
return {
|
||
status: 200,
|
||
body: { code: 200, token, registered: !!token },
|
||
}
|
||
}
|
||
|
||
// 给 request.js 读取用:每次调用实时获取新 token,不缓存
|
||
module.exports.getToken = async () => {
|
||
try {
|
||
return await fetchToken()
|
||
} catch (e) {
|
||
logger.warn('[checkToken v2]', e.message)
|
||
return ''
|
||
}
|
||
}
|