From c4d448641ab65154a4ef7dfe6e2926e28fda8d2d Mon Sep 17 00:00:00 2001 From: 1254qwer Date: Sun, 27 Sep 2026 14:53:29 +0800 Subject: [PATCH] feat: Add neapi support --- generateConfig.js | 14 ++ interface.d.ts | 4 + module/register_neapikey.js | 48 +++++++ package.json | 1 + pnpm-lock.yaml | 8 ++ public/api.html | 1 + public/docs/home.md | 2 +- util/config.json | 1 + util/crypto.js | 77 +++++++++++ util/neapiConfig.js | 258 ++++++++++++++++++++++++++++++++++++ util/neapiKey.js | 49 +++++++ util/request.js | 102 +++++++++++++- util/zstd.js | 41 ++++++ 13 files changed, 600 insertions(+), 6 deletions(-) create mode 100644 module/register_neapikey.js create mode 100644 util/neapiConfig.js create mode 100644 util/neapiKey.js create mode 100644 util/zstd.js diff --git a/generateConfig.js b/generateConfig.js index 7640065..a9f4b8e 100644 --- a/generateConfig.js +++ b/generateConfig.js @@ -3,6 +3,8 @@ const path = require('path') const { register_anonimous } = require('./main') const { cookieToJson, generateRandomChineseIP } = require('./util/index') const { getXeapiPublicKey } = require('./util/xeapiKey') +const { loadNeapiKey } = require('./util/neapiKey') +const registerNeapikey = require('./module/register_neapikey') const tmpPath = require('os').tmpdir() async function generateConfig() { @@ -37,5 +39,17 @@ async function generateConfig() { } catch (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 diff --git a/interface.d.ts b/interface.d.ts index 0952dc8..f81b8e5 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -1087,6 +1087,10 @@ export function register_checktoken_v3( params: { refresh?: boolean | string } & RequestBaseConfig, ): Promise +export function register_neapikey( + params: { version?: string | number; behavior?: string } & RequestBaseConfig, +): Promise + export function register_xeapikey( params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig, ): Promise diff --git a/module/register_neapikey.js b/module/register_neapikey.js new file mode 100644 index 0000000..42c0afb --- /dev/null +++ b/module/register_neapikey.js @@ -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: [], + } +} diff --git a/package.json b/package.json index 8643f7c..43f2247 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "express-fileupload": "^1.5.2", + "fzstd": "^0.1.1", "gzip": "^0.1.0", "jsdom": "^24.1.3", "music-metadata": "^11.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9f6e66..f46d131 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: express-fileupload: specifier: ^1.5.2 version: 1.5.2 + fzstd: + specifier: ^0.1.1 + version: 0.1.1 gzip: specifier: ^0.1.0 version: 0.1.0 @@ -1265,6 +1268,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fzstd@0.1.1: + resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -4226,6 +4232,8 @@ snapshots: functions-have-names@1.2.3: {} + fzstd@0.1.1: {} + generator-function@2.0.1: {} get-caller-file@2.0.5: {} diff --git a/public/api.html b/public/api.html index e62411c..c860761 100644 --- a/public/api.html +++ b/public/api.html @@ -319,6 +319,7 @@ + diff --git a/public/docs/home.md b/public/docs/home.md index 36ad52a..166a836 100644 --- a/public/docs/home.md +++ b/public/docs/home.md @@ -216,7 +216,7 @@ $ sudo docker run -d -p 3000:3000 netease-music-api - 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析 - 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试 - 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持 -- 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数) +- 目前支持算法 有 `weapi`, `eapi`, `linuxapi`, `xeapi` 和 `neapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数; neapi 走独立的压缩加密通道, 配置由 `/register/neapikey` 自动获取与刷新) ## 接口文档 diff --git a/util/config.json b/util/config.json index 654f898..2bbdc3c 100644 --- a/util/config.json +++ b/util/config.json @@ -13,6 +13,7 @@ "apiDomain": "https://interface.music.163.com", "eapiDomain": "https://interfacepc.music.163.com", "xeapiDomain": "https://interface3.music.163.com", + "neapiDomain": "https://interface3.music.163.com", "domain": "https://music.163.com", "clDomian": "https://clientlog.music.163.com", "clDomian3": "https://clientlog3.music.163.com", diff --git a/util/crypto.js b/util/crypto.js index 6838fe1..419336d 100644 --- a/util/crypto.js +++ b/util/crypto.js @@ -2,6 +2,7 @@ const CryptoJS = require('crypto-js') const crypto = require('crypto') const forge = require('node-forge') const zlib = require('zlib') +const zstd = require('./zstd') const iv = '0102030405060708' const presetKey = '0CoJUm6Qyw8W8jud' 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 = { weapi, linuxapi, eapi, xeapi, + neapi, decrypt, aesEncrypt, aesDecrypt, @@ -318,4 +394,5 @@ module.exports = { xeapiSign, xeapiResDecrypt, xeapiDecryptPublicKey, + neapiResDecrypt, } diff --git a/util/neapiConfig.js b/util/neapiConfig.js new file mode 100644 index 0000000..498ce82 --- /dev/null +++ b/util/neapiConfig.js @@ -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 == then offset = +// elseif direction == then offset = +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, +} diff --git a/util/neapiKey.js b/util/neapiKey.js new file mode 100644 index 0000000..8119ad3 --- /dev/null +++ b/util/neapiKey.js @@ -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, +} diff --git a/util/request.js b/util/request.js index 55b6d2b..9b277e9 100644 --- a/util/request.js +++ b/util/request.js @@ -18,12 +18,14 @@ const { } = require('./index') const { URLSearchParams, URL } = require('url') const { APP_CONF } = require('./config.json') +const { loadNeapiKey } = require('./neapiKey') const { getToken: antiCheatTokenV2, } = require('../module/register_checktoken_v2') const { getToken: antiCheatTokenV3, } = require('../module/register_checktoken_v3') +const registerNeapikey = require('../module/register_neapikey') // 预先读取匿名token并缓存 const anonymous_token = fs.readFileSync( @@ -71,6 +73,20 @@ const WNMCID = (function () { let NMTID = '' 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 const osMap = { pc: { @@ -127,6 +143,7 @@ const DOMAIN = APP_CONF.domain const API_DOMAIN = APP_CONF.apiDomain const EAPI_DOMAIN = APP_CONF.eapiDomain const XEAPI_DOMAIN = APP_CONF.xeapiDomain +const NEAPI_DOMAIN = APP_CONF.neapiDomain const ENCRYPT_RESPONSE = APP_CONF.encryptResponse 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 + 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 'api': // header创建 @@ -367,12 +425,19 @@ const createRequest = async (uri, data, options) => { console.log('[ERR]', 'Unknown Crypto:', crypto) break } + const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r + const use_xeapi = crypto === 'xeapi' + const use_neapi = crypto === 'neapi' + // settings创建 let settings = { method: 'POST', url: url, headers: headers, - data: new URLSearchParams(encryptData).toString(), + // neapi 的请求体就是容器本身,不是表单字段 + data: use_neapi + ? encryptData + : new URLSearchParams(encryptData).toString(), httpAgent: createHttpAgent(), httpsAgent: createHttpsAgent(), } @@ -383,9 +448,7 @@ const createRequest = async (uri, data, options) => { } // 使用返回值加密 - const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r - const use_xeapi = crypto === 'xeapi' - if (use_e_r || use_xeapi) { + if (use_e_r || use_xeapi || use_neapi) { settings.encoding = null settings.responseType = 'arraybuffer' } @@ -426,7 +489,7 @@ const createRequest = async (uri, data, options) => { } // console.log(settings.headers); axios(settings) - .then((res) => { + .then(async (res) => { const body = res.data const setCookies = res.headers['set-cookie'] || [] @@ -453,6 +516,30 @@ const createRequest = async (uri, data, options) => { 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: 统一注释块,需要时取消注释查看请求/返回的原始密文 // logger.debug(`[${crypto}]`, uri) @@ -471,6 +558,11 @@ const createRequest = async (uri, data, options) => { xeapiSessionKey = res.headers['x-encr-sskey'] } 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) { answer.body = encrypt.eapiResDecrypt( body.toString('hex').toUpperCase(), diff --git a/util/zstd.js b/util/zstd.js new file mode 100644 index 0000000..f5ba3f6 --- /dev/null +++ b/util/zstd.js @@ -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, +}