feat: Add neapi support

This commit is contained in:
1254qwer
2026-09-27 14:53:29 +08:00
parent a8c781fd64
commit c4d448641a
13 changed files with 600 additions and 6 deletions
+14
View File
@@ -3,6 +3,8 @@ const path = require('path')
const { register_anonimous } = require('./main') const { register_anonimous } = require('./main')
const { cookieToJson, generateRandomChineseIP } = require('./util/index') const { cookieToJson, generateRandomChineseIP } = require('./util/index')
const { getXeapiPublicKey } = require('./util/xeapiKey') const { getXeapiPublicKey } = require('./util/xeapiKey')
const { loadNeapiKey } = require('./util/neapiKey')
const registerNeapikey = require('./module/register_neapikey')
const tmpPath = require('os').tmpdir() const tmpPath = require('os').tmpdir()
async function generateConfig() { async function generateConfig() {
@@ -37,5 +39,17 @@ async function generateConfig() {
} catch (error) { } catch (error) {
console.log(error) console.log(error)
} }
try {
const current = loadNeapiKey()
await registerNeapikey(
{
version: current ? current.version : 0,
behavior: current ? 'update' : 'open',
},
null,
)
} catch (error) {
console.log(error)
}
} }
module.exports = generateConfig module.exports = generateConfig
+4
View File
@@ -1087,6 +1087,10 @@ export function register_checktoken_v3(
params: { refresh?: boolean | string } & RequestBaseConfig, params: { refresh?: boolean | string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function register_neapikey(
params: { version?: string | number; behavior?: string } & RequestBaseConfig,
): Promise<Response>
export function register_xeapikey( export function register_xeapikey(
params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig, params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
+48
View File
@@ -0,0 +1,48 @@
const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json')
const { saveNeapiKey } = require('../util/neapiKey')
const { decodeConfig } = require('../util/neapiConfig')
const USER_AGENT =
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)'
module.exports = async (query, request) => {
const version = Number(query.version) || 0
const behavior = query.behavior === 'update' ? 'update' : 'open'
const res = await axios({
method: 'GET',
url: APP_CONF.apiDomain + '/api/gorilla/algorithm/record',
params: { version, behavior },
headers: { 'User-Agent': USER_AGENT },
proxy: false,
})
if (!res.data || res.data.code !== 200 || !res.data.data) {
throw new Error('neapi config request failed')
}
// file 为空表示本地已是最新,无需更新
const { file, circleTime } = res.data.data
const config = file
? saveNeapiKey(decodeConfig(Buffer.from(file, 'base64')), circleTime)
: null
// 只回版本与密钥偏移,密钥本身留在本地缓存
const keyOffsets = {}
if (config) {
keyOffsets.encrypt = config.keyBlob.indexOf(config.encryptKey)
keyOffsets.decrypt = config.keyBlob.indexOf(config.decryptKey)
}
return {
status: 200,
body: {
version: config ? config.version : res.data.data.version,
updated: Boolean(config),
circleTime,
keyOffsets,
},
cookie: [],
}
}
+1
View File
@@ -81,6 +81,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-fileupload": "^1.5.2", "express-fileupload": "^1.5.2",
"fzstd": "^0.1.1",
"gzip": "^0.1.0", "gzip": "^0.1.0",
"jsdom": "^24.1.3", "jsdom": "^24.1.3",
"music-metadata": "^11.15.0", "music-metadata": "^11.15.0",
+8
View File
@@ -26,6 +26,9 @@ importers:
express-fileupload: express-fileupload:
specifier: ^1.5.2 specifier: ^1.5.2
version: 1.5.2 version: 1.5.2
fzstd:
specifier: ^0.1.1
version: 0.1.1
gzip: gzip:
specifier: ^0.1.0 specifier: ^0.1.0
version: 0.1.0 version: 0.1.0
@@ -1265,6 +1268,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
[email protected]:
resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4226,6 +4232,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+1
View File
@@ -319,6 +319,7 @@
<option value="api">api</option> <option value="api">api</option>
<option value="linuxapi">linuxapi</option> <option value="linuxapi">linuxapi</option>
<option value="xeapi">xeapi</option> <option value="xeapi">xeapi</option>
<option value="neapi">neapi</option>
<option value="" selected>(默认)</option> <option value="" selected>(默认)</option>
</select> </select>
</div> </div>
+1 -1
View File
@@ -216,7 +216,7 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
- 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析 - 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试 - 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持 - 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数) - 目前支持算法 有 `weapi`, `eapi`, `linuxapi`, `xeapi` 和 `neapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数; neapi 走独立的压缩加密通道, 配置由 `/register/neapikey` 自动获取与刷新)
## 接口文档 ## 接口文档
+1
View File
@@ -13,6 +13,7 @@
"apiDomain": "https://interface.music.163.com", "apiDomain": "https://interface.music.163.com",
"eapiDomain": "https://interfacepc.music.163.com", "eapiDomain": "https://interfacepc.music.163.com",
"xeapiDomain": "https://interface3.music.163.com", "xeapiDomain": "https://interface3.music.163.com",
"neapiDomain": "https://interface3.music.163.com",
"domain": "https://music.163.com", "domain": "https://music.163.com",
"clDomian": "https://clientlog.music.163.com", "clDomian": "https://clientlog.music.163.com",
"clDomian3": "https://clientlog3.music.163.com", "clDomian3": "https://clientlog3.music.163.com",
+77
View File
@@ -2,6 +2,7 @@ const CryptoJS = require('crypto-js')
const crypto = require('crypto') const crypto = require('crypto')
const forge = require('node-forge') const forge = require('node-forge')
const zlib = require('zlib') const zlib = require('zlib')
const zstd = require('./zstd')
const iv = '0102030405060708' const iv = '0102030405060708'
const presetKey = '0CoJUm6Qyw8W8jud' const presetKey = '0CoJUm6Qyw8W8jud'
const linuxapiKey = 'rFgB&h#%2?^eDg:Q' const linuxapiKey = 'rFgB&h#%2?^eDg:Q'
@@ -305,11 +306,86 @@ const xeapiDecryptPublicKey = (encryptedData) => {
) )
} }
const NEAPI_MAGIC = Buffer.from('CSJM')
const NEAPI_NONCE_LEN = 12
const NEAPI_AD_LEN = 16
const NEAPI_TAG_LEN = 16
const NEAPI_HEADER_LEN = 4 + 1 + 1 + 2 + NEAPI_NONCE_LEN + 1 + NEAPI_AD_LEN + 4
// Header: magic, the config version as two big-endian bytes, then the
// nonce / associated data / sealed length.
const neapi = (data, config) => {
const nonce = crypto.randomBytes(NEAPI_NONCE_LEN)
const ad = crypto.randomBytes(NEAPI_AD_LEN)
const cipher = crypto.createCipheriv(
'chacha20-poly1305',
config.encryptKey,
nonce,
{ authTagLength: NEAPI_TAG_LEN },
)
cipher.setAAD(ad)
const sealed = Buffer.concat([
cipher.update(zstd.compress(data)),
cipher.final(),
cipher.getAuthTag(),
])
const header = Buffer.alloc(NEAPI_HEADER_LEN)
NEAPI_MAGIC.copy(header, 0)
header[4] = config.version >> 8
header[5] = config.version & 0xff
header.writeUInt16LE(nonce.length, 6)
nonce.copy(header, 8)
header[8 + nonce.length] = ad.length
ad.copy(header, 9 + nonce.length)
header.writeUInt32LE(sealed.length, 9 + nonce.length + ad.length)
return Buffer.concat([header, sealed]).toString('base64')
}
// Some error responses repeat the same document back to back.
const parseBody = (text) => {
try {
return JSON.parse(text)
} catch (error) {
const boundary = text.indexOf('}{')
if (boundary < 0) throw error
return JSON.parse(text.slice(0, boundary + 1))
}
}
const neapiResDecrypt = (body, config) => {
const raw = Buffer.from(String(body).trim(), 'base64')
const nonceLen = raw.readUInt16LE(6)
const nonce = raw.subarray(8, 8 + nonceLen)
const adLen = raw[8 + nonceLen]
const ad = raw.subarray(9 + nonceLen, 9 + nonceLen + adLen)
const offset = 9 + nonceLen + adLen
const sealed = raw.subarray(offset + 4, offset + 4 + raw.readUInt32LE(offset))
const decipher = crypto.createDecipheriv(
'chacha20-poly1305',
config.decryptKey,
nonce,
{ authTagLength: NEAPI_TAG_LEN },
)
decipher.setAAD(ad)
decipher.setAuthTag(sealed.subarray(sealed.length - NEAPI_TAG_LEN))
const compressed = Buffer.concat([
decipher.update(sealed.subarray(0, sealed.length - NEAPI_TAG_LEN)),
decipher.final(),
])
return parseBody(zstd.decompress(compressed).toString())
}
module.exports = { module.exports = {
weapi, weapi,
linuxapi, linuxapi,
eapi, eapi,
xeapi, xeapi,
neapi,
decrypt, decrypt,
aesEncrypt, aesEncrypt,
aesDecrypt, aesDecrypt,
@@ -318,4 +394,5 @@ module.exports = {
xeapiSign, xeapiSign,
xeapiResDecrypt, xeapiResDecrypt,
xeapiDecryptPublicKey, xeapiDecryptPublicKey,
neapiResDecrypt,
} }
+258
View File
@@ -0,0 +1,258 @@
const crypto = require('crypto')
const zstd = require('./zstd')
// gorilla/algorithm/record 返回的 base64 解码后:
// [0 .. 64) RSA 块 -> RSA_PKCS1 去填充得到 RC4 密钥
// [64 .. 68) u32 LE 载荷长度
// [68 .. ) RC4 载荷 -> zstd 解压得到 ECFG 容器
const CONFIG_PUBLIC_KEY = Buffer.from(
'3048024100e4e79c1cb27a9fab7d2c740e99e923ec51c009169ec3a380c60404' +
'e01b060f0160cdc631d9fb7fe53b228a5ff11bee42b965d28c6c2036193f4ccd' +
'c7ed853d110203010001',
'hex',
)
// 容器里的 Lua 区块对指令和字符串做了逐字节异或混淆
const CODE_XOR = [
0x94b4f825, 0x977c6795, 0x327de32b, 0x16da5ba1, 0xcf55a2eb, 0x080bb445,
0x021af055, 0xfc56b8bd,
]
const STR_XOR = Buffer.from('aefda2d1832b518a7741c325fb2687f0', 'hex')
const CHUNK_SIGNATURE = Buffer.from('7f454c46', 'hex')
// 脚本用这两个标记区分请求方向与响应方向
const DIRECTION_ENCRYPT = 251
const DIRECTION_DECRYPT = 503
const KEY_LEN = 32
const T_NUMFLT = 3
const T_NUMINT = 19
const OP_LOADK = 1
const OP_GETUPVAL = 5
const OP_EQ = 31
const op = (ins) => ins & 0x3f
const argA = (ins) => (ins >>> 6) & 0xff
const argB = (ins) => (ins >>> 23) & 0x1ff
const argC = (ins) => (ins >>> 14) & 0x1ff
const argBx = (ins) => (ins >>> 14) & 0x3ffff
const rc4 = (key, data) => {
const s = Buffer.alloc(256)
for (let i = 0; i < 256; i++) s[i] = i
let j = 0
for (let i = 0; i < 256; i++) {
j = (j + s[i] + key[i % key.length]) & 0xff
;[s[i], s[j]] = [s[j], s[i]]
}
const out = Buffer.allocUnsafe(data.length)
let i = 0
j = 0
for (let n = 0; n < data.length; n++) {
i = (i + 1) & 0xff
j = (j + s[i]) & 0xff
;[s[i], s[j]] = [s[j], s[i]]
out[n] = data[n] ^ s[(s[i] + s[j]) & 0xff]
}
return out
}
const unwrapContainer = (raw) => {
const key = crypto.publicDecrypt(
crypto.createPublicKey({
key: CONFIG_PUBLIC_KEY,
format: 'der',
type: 'pkcs1',
}),
raw.subarray(0, 64),
)
const size = raw.readUInt32LE(64)
return zstd.decompress(rc4(key, raw.subarray(68, 68 + size)))
}
class ChunkReader {
constructor(buf, pos = 0) {
this.buf = buf
this.pos = pos
}
u8() {
return this.buf[this.pos++]
}
u32() {
const value = this.buf.readUInt32LE(this.pos)
this.pos += 4
return value
}
i64() {
const value = this.buf.readBigInt64LE(this.pos)
this.pos += 8
return Number(value)
}
f64() {
const value = this.buf.readDoubleLE(this.pos)
this.pos += 8
return value
}
string() {
let size = this.u8()
if (size === 0) return null
if (size === 0xff) size = this.u32()
// 长度比实际字节数多 1(标准 luac 的写法)
const body = this.buf.subarray(this.pos, this.pos + size - 1)
this.pos += size - 1
const out = Buffer.allocUnsafe(body.length)
for (let i = 0; i < body.length; i++) {
out[i] = body[i] ^ STR_XOR[i & 0x0f]
}
return out.toString('utf8')
}
}
const loadProto = (r) => {
const proto = {
source: r.string(),
linedefined: r.u32(),
lastlinedefined: r.u32(),
numparams: r.u8(),
isvararg: r.u8(),
maxstack: r.u8(),
}
let count = r.u32()
proto.code = new Array(count)
for (let i = 0; i < count; i++) {
proto.code[i] = (r.u32() ^ CODE_XOR[i & 7]) >>> 0
}
// 常量标签:0 nil / 1 boolean / 3 float / 19 integer / 4,20 string
// 只保留用得到的整数与字符串
count = r.u32()
proto.consts = new Array(count)
for (let i = 0; i < count; i++) {
const tag = r.u8()
if (tag === 0) proto.consts[i] = null
else if (tag === 1) {
r.u8()
proto.consts[i] = null
} else if (tag === T_NUMFLT) {
r.f64()
proto.consts[i] = null
} else if (tag === T_NUMINT) proto.consts[i] = r.i64()
else proto.consts[i] = r.string()
}
count = r.u32()
proto.upvals = new Array(count)
for (let i = 0; i < count; i++) {
proto.upvals[i] = [r.u8(), r.u8()]
}
count = r.u32()
proto.protos = new Array(count)
for (let i = 0; i < count; i++) {
proto.protos[i] = loadProto(r)
}
// 调试段跳过即可
count = r.u32()
r.pos += 4 * count
count = r.u32()
for (let i = 0; i < count; i++) {
r.string()
r.u32()
r.u32()
}
count = r.u32()
for (let i = 0; i < count; i++) {
r.string()
}
return proto
}
const loadChunk = (buf) => {
if (!buf.subarray(0, 4).equals(CHUNK_SIGNATURE)) {
throw new Error('neapi config is not a chunk')
}
const r = new ChunkReader(buf, 33)
r.u8() // 主闭包 upvalue 数量
return loadProto(r)
}
// 主函数用一连串 LOADK 装载所有标量,据此可以把 upvalue 追回常量值
const mainRegisters = (main) => {
const regs = new Map()
for (const ins of main.code) {
if (op(ins) === OP_LOADK) regs.set(argA(ins), main.consts[argBx(ins)])
}
return regs
}
// create_ctx 里方向 -> 密钥偏移的分支:
// if direction == <a> then offset = <upvalue>
// elseif direction == <b> then offset = <upvalue>
const extractKeyOffsets = (script) => {
const createCtx = script.protos[0]
const regs = mainRegisters(script)
const offsets = {}
let pending = null
for (const ins of createCtx.code) {
const code = op(ins)
if (code === OP_EQ) {
const c = argC(ins)
const constant = c & 0x100 && createCtx.consts[c & 0xff]
if (Number.isInteger(constant)) pending = constant
} else if (code === OP_GETUPVAL && pending !== null) {
const up = createCtx.upvals[argB(ins)]
const constant = up && up[0] === 1 && regs.get(up[1])
if (Number.isInteger(constant)) {
offsets[pending] = constant
pending = null
}
}
}
if (!(DIRECTION_ENCRYPT in offsets) || !(DIRECTION_DECRYPT in offsets)) {
throw new Error('neapi config script has an unexpected direction table')
}
return offsets
}
const extractKeyBlob = (script) => {
const blob = script.consts
.filter(
(c) => typeof c === 'string' && /^[A-Za-z0-9+/]{64,}={0,2}$/.test(c),
)
.sort((a, b) => b.length - a.length)[0]
return Buffer.from(blob, 'base64')
}
const decodeConfig = (raw) => {
const ecfg = unwrapContainer(raw)
// ECFG 头 4 字节 + 大端 u16 版本,之后是 Lua 区块
const version = ecfg.readUInt16BE(4)
const script = loadChunk(ecfg.subarray(6))
const offsets = extractKeyOffsets(script)
const keyBlob = extractKeyBlob(script)
const slice = (direction) =>
keyBlob.subarray(offsets[direction], offsets[direction] + KEY_LEN)
return {
version,
keyBlob,
encryptKey: slice(DIRECTION_ENCRYPT),
decryptKey: slice(DIRECTION_DECRYPT),
}
}
module.exports = {
decodeConfig,
}
+49
View File
@@ -0,0 +1,49 @@
const fs = require('fs')
const path = require('path')
// 配置会轮换,启动时由 /register/neapikey 拉取后缓存到这里
const neapiKeyPath = path.resolve(require('os').tmpdir(), 'neapi_key')
let neapi_key = null
const loadNeapiKey = () => {
if (!neapi_key && fs.existsSync(neapiKeyPath)) {
try {
const stored = JSON.parse(fs.readFileSync(neapiKeyPath, 'utf-8'))
neapi_key = {
version: stored.version,
circleTime: stored.circleTime,
fetchedAt: stored.fetchedAt,
keyBlob: Buffer.from(stored.keyBlob, 'base64'),
encryptKey: Buffer.from(stored.encryptKey, 'base64'),
decryptKey: Buffer.from(stored.decryptKey, 'base64'),
}
} catch (error) {
console.log('[ERR]', error)
}
}
return neapi_key
}
const saveNeapiKey = (config, circleTime) => {
const fetchedAt = Date.now()
fs.writeFileSync(
neapiKeyPath,
JSON.stringify({
version: config.version,
circleTime,
fetchedAt,
keyBlob: config.keyBlob.toString('base64'),
encryptKey: config.encryptKey.toString('base64'),
decryptKey: config.decryptKey.toString('base64'),
}),
'utf-8',
)
neapi_key = { ...config, circleTime, fetchedAt }
return neapi_key
}
module.exports = {
loadNeapiKey,
saveNeapiKey,
}
+97 -5
View File
@@ -18,12 +18,14 @@ const {
} = require('./index') } = require('./index')
const { URLSearchParams, URL } = require('url') const { URLSearchParams, URL } = require('url')
const { APP_CONF } = require('./config.json') const { APP_CONF } = require('./config.json')
const { loadNeapiKey } = require('./neapiKey')
const { const {
getToken: antiCheatTokenV2, getToken: antiCheatTokenV2,
} = require('../module/register_checktoken_v2') } = require('../module/register_checktoken_v2')
const { const {
getToken: antiCheatTokenV3, getToken: antiCheatTokenV3,
} = require('../module/register_checktoken_v3') } = require('../module/register_checktoken_v3')
const registerNeapikey = require('../module/register_neapikey')
// 预先读取匿名token并缓存 // 预先读取匿名token并缓存
const anonymous_token = fs.readFileSync( const anonymous_token = fs.readFileSync(
@@ -71,6 +73,20 @@ const WNMCID = (function () {
let NMTID = '' let NMTID = ''
let NMTID_RETRIES_LEFT = 3 let NMTID_RETRIES_LEFT = 3
// neapi 配置过期时只让一个请求去刷新,并发的其余请求共用同一次结果
let neapiUserStatus = ''
let neapiRefreshing = null
const refreshNeapiKey = (version) => {
if (!neapiRefreshing) {
neapiRefreshing = registerNeapikey({ version, behavior: 'update' }, null)
.catch((error) => console.log('[ERR]', error))
.finally(() => {
neapiRefreshing = null
})
}
return neapiRefreshing
}
// 预先定义osMap // 预先定义osMap
const osMap = { const osMap = {
pc: { pc: {
@@ -127,6 +143,7 @@ const DOMAIN = APP_CONF.domain
const API_DOMAIN = APP_CONF.apiDomain const API_DOMAIN = APP_CONF.apiDomain
const EAPI_DOMAIN = APP_CONF.eapiDomain const EAPI_DOMAIN = APP_CONF.eapiDomain
const XEAPI_DOMAIN = APP_CONF.xeapiDomain const XEAPI_DOMAIN = APP_CONF.xeapiDomain
const NEAPI_DOMAIN = APP_CONF.neapiDomain
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803]) const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
@@ -321,6 +338,47 @@ const createRequest = async (uri, data, options) => {
}) })
break break
case 'neapi':
const neapiKey = loadNeapiKey()
if (!neapiKey) {
throw new Error('neapi config is missing')
}
// 超过 circleTime 就后台刷新,本次仍用手上的配置
if (now() - neapiKey.fetchedAt >= neapiKey.circleTime) {
refreshNeapiKey(neapiKey.version)
}
const neapiCookie = {
osver: cookie.osver,
deviceId: cookie.deviceId,
os: cookie.os,
appver: cookie.appver,
versioncode: cookie.versioncode || '140',
mobilename: cookie.mobilename || '',
buildver: cookie.buildver || now().toString().substr(0, 10),
resolution: cookie.resolution || '1920x1080',
__csrf: csrfToken,
channel: cookie.channel,
requestId: generateRequestId(),
}
if (cookie.MUSIC_U) neapiCookie['MUSIC_U'] = cookie.MUSIC_U
if (cookie.MUSIC_A) neapiCookie['MUSIC_A'] = cookie.MUSIC_A
headers['Cookie'] = createHeaderCookie(neapiCookie)
headers['User-Agent'] = options.ua || chooseUserAgent('api', 'android')
headers['content-type'] =
'application/x-www-form-urlencoded;charset=utf-8'
// 服务端按 X-ER 选密钥,配置过期时由下面的 777/999 分支重拉并重试
headers['X-ER'] = String(neapiKey.version)
if (neapiUserStatus) headers['X-USER-STATUS'] = neapiUserStatus
const neapiBody = { ...data }
delete neapiBody.e_r
encryptData = encrypt.neapi(
new URLSearchParams(neapiBody).toString(),
neapiKey,
)
url = (options.domain || NEAPI_DOMAIN) + '/neapi/' + uri.substr(5)
break
case 'eapi': case 'eapi':
case 'api': case 'api':
// header创建 // header创建
@@ -367,12 +425,19 @@ const createRequest = async (uri, data, options) => {
console.log('[ERR]', 'Unknown Crypto:', crypto) console.log('[ERR]', 'Unknown Crypto:', crypto)
break break
} }
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
const use_xeapi = crypto === 'xeapi'
const use_neapi = crypto === 'neapi'
// settings创建 // settings创建
let settings = { let settings = {
method: 'POST', method: 'POST',
url: url, url: url,
headers: headers, headers: headers,
data: new URLSearchParams(encryptData).toString(), // neapi 的请求体就是容器本身,不是表单字段
data: use_neapi
? encryptData
: new URLSearchParams(encryptData).toString(),
httpAgent: createHttpAgent(), httpAgent: createHttpAgent(),
httpsAgent: createHttpsAgent(), httpsAgent: createHttpsAgent(),
} }
@@ -383,9 +448,7 @@ const createRequest = async (uri, data, options) => {
} }
// 使用返回值加密 // 使用返回值加密
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r if (use_e_r || use_xeapi || use_neapi) {
const use_xeapi = crypto === 'xeapi'
if (use_e_r || use_xeapi) {
settings.encoding = null settings.encoding = null
settings.responseType = 'arraybuffer' settings.responseType = 'arraybuffer'
} }
@@ -426,7 +489,7 @@ const createRequest = async (uri, data, options) => {
} }
// console.log(settings.headers); // console.log(settings.headers);
axios(settings) axios(settings)
.then((res) => { .then(async (res) => {
const body = res.data const body = res.data
const setCookies = res.headers['set-cookie'] || [] const setCookies = res.headers['set-cookie'] || []
@@ -453,6 +516,30 @@ const createRequest = async (uri, data, options) => {
answer.cookie = setCookies.map(cleanCookie) answer.cookie = setCookies.map(cleanCookie)
} }
// 空值同样要记下来,否则状态恢复后会一直带着上一次的值
const userStatus = res.headers['x-user-status'] || ''
if (use_neapi) {
neapiUserStatus = userStatus
if (
(userStatus === '777' || userStatus === '999') &&
!options.neapiRetried
) {
// 配置已轮换:刷新后重试一次
await refreshNeapiKey(loadNeapiKey().version)
try {
resolve(
await createRequest(uri, data, {
...options,
neapiRetried: true,
}),
)
} catch (error) {
reject(error)
}
return
}
}
// debug: 统一注释块,需要时取消注释查看请求/返回的原始密文 // debug: 统一注释块,需要时取消注释查看请求/返回的原始密文
// logger.debug(`[${crypto}]`, uri) // logger.debug(`[${crypto}]`, uri)
@@ -471,6 +558,11 @@ const createRequest = async (uri, data, options) => {
xeapiSessionKey = res.headers['x-encr-sskey'] xeapiSessionKey = res.headers['x-encr-sskey']
} }
answer.body = encrypt.xeapiResDecrypt(Buffer.from(body)) answer.body = encrypt.xeapiResDecrypt(Buffer.from(body))
} else if (use_neapi) {
answer.body = encrypt.neapiResDecrypt(
Buffer.from(body).toString(),
loadNeapiKey(),
)
} else if (use_e_r) { } else if (use_e_r) {
answer.body = encrypt.eapiResDecrypt( answer.body = encrypt.eapiResDecrypt(
body.toString('hex').toUpperCase(), body.toString('hex').toUpperCase(),
+41
View File
@@ -0,0 +1,41 @@
const zlib = require('zlib')
const { decompress: fzstdDecompress } = require('fzstd')
// Node 22.15+ 自带 zstd。更早的版本(CI 含 18)用 fzstd 解压,压缩则退回到
// raw block 帧:内容原样存储,任何 zstd 解码器都能读,只是没有压缩率。
const hasNative = typeof zlib.zstdCompressSync === 'function'
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
const WINDOW_DESCRIPTOR = 0x50 // windowLog 20
const BLOCK_MAX = 0x20000 // zstd 单块上限 128 KiB
const compressRawFrame = (input) => {
const parts = [MAGIC, Buffer.from([0x00, WINDOW_DESCRIPTOR])]
let offset = 0
do {
const block = input.subarray(offset, offset + BLOCK_MAX)
const last = offset + BLOCK_MAX >= input.length
const header = Buffer.alloc(3)
header.writeUIntLE((block.length << 3) | (last ? 1 : 0), 0, 3)
parts.push(header, block)
offset += BLOCK_MAX
} while (offset < input.length)
return Buffer.concat(parts)
}
const compress = (input) => {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input)
return hasNative ? zlib.zstdCompressSync(buf) : compressRawFrame(buf)
}
const decompress = (input) => {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input)
return hasNative
? zlib.zstdDecompressSync(buf)
: Buffer.from(fzstdDecompress(buf))
}
module.exports = {
compress,
decompress,
}