mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-10-25 00:51:22 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddfd680301 | ||
|
|
c478367c6c |
@@ -6,7 +6,6 @@ node_modules
|
|||||||
.history
|
.history
|
||||||
examples/moddef.json
|
examples/moddef.json
|
||||||
bin
|
bin
|
||||||
anonymous_token
|
|
||||||
.vercel
|
.vercel
|
||||||
.env
|
.env
|
||||||
IFLOW.md
|
IFLOW.md
|
||||||
|
|||||||
@@ -14,11 +14,12 @@
|
|||||||
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
|
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
|
- `app.js`(也是 `bin`)——服务入口。执行 `generateConfig()` 预热运行时凭证(匿名令牌与 xeapi 公钥,存于内存),再调用 `server.serveNcmApi()`。不依赖 `/tmp` 等可写文件系统。
|
||||||
- `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
|
- `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
|
||||||
- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`。
|
- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`、`generateConfig`(库用户可 `await main.generateConfig()` 预热/续期凭证;不调用也行,首个请求会在凭证缺失时自动引导)。
|
||||||
- `module/*.js`——每个接口一个文件,标准写法:`module.exports = (query, request) => request(path, data, createOption(query))`。`createOption` 在 `util/option.js`,负责 crypto、cookie(回退到 `NETEASE_COOKIE`)、proxy、realIP/randomCNIP、headers、timeout。
|
- `module/*.js`——每个接口一个文件,标准写法:`module.exports = (query, request) => request(path, data, createOption(query))`。`createOption` 在 `util/option.js`,负责 crypto、cookie(回退到 `NETEASE_COOKIE`)、proxy、realIP/randomCNIP、headers、timeout。
|
||||||
- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token` 与 `xeapi_public_key`。
|
- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;匿名令牌与 xeapi 公钥每次请求实时读取 `util/runtimeState.js`,缺失时按需引导(并发共享同一刷新任务,刷新自身的请求带 `skipTokenEnsure` 防自等待死锁)。另导出 `refreshAnonymousToken` / `refreshXeapiPublicKey` 供 `generateConfig` 复用。
|
||||||
|
- `util/runtimeState.js`——统一的进程内运行时动态状态(`anonymousToken` / `xeapiPublicKey`)。纯内存、零文件 I/O;`register_anonimous` / `register_xeapikey` 成功后写入,`util/request.js` 读取,`ensure()` 提供缺失时按需刷新。以后新增动态配置放这里,不要再往 `os.tmpdir()` 写文件。
|
||||||
- `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。
|
- `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。
|
||||||
- `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel(`vercel.json`)和 ESM 导入使用。
|
- `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel(`vercel.json`)和 ESM 导入使用。
|
||||||
- `server.js` 里有一段非显而易见的逻辑:环境变量 `ENABLE_GENERAL_UNBLOCK=true` 时,`/song/url/v1` 的响应会被自动解灰(走 `@neteasecloudmusicapienhanced/unblockmusic-utils`);另外 `query.noCookie` 为真时不向响应写 `Set-Cookie`。
|
- `server.js` 里有一段非显而易见的逻辑:环境变量 `ENABLE_GENERAL_UNBLOCK=true` 时,`/song/url/v1` 的响应会被自动解灰(走 `@neteasecloudmusicapienhanced/unblockmusic-utils`);另外 `query.noCookie` 为真时不向响应写 `Set-Cookie`。
|
||||||
@@ -39,5 +40,5 @@
|
|||||||
- **改 `package.json` 的 `version` 会触发自动发布**:`release-on-version-change.yml` 按 paths 过滤——只要 push 到 `main` 时 `package.json` 有改动且 version 变了,就自动打 tag + GitHub Release(`pkg` 三平台二进制)、推送 Docker 镜像(Docker Hub + GHCR 多架构)、`pnpm publish` 到 npm;tag 已存在则跳过。别顺手改版本号,也别往 `package.json` 里写无关改动凑提交。
|
- **改 `package.json` 的 `version` 会触发自动发布**:`release-on-version-change.yml` 按 paths 过滤——只要 push 到 `main` 时 `package.json` 有改动且 version 变了,就自动打 tag + GitHub Release(`pkg` 三平台二进制)、推送 Docker 镜像(Docker Hub + GHCR 多架构)、`pnpm publish` 到 npm;tag 已存在则跳过。别顺手改版本号,也别往 `package.json` 里写无关改动凑提交。
|
||||||
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged` 和 husky,但 `.husky/` 下只有 `_` 脚手架目录、没有真正的 hook 文件,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
|
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged` 和 husky,但 `.husky/` 下只有 `_` 脚手架目录、没有真正的 hook 文件,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
|
||||||
- **代理环境变量已失效**:README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent,且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。
|
- **代理环境变量已失效**:README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent,且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。
|
||||||
- **启动令牌在系统临时目录**:`anonymous_token`、`xeapi_public_key` 存放在 `os.tmpdir()`,`util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。
|
- **运行时凭证在内存(runtimeState)**:`anonymous_token` 与 `xeapi_public_key` 存于 `util/runtimeState.js`,不做任何文件 I/O(历史上曾以文件形式放 `os.tmpdir()`,为可移植性已移除——只读 FS / Android / iOS / serverless 均可运行)。启动时 `generateConfig()` 先取 xeapi 公钥、再注册匿名令牌(顺序不能反,后者依赖前者);运行中过期可再调 `generateConfig()` 或对应 `register_*` 接口,立即生效、无需重启。
|
||||||
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`)。
|
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`)。
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
const tmpPath = require('os').tmpdir()
|
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
// 检测是否存在 anonymous_token 文件,没有则生成
|
// 启动时预热运行时凭证(匿名令牌 / xeapi 公钥,均存于内存中的 runtimeState)
|
||||||
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
|
|
||||||
fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8')
|
|
||||||
}
|
|
||||||
// 启动时更新anonymous_token
|
|
||||||
const generateConfig = require('./generateConfig')
|
const generateConfig = require('./generateConfig')
|
||||||
await generateConfig()
|
await generateConfig()
|
||||||
require('./server').serveNcmApi({
|
require('./server').serveNcmApi({
|
||||||
|
|||||||
+17
-28
@@ -1,41 +1,30 @@
|
|||||||
const fs = require('fs')
|
const request = require('./util/request')
|
||||||
const path = require('path')
|
const { generateRandomChineseIP, generateDeviceId } = require('./util/index')
|
||||||
const { register_anonimous } = require('./main')
|
|
||||||
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
|
|
||||||
const { getXeapiPublicKey } = require('./util/xeapiKey')
|
|
||||||
const tmpPath = require('os').tmpdir()
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 / 手动刷新运行时动态配置。
|
||||||
|
*
|
||||||
|
* 凭证均存于内存(util/runtimeState.js),不做任何文件 I/O:
|
||||||
|
* - 设备 ID 先行 —— xeapi 公钥注册需携带 deviceId(服务端对无
|
||||||
|
* deviceId 的请求不下发 sk),匿名注册也复用同一设备身份;
|
||||||
|
* - xeapi 公钥其次 —— 注册匿名令牌走 xeapi 加密,依赖该公钥;
|
||||||
|
* - 匿名令牌最后注册,三者就绪后冷启动一次即可完成引导。
|
||||||
|
*/
|
||||||
async function generateConfig() {
|
async function generateConfig() {
|
||||||
global.cnIp = generateRandomChineseIP()
|
global.cnIp = generateRandomChineseIP()
|
||||||
|
if (!global.deviceId) {
|
||||||
|
global.deviceId = generateDeviceId()
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await register_anonimous()
|
await request.refreshXeapiPublicKey()
|
||||||
const cookie = res.body.cookie
|
|
||||||
if (cookie) {
|
|
||||||
const cookieObj = cookieToJson(cookie)
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.resolve(tmpPath, 'anonymous_token'),
|
|
||||||
cookieObj.MUSIC_A,
|
|
||||||
'utf-8',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let currentPublicKey = {}
|
await request.refreshAnonymousToken()
|
||||||
try {
|
|
||||||
currentPublicKey = JSON.parse(
|
|
||||||
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
|
|
||||||
)
|
|
||||||
} catch (_) {}
|
|
||||||
const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId)
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.resolve(tmpPath, 'xeapi_public_key'),
|
|
||||||
JSON.stringify(publicKey),
|
|
||||||
'utf-8',
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = generateConfig
|
module.exports = generateConfig
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const tmpPath = require('os').tmpdir()
|
|
||||||
const { cookieToJson } = require('./util')
|
const { cookieToJson } = require('./util')
|
||||||
|
|
||||||
const anonymousTokenPath = path.resolve(tmpPath, 'anonymous_token')
|
|
||||||
if (!fs.existsSync(anonymousTokenPath)) {
|
|
||||||
fs.writeFileSync(anonymousTokenPath, '', 'utf-8')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {Record<string, any>} */
|
/** @type {Record<string, any>} */
|
||||||
let obj = {}
|
let obj = {}
|
||||||
|
|
||||||
@@ -46,6 +40,7 @@ moduleFiles.forEach((file) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let serverModule = null
|
let serverModule = null
|
||||||
|
let generateConfigModule = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {Record<string, any> & import("./server")}
|
* @type {Record<string, any> & import("./server")}
|
||||||
@@ -57,6 +52,17 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
return serverModule
|
return serverModule
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 刷新运行时动态配置(匿名令牌 / xeapi 公钥,存于内存 runtimeState)。
|
||||||
|
* 作为依赖使用时可以不调用——首个请求会在凭证缺失时自动引导;
|
||||||
|
* 需要预热或手动续期时再 `await main.generateConfig()`。
|
||||||
|
*/
|
||||||
|
get generateConfig() {
|
||||||
|
if (!generateConfigModule) {
|
||||||
|
generateConfigModule = require('./generateConfig')
|
||||||
|
}
|
||||||
|
return generateConfigModule
|
||||||
|
},
|
||||||
...obj,
|
...obj,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,63 @@ describe('methods in module', () => {
|
|||||||
assert.strictEqual(typeof main.activate_init_profile, 'function')
|
assert.strictEqual(typeof main.activate_init_profile, 'function')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('runtimeState', () => {
|
||||||
|
const runtimeState = require('./util/runtimeState')
|
||||||
|
|
||||||
|
it('has generateConfig for bootstrapping in-memory credentials', () => {
|
||||||
|
assert.strictEqual(typeof main.generateConfig, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes anonymousToken / xeapiPublicKey in memory', () => {
|
||||||
|
assert.ok('anonymousToken' in runtimeState)
|
||||||
|
assert.ok('xeapiPublicKey' in runtimeState)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ensure()', () => {
|
||||||
|
it('returns existing value without refreshing', async () => {
|
||||||
|
runtimeState.ensureProbe = 'ready'
|
||||||
|
let called = 0
|
||||||
|
const value = await runtimeState.ensure('ensureProbe', async () => {
|
||||||
|
called++
|
||||||
|
})
|
||||||
|
assert.strictEqual(value, 'ready')
|
||||||
|
assert.strictEqual(called, 0)
|
||||||
|
delete runtimeState.ensureProbe
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes when missing and shares one task among concurrent calls', async () => {
|
||||||
|
let refreshes = 0
|
||||||
|
const refresh = () =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshes++
|
||||||
|
runtimeState.ensureProbe = 'fresh'
|
||||||
|
resolve()
|
||||||
|
}, 20),
|
||||||
|
)
|
||||||
|
const results = await Promise.all([
|
||||||
|
runtimeState.ensure('ensureProbe', refresh),
|
||||||
|
runtimeState.ensure('ensureProbe', refresh),
|
||||||
|
runtimeState.ensure('ensureProbe', refresh),
|
||||||
|
])
|
||||||
|
assert.deepStrictEqual(results, ['fresh', 'fresh', 'fresh'])
|
||||||
|
assert.strictEqual(refreshes, 1)
|
||||||
|
delete runtimeState.ensureProbe
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates refresh failure and allows retry', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const fail = async () => {
|
||||||
|
attempts++
|
||||||
|
throw new Error('boom')
|
||||||
|
}
|
||||||
|
await assert.rejects(runtimeState.ensure('ensureProbe', fail), /boom/)
|
||||||
|
assert.strictEqual(attempts, 1)
|
||||||
|
// 失败后任务被清理,下一次调用会重试
|
||||||
|
await assert.rejects(runtimeState.ensure('ensureProbe', fail), /boom/)
|
||||||
|
assert.strictEqual(attempts, 2)
|
||||||
|
delete runtimeState.ensureProbe
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const ID_XOR_KEY_1 = '3go8&$8*3*3h0k(2)2'
|
|||||||
const logger = require('../util/logger.js')
|
const logger = require('../util/logger.js')
|
||||||
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
const { generateDeviceId } = require('../util/index')
|
const { generateDeviceId, cookieToJson } = require('../util/index')
|
||||||
|
const runtimeState = require('../util/runtimeState')
|
||||||
|
|
||||||
// function getRandomFromList(list) {
|
// function getRandomFromList(list) {
|
||||||
// return list[Math.floor(Math.random() * list.length)]
|
// return list[Math.floor(Math.random() * list.length)]
|
||||||
@@ -25,7 +26,9 @@ function cloudmusic_dll_encode_id(some_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async (query, request) => {
|
module.exports = async (query, request) => {
|
||||||
const deviceId = generateDeviceId()
|
// 复用进程内已有的设备身份(真实客户端的设备 ID 是稳定的);
|
||||||
|
// xeapi 公钥注册同样依赖该 deviceId
|
||||||
|
const deviceId = global.deviceId || generateDeviceId()
|
||||||
logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`)
|
logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`)
|
||||||
global.deviceId = deviceId
|
global.deviceId = deviceId
|
||||||
const encodedId = CryptoJS.enc.Base64.stringify(
|
const encodedId = CryptoJS.enc.Base64.stringify(
|
||||||
@@ -42,6 +45,11 @@ module.exports = async (query, request) => {
|
|||||||
createOption(query, 'xeapi'),
|
createOption(query, 'xeapi'),
|
||||||
)
|
)
|
||||||
if (result.body.code === 200) {
|
if (result.body.code === 200) {
|
||||||
|
// 注册成功后立即写入运行时状态,后续请求直接使用新令牌(无需重启)
|
||||||
|
const cookieObj = cookieToJson(result.cookie.join(';'))
|
||||||
|
if (cookieObj.MUSIC_A) {
|
||||||
|
runtimeState.anonymousToken = cookieObj.MUSIC_A
|
||||||
|
}
|
||||||
result = {
|
result = {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { default: axios } = require('axios')
|
const { default: axios } = require('axios')
|
||||||
const encrypt = require('../util/crypto')
|
const encrypt = require('../util/crypto')
|
||||||
const { APP_CONF } = require('../util/config.json')
|
const { APP_CONF } = require('../util/config.json')
|
||||||
|
const runtimeState = require('../util/runtimeState')
|
||||||
|
|
||||||
const generateNonce = () => {
|
const generateNonce = () => {
|
||||||
let nonce = ''
|
let nonce = ''
|
||||||
@@ -63,6 +64,9 @@ module.exports = async (query, request) => {
|
|||||||
throw new Error('xeapi public key response missing sk')
|
throw new Error('xeapi public key response missing sk')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取成功后立即写入运行时状态,供 util/request.js 的 xeapi 请求使用
|
||||||
|
runtimeState.xeapiPublicKey = { ...publicKey, deviceId }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const tmpPath = require('os').tmpdir()
|
|
||||||
/** @type {import("express").Express & serverMod.ExpressExtension} */
|
/** @type {import("express").Express & serverMod.ExpressExtension} */
|
||||||
let app
|
let app
|
||||||
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
|
|
||||||
fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8')
|
|
||||||
}
|
|
||||||
const serverMod = require('./server')
|
const serverMod = require('./server')
|
||||||
before(async () => {
|
before(async () => {
|
||||||
const generateConfig = require('./generateConfig')
|
const generateConfig = require('./generateConfig')
|
||||||
|
|||||||
+60
-25
@@ -7,9 +7,7 @@ const { PacProxyAgent } = require('pac-proxy-agent')
|
|||||||
const http = require('http')
|
const http = require('http')
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
const tunnel = require('tunnel')
|
const tunnel = require('tunnel')
|
||||||
const fs = require('fs')
|
const runtimeState = require('./runtimeState')
|
||||||
const path = require('path')
|
|
||||||
const tmpPath = require('os').tmpdir()
|
|
||||||
const {
|
const {
|
||||||
cookieToJson,
|
cookieToJson,
|
||||||
cookieObjToString,
|
cookieObjToString,
|
||||||
@@ -25,26 +23,6 @@ const {
|
|||||||
getToken: antiCheatTokenV3,
|
getToken: antiCheatTokenV3,
|
||||||
} = require('../module/register_checktoken_v3')
|
} = require('../module/register_checktoken_v3')
|
||||||
|
|
||||||
// 预先读取匿名token并缓存
|
|
||||||
const anonymous_token = fs.readFileSync(
|
|
||||||
path.resolve(tmpPath, './anonymous_token'),
|
|
||||||
'utf-8',
|
|
||||||
)
|
|
||||||
const xeapiPublicKeyPath = path.resolve(tmpPath, './xeapi_public_key')
|
|
||||||
let xeapi_public_key = null
|
|
||||||
const loadXeapiPublicKey = () => {
|
|
||||||
if (!xeapi_public_key && fs.existsSync(xeapiPublicKeyPath)) {
|
|
||||||
try {
|
|
||||||
xeapi_public_key = JSON.parse(
|
|
||||||
fs.readFileSync(xeapiPublicKeyPath, 'utf-8'),
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.log('[ERR]', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return xeapi_public_key
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预先绑定常用函数和常量
|
// 预先绑定常用函数和常量
|
||||||
const floor = Math.floor
|
const floor = Math.floor
|
||||||
const random = Math.random
|
const random = Math.random
|
||||||
@@ -169,7 +147,8 @@ const processCookieObject = (cookie, crypto) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!processedCookie.MUSIC_U) {
|
if (!processedCookie.MUSIC_U) {
|
||||||
processedCookie.MUSIC_A = processedCookie.MUSIC_A || anonymous_token
|
processedCookie.MUSIC_A =
|
||||||
|
processedCookie.MUSIC_A || runtimeState.anonymousToken
|
||||||
}
|
}
|
||||||
|
|
||||||
return processedCookie
|
return processedCookie
|
||||||
@@ -196,6 +175,33 @@ const generateRequestId = () => {
|
|||||||
.padStart(4, '0')}`
|
.padStart(4, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 运行时凭证的强制刷新 ----
|
||||||
|
// 匿名令牌与 xeapi 公钥存于 util/runtimeState(纯内存、零文件 I/O),
|
||||||
|
// 由对应的 register_* 模块在成功获取后写入,此处只负责编排调用。
|
||||||
|
|
||||||
|
// 刷新自身的请求带 skipTokenEnsure 标记:跳过下方匿名令牌的按需引导,
|
||||||
|
// 避免“刷新任务等待自己”的死锁(注册匿名账号本就不依赖旧令牌)
|
||||||
|
const refreshTokenRequest = (uri, data, options = {}) =>
|
||||||
|
createRequest(uri, data, { ...options, skipTokenEnsure: true })
|
||||||
|
|
||||||
|
// 强制刷新匿名令牌(成功后由 register_anonimous 写入 runtimeState)
|
||||||
|
const refreshAnonymousToken = async () => {
|
||||||
|
const registerAnonimous = require('../module/register_anonimous')
|
||||||
|
await registerAnonimous({}, refreshTokenRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制刷新 xeapi 公钥(register_xeapikey 直接走 axios,无重入问题;
|
||||||
|
// 成功后由其写入 runtimeState)
|
||||||
|
const refreshXeapiPublicKey = async () => {
|
||||||
|
const registerXeapikey = require('../module/register_xeapikey')
|
||||||
|
await registerXeapikey(
|
||||||
|
{
|
||||||
|
currentKeyVersion: (runtimeState.xeapiPublicKey || {}).version || '',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const createRequest = async (uri, data, options) => {
|
const createRequest = async (uri, data, options) => {
|
||||||
let token = ''
|
let token = ''
|
||||||
switch (options.checkToken) {
|
switch (options.checkToken) {
|
||||||
@@ -209,6 +215,32 @@ const createRequest = async (uri, data, options) => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 匿名令牌缺失时按需引导:并发请求共享同一次刷新任务;
|
||||||
|
// 刷新自身的请求(skipTokenEnsure)跳过,避免任务等待自己造成死锁
|
||||||
|
if (!runtimeState.anonymousToken && !options.skipTokenEnsure) {
|
||||||
|
try {
|
||||||
|
await runtimeState.ensure('anonymousToken', refreshAnonymousToken)
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[ERR]', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本请求走 xeapi 加密且公钥缺失时按需获取
|
||||||
|
// (须在进入下方同步 executor 前完成,加密过程需要同步拿到公钥)
|
||||||
|
const pendingCrypto =
|
||||||
|
options.crypto === undefined || options.crypto === ''
|
||||||
|
? APP_CONF.encrypt
|
||||||
|
? 'eapi'
|
||||||
|
: 'api'
|
||||||
|
: options.crypto
|
||||||
|
if (pendingCrypto === 'xeapi' && !runtimeState.xeapiPublicKey) {
|
||||||
|
try {
|
||||||
|
await runtimeState.ensure('xeapiPublicKey', refreshXeapiPublicKey)
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[ERR]', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 变量声明和初始化
|
// 变量声明和初始化
|
||||||
const headers = options.headers ? { ...options.headers } : {}
|
const headers = options.headers ? { ...options.headers } : {}
|
||||||
@@ -273,7 +305,7 @@ const createRequest = async (uri, data, options) => {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'xeapi':
|
case 'xeapi':
|
||||||
const xeapiPublicKey = loadXeapiPublicKey()
|
const xeapiPublicKey = runtimeState.xeapiPublicKey
|
||||||
if (!xeapiPublicKey) {
|
if (!xeapiPublicKey) {
|
||||||
throw new Error('xeapi public key is missing')
|
throw new Error('xeapi public key is missing')
|
||||||
}
|
}
|
||||||
@@ -516,3 +548,6 @@ const createRequest = async (uri, data, options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createRequest
|
module.exports = createRequest
|
||||||
|
// 供 generateConfig 等复用的强制刷新入口(成功后写入 runtimeState)
|
||||||
|
module.exports.refreshAnonymousToken = refreshAnonymousToken
|
||||||
|
module.exports.refreshXeapiPublicKey = refreshXeapiPublicKey
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 统一的运行时动态状态存储(仅内存,不落盘)
|
||||||
|
//
|
||||||
|
// 存放需要在进程内共享、且会在运行期间变化的动态配置,
|
||||||
|
// 目前包括匿名令牌(MUSIC_A)与 xeapi 公钥。
|
||||||
|
//
|
||||||
|
// 设计约定:
|
||||||
|
// - 只存在于内存中,不做任何文件 I/O —— 在没有 /tmp 或临时目录
|
||||||
|
// 不可写的平台(Android / iOS / 嵌入式 / serverless)上同样可用
|
||||||
|
// - 写入方(module/register_anonimous.js、module/register_xeapikey.js)
|
||||||
|
// 在成功获取新值后立即写入,读取方(util/request.js)每次请求实时读取,
|
||||||
|
// 进程内即时生效,无需重启
|
||||||
|
// - 凭证缺失时可通过 ensure() 按需刷新,并发调用共享同一次刷新任务
|
||||||
|
// - 以后新增的动态配置请放在这里,不要再往 os.tmpdir() 写文件
|
||||||
|
|
||||||
|
const runtimeState = {
|
||||||
|
/** 匿名令牌(MUSIC_A cookie),由 module/register_anonimous.js 成功后写入 */
|
||||||
|
anonymousToken: '',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xeapi 公钥(含 sk / version / deviceId 等字段),
|
||||||
|
* 由 module/register_xeapikey.js 成功后写入
|
||||||
|
*/
|
||||||
|
xeapiPublicKey: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进行中的按需刷新任务:并发调用共享同一次刷新,避免重复请求 */
|
||||||
|
const refreshTasks = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保某项动态状态可用:已有值时直接返回;缺失时执行 refresh 刷新
|
||||||
|
* (refresh 成功后需自行把新值写入 runtimeState[key])。
|
||||||
|
* 并发调用会共享同一个进行中的刷新任务。
|
||||||
|
*
|
||||||
|
* @param {string} key runtimeState 上的字段名
|
||||||
|
* @param {() => Promise} refresh 刷新函数
|
||||||
|
* @returns {Promise<*>} 该状态当前的值(刷新失败时抛出错误)
|
||||||
|
*/
|
||||||
|
runtimeState.ensure = async (key, refresh) => {
|
||||||
|
if (runtimeState[key]) return runtimeState[key]
|
||||||
|
|
||||||
|
if (!refreshTasks.has(key)) {
|
||||||
|
refreshTasks.set(
|
||||||
|
key,
|
||||||
|
Promise.resolve()
|
||||||
|
.then(refresh)
|
||||||
|
.finally(() => {
|
||||||
|
refreshTasks.delete(key)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshTasks.get(key)
|
||||||
|
return runtimeState[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = runtimeState
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const registerXeapiKey = require('../module/register_xeapikey')
|
|
||||||
|
|
||||||
const getXeapiPublicKey = async (currentPublicKey = {}, deviceId = '') => {
|
|
||||||
const result = await registerXeapiKey(
|
|
||||||
{
|
|
||||||
deviceId,
|
|
||||||
currentKeyVersion: currentPublicKey.version || '',
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
|
|
||||||
const publicKey = result.body
|
|
||||||
if (!publicKey.sk && currentPublicKey.sk) {
|
|
||||||
publicKey.sk = currentPublicKey.sk
|
|
||||||
}
|
|
||||||
if (!publicKey.sk) {
|
|
||||||
throw new Error('xeapi public key response missing sk')
|
|
||||||
}
|
|
||||||
return publicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getXeapiPublicKey,
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user