Compare commits

...

3 Commits

Author SHA1 Message Date
48f35502ba
feat: 支持md导出数据 2026-07-10 13:26:17 +08:00
e2b0d2ceef
fix: 修复显示过挤 2026-07-10 13:20:15 +08:00
0fe0500ae1
feat: 支持更多功能 2026-07-10 13:12:43 +08:00
12 changed files with 4209 additions and 490 deletions

3
.gitignore vendored
View File

@ -70,6 +70,9 @@ web_modules/
.env.* .env.*
!.env.example !.env.example
# Captured data persistence
captures.jsonl
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
.parcel-cache .parcel-cache

119
bin/cli.js Normal file
View File

@ -0,0 +1,119 @@
#!/usr/bin/env node
/**
* api-clawer 网易云音乐客户端抓包工具
*
* 用法:
* npx api-clawer # 启动 (默认端口 3000 + 9000:9001)
* npx api-clawer --help # 查看帮助
* npx api-clawer --version # 查看版本
* npx api-clawer -p 8080 # 指定 HTTP 代理端口
* npx api-clawer -p 8080:8443 # 指定 HTTP + HTTPS 代理端口
* npx api-clawer -a 127.0.0.1 # 绑定地址
*/
const path = require('path');
// 确保 dotenv 加载项目根目录的 .env
process.env.DOTENV_CONFIG_PATH = path.resolve(__dirname, '..', '.env');
require('dotenv').config({ path: process.env.DOTENV_CONFIG_PATH });
const packageJson = require('../package.json');
// 简易参数解析
const args = process.argv.slice(2);
const flags = {
help: args.includes('--help') || args.includes('-h'),
version: args.includes('--version') || args.includes('-v'),
port: null,
address: null,
};
// 解析 -p/--port
const portIdx = args.findIndex(a => a === '-p' || a === '--port');
if (portIdx !== -1 && args[portIdx + 1] && !args[portIdx + 1].startsWith('-')) {
flags.port = args[portIdx + 1];
}
// 解析 -a/--address
const addrIdx = args.findIndex(a => a === '-a' || a === '--address');
if (addrIdx !== -1 && args[addrIdx + 1] && !args[addrIdx + 1].startsWith('-')) {
flags.address = args[addrIdx + 1];
}
if (flags.help) {
console.log(`
api-clawer v${packageJson.version} 网易云音乐客户端抓包工具
用法:
npx api-clawer [选项]
选项:
-p, --port <http[:https]> 指定代理端口 (默认: 9000:9001)
-a, --address <address> 绑定监听地址 (默认: 0.0.0.0)
-v, --version 输出版本号
-h, --help 输出帮助信息
环境变量:
PORT=3000 前端界面端口
HOOK_PORT=9000:9001 代理服务器端口
LOG_LEVEL=info 日志级别 (debug/info/warn/error)
示例:
npx api-clawer
npx api-clawer -p 8080
npx api-clawer -p 8080:8443 -a 127.0.0.1
`);
process.exit(0);
}
if (flags.version) {
console.log(packageJson.version);
process.exit(0);
}
// 将 CLI 参数写入环境变量,供 server/app.js 读取
if (flags.port) process.env.HOOK_PORT = flags.port;
if (flags.address) process.env.ADDRESS = flags.address;
if (!process.env.PORT) process.env.PORT = '3000';
const { startServer } = require('../src/server/app');
const { startClient } = require('../src/client/app');
/** @type {import('http').Server[]} */
const servers = [];
const gracefulShutdown = async (signal) => {
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
const closePromises = servers.map(s => new Promise(resolve => {
s.close(() => resolve());
setTimeout(() => resolve(), 5000);
}));
await Promise.all(closePromises);
console.log('所有服务已关闭');
process.exit(0);
};
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
(async () => {
try {
const [proxyResult, clientServer] = await Promise.all([
startServer(),
startClient()
]);
if (proxyResult) {
if (proxyResult.httpServer) servers.push(proxyResult.httpServer);
if (proxyResult.httpsServer) servers.push(proxyResult.httpsServer);
}
servers.push(clientServer);
console.log('所有服务启动完成!');
console.log(` 前端界面: http://localhost:${process.env.PORT || 3000}`);
console.log(` HTTP 代理: http://localhost:${(flags.port || process.env.HOOK_PORT || '9000').split(':')[0]}`);
console.log(' 按 Ctrl+C 停止服务');
} catch (error) {
console.error('启动服务失败:', error);
process.exit(1);
}
})();

View File

@ -1,16 +1,23 @@
{ {
"name": "api-clawer", "name": "ncm-api-clawer",
"version": "0.4.0", "version": "0.5.1",
"description": "网易云音乐客户端抓包工具", "description": "网易云音乐客户端抓包工具",
"main": "src/server/app.js", "main": "bin/cli.js",
"bin": {
"api-clawer": "./bin/cli.js"
},
"scripts": { "scripts": {
"start": "node src/index.js" "start": "node bin/cli.js",
"test": "jest"
}, },
"keywords": [ "keywords": [
"netease", "netease",
"music", "music",
"api", "api",
"clawer" "clawer",
"capture",
"proxy",
"mitm"
], ],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
@ -23,5 +30,8 @@
"pino": "^6.14.0", "pino": "^6.14.0",
"pino-pretty": "^7.6.1", "pino-pretty": "^7.6.1",
"ws": "^8.21.0" "ws": "^8.21.0"
},
"devDependencies": {
"jest": "^30.4.2"
} }
} }

2561
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,188 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs');
const axios = require('axios');
require('dotenv').config(); require('dotenv').config();
const app = express(); const CAPTURE_FILE = path.join(__dirname, '..', '..', 'captures.jsonl');
const PORT = process.env.PORT || 3000;
let capturedData = []; let capturedData = [];
let clients = []; let clients = [];
app.use(express.json()); // ======================== 数据持久化 ========================
/**
* 从文件加载历史数据
*/
function loadFromFile() {
try {
if (fs.existsSync(CAPTURE_FILE)) {
const content = fs.readFileSync(CAPTURE_FILE, 'utf-8').trim();
if (content) {
capturedData = content.split('\n')
.filter(line => line.trim())
.map(line => {
try { return JSON.parse(line); }
catch { return null; }
})
.filter(Boolean);
console.log(`Loaded ${capturedData.length} historical records from ${CAPTURE_FILE}`);
}
}
} catch (e) {
console.error('Failed to load capture file:', e.message);
}
}
/**
* 追加一条数据到文件
*/
function appendToFile(data) {
try {
fs.appendFileSync(CAPTURE_FILE, JSON.stringify(data) + '\n', 'utf-8');
} catch (e) {
console.error('Failed to append capture to file:', e.message);
}
}
/**
* 清空文件
*/
function clearFile() {
try {
fs.writeFileSync(CAPTURE_FILE, '', 'utf-8');
} catch (e) {
console.error('Failed to clear capture file:', e.message);
}
}
// ======================== 请求重放 ========================
/**
* 重放一个被抓包的请求
* @param {object} param0 { path, method, params, crypto, rawPath, requestHeaders }
* @returns {Promise<object>} 重放结果
*/
async function replayRequest({ path: apiPath, method, params, crypto, rawPath, requestHeaders }) {
const cryptoModule = require('../server/crypto');
const url = require('url');
const baseUrl = 'https://music.163.com';
const origPath = rawPath || apiPath;
// 根据加密类型构造请求
let requestUrl, requestBody, requestHeadersObj = {};
switch (crypto) {
case 'eapi': {
const eapiPath = '/eapi' + apiPath.replace(/^\/api/, '');
const encrypted = cryptoModule.eapi.encryptRequest(baseUrl + eapiPath, params);
requestUrl = baseUrl + eapiPath;
requestBody = encrypted.body;
break;
}
case 'linuxapi': {
const encrypted = cryptoModule.linuxapi.encryptRequest(apiPath, params);
requestUrl = baseUrl + '/api/linux/forward';
requestBody = encrypted.body;
break;
}
case 'api': {
requestUrl = baseUrl + apiPath;
requestBody = new url.URLSearchParams(params).toString();
requestHeadersObj['Content-Type'] = 'application/x-www-form-urlencoded';
break;
}
default: {
// weapi / 未知 -> 直接 POST JSON
requestUrl = baseUrl + apiPath;
requestBody = params;
requestHeadersObj['Content-Type'] = 'application/json';
break;
}
}
// 合并原始请求头
if (requestHeaders) {
['cookie', 'user-agent', 'referer', 'origin', 'x-real-ip'].forEach(key => {
if (requestHeaders[key]) requestHeadersObj[key] = requestHeaders[key];
});
}
if (!requestHeadersObj['User-Agent'] && !requestHeadersObj['user-agent']) {
requestHeadersObj['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
}
requestHeadersObj['X-Real-IP'] = '118.88.88.88';
const startTime = Date.now();
const response = await axios.post(requestUrl, requestBody, {
headers: requestHeadersObj,
responseType: 'arraybuffer',
timeout: 15000,
httpsAgent: new (require('https').Agent)({ rejectUnauthorized: false }),
});
const duration = Date.now() - startTime;
// 尝试解密响应
let responseData = null;
const resBuffer = Buffer.from(response.data);
const contentType = response.headers['content-type'] || '';
if (contentType.includes('json') || contentType.includes('text')) {
try {
responseData = JSON.parse(resBuffer.toString());
} catch {
// 非 JSON
}
}
// 尝试 eapi 解密
if (!responseData && resBuffer.length > 0) {
try {
const decrypted = cryptoModule.eapi.decrypt(resBuffer).toString();
responseData = JSON.parse(decrypted);
} catch {
// 无法解密
}
}
return {
timestamp: new Date().toISOString(),
path: apiPath,
rawPath: origPath,
crypto: crypto || 'replay',
param: params,
response: responseData,
statusCode: response.status,
method: method || 'POST',
duration,
requestHeaders: requestHeadersObj,
responseHeaders: { ...response.headers },
replay: true,
};
}
// ======================== Express App ========================
/**
* 创建前端 Express app (共享 capturedData clients)
*/
function createApp() {
const app = express();
// 启动时加载历史数据
loadFromFile();
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ limit: '10mb', extended: true }));
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.post('/api/capture', (req, res) => { app.post('/api/capture', (req, res) => {
const data = req.body; const data = req.body;
capturedData.push(data); capturedData.push(data);
console.log('Captured data:', data.path); console.log('Captured data:', data.path);
// 持久化
// 通知所有 SSE 客户端有新数据 appendToFile(data);
broadcastData(); broadcastData();
res.status(200).send('OK'); res.status(200).send('OK');
}); });
@ -26,7 +190,6 @@ app.get('/api/data', (req, res) => {
res.json(capturedData); res.json(capturedData);
}); });
// 版本信息端点
app.get('/api/version', (req, res) => { app.get('/api/version', (req, res) => {
try { try {
const packageJson = require('../../package.json'); const packageJson = require('../../package.json');
@ -37,41 +200,112 @@ app.get('/api/version', (req, res) => {
} }
}); });
// 清空数据端点
app.post('/api/clear', (req, res) => { app.post('/api/clear', (req, res) => {
capturedData = []; capturedData = [];
clearFile();
broadcastData(); broadcastData();
res.json({ success: true }); res.json({ success: true });
}); });
// SSE 端点 // 请求重放端点
app.post('/api/replay', async (req, res) => {
const { path: apiPath, method, params, crypto, rawPath, requestHeaders } = req.body;
if (!apiPath) {
return res.status(400).json({ error: 'Missing path' });
}
try {
const result = await replayRequest({ path: apiPath, method, params, crypto, rawPath, requestHeaders });
// 将重放结果也加入抓包列表
capturedData.push(result);
appendToFile(result);
broadcastData();
res.json(result);
} catch (e) {
console.error('Replay failed:', e.message);
res.status(500).json({ error: e.message });
}
});
// 获取/设置完整抓包模式
app.get('/api/settings', (req, res) => {
res.json({
fullCapture: global.fullCapture === true,
});
});
app.post('/api/settings', (req, res) => {
const { fullCapture } = req.body;
if (typeof fullCapture === 'boolean') {
global.fullCapture = fullCapture;
console.log(`Full capture mode: ${fullCapture ? 'ON' : 'OFF'}`);
res.json({ success: true, fullCapture });
} else {
res.status(400).json({ error: 'fullCapture must be boolean' });
}
});
// 获取数据统计信息
app.get('/api/stats', (req, res) => {
const total = capturedData.length;
const methods = {};
const cryptos = {};
const statusGroups = {};
capturedData.forEach(item => {
const m = (item.method || 'UNKNOWN').toUpperCase();
methods[m] = (methods[m] || 0) + 1;
const c = item.crypto || 'unknown';
cryptos[c] = (cryptos[c] || 0) + 1;
const sg = String(item.statusCode || '?')[0] + 'xx';
statusGroups[sg] = (statusGroups[sg] || 0) + 1;
});
res.json({ total, methods, cryptos, statusGroups });
});
app.get('/api/events', (req, res) => { app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
// 立即发送当前数据
res.write(`data: ${JSON.stringify(capturedData)}\n\n`); res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
// 添加到客户端列表
clients.push(res); clients.push(res);
req.on('close', () => { req.on('close', () => {
clients = clients.filter(client => client !== res); clients = clients.filter(client => client !== res);
}); });
}); });
return app;
}
function broadcastData() { function broadcastData() {
clients.forEach(client => { clients.forEach(client => {
try { try {
client.write(`data: ${JSON.stringify(capturedData)}\n\n`); client.write(`data: ${JSON.stringify(capturedData)}\n\n`);
} catch (e) { } catch (e) {
// 连接已断开,移除客户端
clients = clients.filter(c => c !== client); clients = clients.filter(c => c !== client);
} }
}); });
} }
app.listen(PORT, () => { /**
* 启动前端服务器
* @returns {Promise<import('http').Server>}
*/
function startClient(port) {
return new Promise((resolve, reject) => {
const PORT = port || process.env.PORT || 3000;
const app = createApp();
const server = app.listen(PORT, () => {
console.log(`Frontend server running at http://localhost:${PORT}`); console.log(`Frontend server running at http://localhost:${PORT}`);
resolve(server);
}); });
server.on('error', reject);
});
}
// 直接运行时启动
if (require.main === module) {
startClient();
}
module.exports = { startClient, createApp };

File diff suppressed because it is too large Load Diff

View File

@ -1,203 +1,17 @@
// 主入口文件,同时启动 server 和 client // 主入口文件,同时启动 proxy server 和 frontend client
// 确保 Ctrl+C 时能够优雅地关闭所有服务 // 确保 Ctrl+C 时能够优雅地关闭所有服务
require('dotenv').config(); require('dotenv').config();
const http = require('http'); /** @type {import('http').Server[]} */
const https = require('https');
// 保存服务器引用以便关闭
let servers = []; let servers = [];
// 代理 server.app.js /**
const startServer = () => { * 优雅关闭所有服务器
return new Promise((resolve, reject) => { */
const packageJson = require('../package.json');
const config = require('./server/cli.js')
.program({
name: packageJson.name.replace(/@.+\//, ''),
version: packageJson.version,
})
.option(['-v', '--version'], { action: 'version' })
.option(['-p', '--port'], {
metavar: 'http[:https]',
help: 'specify server port',
})
.option(['-a', '--address'], {
metavar: 'address',
help: 'specify server host',
})
.option(['-h', '--help'], { action: 'help' })
.parse(process.argv);
global.address = config.address;
config.port = (config.port || process.env.HOOK_PORT || '9000')
.split(':')
.map((string) => parseInt(string));
const invalid = (value) => isNaN(value) || value < 1 || value > 65535;
if (config.port.some(invalid)) {
console.log('Port must be a number higher than 0 and lower than 65535.');
process.exit(1);
}
if (!process.env.PORT) {
process.env.PORT = process.env.PORT || '3000';
}
const { logScope } = require('./server/logger');
const escape = require('querystring').escape;
const hook = require('./server/hook');
const server = require('./server/server');
const logger = logScope('app');
const target = Array.from(hook.target.host);
global.port = config.port;
global.proxy = null;
global.hosts = {};
global.endpoint = 'https://music.163.com';
server.whitelist = [
'://[\w.]*music\\.126\\.net',
'://[\w.]*vod\\.126\\.net',
'://acstatic-dun.126.net',
'://[\w.]*\\.netease\\.com',
'://[\w.]*\\.163yun\\.com',
];
if (config.endpoint) server.whitelist.push(escape(config.endpoint));
const dns = (host) =>
new Promise((resolve, reject) =>
require('dns').lookup(host, { all: true }, (error, records) =>
error
? reject(error)
: resolve(records.map((record) => record.address))
)
);
Promise.all(target.map(dns))
.then((result) => {
const { host } = hook.target;
result.forEach((array) => array.forEach(host.add, host));
server.whitelist = server.whitelist.concat(
Array.from(host).map(escape)
);
const log = (type) =>
logger.info(
`${['HTTP', 'HTTPS'][type]} Server running @ http://${
address || '0.0.0.0'
}:${port[type]}`
);
if (port[0]) {
const httpServer = server.http
.listen(port[0], address)
.once('listening', () => {
log(0);
servers.push(httpServer);
});
}
if (port[1]) {
const httpsServer = server.https
.listen(port[1], address)
.once('listening', () => {
log(1);
servers.push(httpsServer);
});
}
resolve();
})
.catch((error) => {
console.log(error);
reject(error);
});
});
};
// 启动 client
const startClient = () => {
return new Promise((resolve, reject) => {
const express = require('express');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
let capturedData = [];
let clients = [];
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ limit: '10mb', extended: true }));
app.use(express.static(path.join(__dirname, 'client/public')));
app.post('/api/capture', (req, res) => {
const data = req.body;
capturedData.push(data);
console.log('Captured data:', data.path);
broadcastData();
res.status(200).send('OK');
});
app.get('/api/data', (req, res) => {
res.json(capturedData);
});
app.get('/api/version', (req, res) => {
try {
const packageJson = require('../package.json');
res.json({ version: packageJson.version });
} catch (error) {
console.error('Failed to read package.json:', error);
res.json({ version: '0.1.0' });
}
});
app.post('/api/clear', (req, res) => {
capturedData = [];
broadcastData();
res.json({ success: true });
});
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
clients.push(res);
req.on('close', () => {
clients = clients.filter(client => client !== res);
});
});
function broadcastData() {
clients.forEach(client => {
try {
client.write(`data: ${JSON.stringify(capturedData)}\n\n`);
} catch (e) {
clients = clients.filter(c => c !== client);
}
});
}
const clientServer = app.listen(PORT, () => {
console.log(`Frontend server running at http://localhost:${PORT}`);
servers.push(clientServer);
resolve();
});
clientServer.on('error', reject);
});
};
// 优雅关闭所有服务器
const gracefulShutdown = async (signal) => { const gracefulShutdown = async (signal) => {
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`); console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
// 关闭所有服务器
const closePromises = servers.map(server => { const closePromises = servers.map(server => {
return new Promise((resolve) => { return new Promise((resolve) => {
server.close(() => { server.close(() => {
@ -205,9 +19,7 @@ const gracefulShutdown = async (signal) => {
resolve(); resolve();
}); });
// 设置超时,强制关闭 // 设置超时,强制关闭
setTimeout(() => { setTimeout(() => resolve(), 5000);
resolve();
}, 5000);
}); });
}); });
@ -223,10 +35,24 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// 启动所有服务 // 启动所有服务
const startAll = async () => { const startAll = async () => {
try { try {
await Promise.all([ const { startServer } = require('./server/app');
const { startClient } = require('./client/app');
// 确保环境变量
if (!process.env.PORT) process.env.PORT = '3000';
const [proxyResult, clientServer] = await Promise.all([
startServer(), startServer(),
startClient() startClient()
]); ]);
// 收集服务器实例以便优雅关闭
if (proxyResult) {
if (proxyResult.httpServer) servers.push(proxyResult.httpServer);
if (proxyResult.httpsServer) servers.push(proxyResult.httpsServer);
}
servers.push(clientServer);
console.log('所有服务启动完成!'); console.log('所有服务启动完成!');
} catch (error) { } catch (error) {
console.error('启动服务失败:', error); console.error('启动服务失败:', error);

View File

@ -1,4 +1,10 @@
const packageJson = require('../../package.json'); const packageJson = require('../../package.json');
/**
* 启动代理服务器
* @returns {Promise<{httpServer: import('http').Server|null, httpsServer: import('http').Server|null}>}
*/
function startServer(cliArgs) {
const config = require('./cli.js') const config = require('./cli.js')
.program({ .program({
name: packageJson.name.replace(/@.+\//, ''), name: packageJson.name.replace(/@.+\//, ''),
@ -14,7 +20,7 @@ const config = require('./cli.js')
help: 'specify server host', help: 'specify server host',
}) })
.option(['-h', '--help'], { action: 'help' }) .option(['-h', '--help'], { action: 'help' })
.parse(process.argv); .parse(cliArgs || process.argv);
require('dotenv').config(); require('dotenv').config();
@ -64,7 +70,7 @@ const dns = (host) =>
) )
); );
Promise.all(target.map(dns)) return Promise.all(target.map(dns))
.then((result) => { .then((result) => {
const { host } = hook.target; const { host } = hook.target;
result.forEach((array) => array.forEach(host.add, host)); result.forEach((array) => array.forEach(host.add, host));
@ -77,16 +83,26 @@ Promise.all(target.map(dns))
address || '0.0.0.0' address || '0.0.0.0'
}:${port[type]}` }:${port[type]}`
); );
let httpServer = null, httpsServer = null;
if (port[0]) if (port[0])
server.http httpServer = server.http
.listen(port[0], address) .listen(port[0], address)
.once('listening', () => log(0)); .once('listening', () => log(0));
if (port[1]) if (port[1])
server.https httpsServer = server.https
.listen(port[1], address) .listen(port[1], address)
.once('listening', () => log(1)); .once('listening', () => log(1));
return { httpServer, httpsServer };
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
process.exit(1); process.exit(1);
}); });
}
// 直接运行时启动
if (require.main === module) {
startServer();
}
module.exports = { startServer };

97
src/server/crypto.test.js Normal file
View File

@ -0,0 +1,97 @@
const crypto = require('./crypto');
describe('crypto', () => {
describe('eapi encrypt/decrypt', () => {
test('should encrypt and decrypt correctly', () => {
const original = Buffer.from('Hello, World! This is a test message for eapi.');
const encrypted = crypto.eapi.encrypt(original);
const decrypted = crypto.eapi.decrypt(encrypted);
expect(decrypted.toString()).toBe(original.toString());
});
test('should handle empty buffer', () => {
const original = Buffer.from('');
const encrypted = crypto.eapi.encrypt(original);
const decrypted = crypto.eapi.decrypt(encrypted);
expect(decrypted.toString()).toBe('');
});
});
describe('linuxapi encrypt/decrypt', () => {
test('should encrypt and decrypt correctly', () => {
const original = Buffer.from('{"method":"POST","url":"https://music.163.com/api/test","params":{"id":1}}');
const encrypted = crypto.linuxapi.encrypt(original);
const decrypted = crypto.linuxapi.decrypt(encrypted);
expect(decrypted.toString()).toBe(original.toString());
});
});
describe('base64', () => {
test('should encode and decode correctly', () => {
const original = 'Hello World';
const encoded = crypto.base64.encode(original);
const decoded = crypto.base64.decode(encoded);
expect(decoded).toBe(original);
});
test('should handle URL-safe characters', () => {
const original = 'test+data/with=special?chars';
const encoded = crypto.base64.encode(original);
expect(encoded).not.toContain('+');
expect(encoded).not.toContain('/');
const decoded = crypto.base64.decode(encoded);
expect(decoded).toBe(original);
});
});
describe('md5', () => {
test('should produce consistent hash', () => {
const hash1 = crypto.md5.digest('test');
const hash2 = crypto.md5.digest('test');
expect(hash1).toBe(hash2);
expect(hash1).toHaveLength(32);
});
test('should produce different hash for different inputs', () => {
const hash1 = crypto.md5.digest('hello');
const hash2 = crypto.md5.digest('world');
expect(hash1).not.toBe(hash2);
});
});
describe('random', () => {
test('hex should produce correct length', () => {
expect(crypto.random.hex(16)).toHaveLength(16);
expect(crypto.random.hex(32)).toHaveLength(32);
});
test('uuid should produce valid format', () => {
const uuid = crypto.random.uuid();
expect(uuid).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe('eapi.encryptRequest', () => {
test('should produce valid output', () => {
const result = crypto.eapi.encryptRequest(
'https://music.163.com/eapi/song/enhance/player/url',
{ id: '123', br: 320000 }
);
expect(result).toHaveProperty('url');
expect(result).toHaveProperty('body');
expect(result.body).toContain('params=');
});
});
describe('linuxapi.encryptRequest', () => {
test('should produce valid output', () => {
const result = crypto.linuxapi.encryptRequest(
'https://music.163.com/api/song/enhance/player/url',
{ id: '123' }
);
expect(result).toHaveProperty('url');
expect(result).toHaveProperty('body');
expect(result.url).toContain('/api/linux/forward');
});
});
});

View File

@ -98,8 +98,35 @@ const domainList = [
'interface3.music.163.com', 'interface3.music.163.com',
]; ];
/**
* 判断是否为网易云相关域名
*/
function isNeteaseHost(hostname) {
if (!hostname) return false;
const neteasePatterns = [
'music.163.com', 'music.126.net', 'vod.126.net',
'iplay.163.com', 'look.163.com', 'y.163.com',
'interface.music.163.com', '163yun.com',
'163jiasu.com', 'netease.com',
];
return neteasePatterns.some(p => hostname.includes(p));
}
/**
* 是否启用完整抓包模式 (非网易云流量也捕获)
*/
function isFullCapture() {
return global.fullCapture === true;
}
hook.request.before = (ctx) => { hook.request.before = (ctx) => {
const { req } = ctx; const { req } = ctx;
// 记录请求开始时间和请求头
ctx.startTime = Date.now();
ctx.requestHeaders = { ...req.headers };
// 标记是否网易云
ctx.isNeteaseDomain = isNeteaseHost(req.headers.host);
req.url = req.url =
(req.url.startsWith('http://') (req.url.startsWith('http://')
? '' ? ''
@ -111,11 +138,7 @@ hook.request.before = (ctx) => {
? req.headers.host ? req.headers.host
: null)) + req.url; : null)) + req.url;
const url = parse(req.url); const url = parse(req.url);
if ( // 所有请求都走代理 (不再局限网易云)
[url.hostname, req.headers.host].some((host) =>
isHost(host, 'music.163.com')
)
)
ctx.decision = 'proxy'; ctx.decision = 'proxy';
if (process.env.NETEASE_COOKIE && url.path.includes('url')) { if (process.env.NETEASE_COOKIE && url.path.includes('url')) {
@ -321,7 +344,7 @@ hook.request.before = (ctx) => {
} }
} }
ctx.netease = netease; ctx.netease = netease;
console.log(netease.path, netease.param) // 这里输出了网易云音乐的抓包数据, 重点看这里 logger.info({ path: netease.path, params: netease.param }, 'Captured request')
} }
}) })
.catch( .catch(
@ -369,6 +392,12 @@ hook.request.after = (ctx) => {
const { req, proxyRes, netease, package: pkg } = ctx; const { req, proxyRes, netease, package: pkg } = ctx;
if (netease) { if (netease) {
// 计算请求耗时
const duration = ctx.startTime ? Date.now() - ctx.startTime : 0;
// 捕获响应头
const responseHeaders = proxyRes ? { ...proxyRes.headers } : {};
delete responseHeaders['transfer-encoding'];
return request return request
.read(proxyRes, true) .read(proxyRes, true)
.then((buffer) => { .then((buffer) => {
@ -416,7 +445,10 @@ hook.request.after = (ctx) => {
param: netease.param, param: netease.param,
response: netease.jsonBody, response: netease.jsonBody,
statusCode: proxyRes.statusCode, statusCode: proxyRes.statusCode,
method: req.method method: req.method,
duration,
requestHeaders: ctx.requestHeaders,
responseHeaders,
}; };
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend) axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
.catch(err => logger.error('Failed to send data to frontend:', err)); .catch(err => logger.error('Failed to send data to frontend:', err));
@ -430,9 +462,12 @@ hook.request.after = (ctx) => {
crypto: netease.crypto || null, crypto: netease.crypto || null,
param: netease.param, param: netease.param,
response: null, response: null,
statusCode: proxyRes.statusCode, statusCode: proxyRes ? proxyRes.statusCode : null,
error: error.message, error: error.message,
method: req.method method: req.method,
duration,
requestHeaders: ctx.requestHeaders,
responseHeaders,
}; };
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend) axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
.catch(err => logger.error('Failed to send data to frontend:', err)); .catch(err => logger.error('Failed to send data to frontend:', err));
@ -455,16 +490,69 @@ hook.request.after = (ctx) => {
proxyRes.headers['content-type'] = 'audio/*'; proxyRes.headers['content-type'] = 'audio/*';
} }
} }
// ========== 通用抓包: 捕获所有请求 (非网易云也抓) ==========
// 只在全抓包模式或网易云域名下捕获
if (!netease && !pkg && (isFullCapture() || ctx.isNeteaseDomain)) {
const duration = ctx.startTime ? Date.now() - ctx.startTime : 0;
const responseHeaders = proxyRes ? { ...proxyRes.headers } : {};
delete responseHeaders['transfer-encoding'];
const reqUrl = req.url || '';
const contentType = (proxyRes && proxyRes.headers['content-type']) || '';
// 基本数据 (所有请求都有)
const dataToSend = {
timestamp: new Date().toISOString(),
path: reqUrl,
method: req.method || 'GET',
statusCode: proxyRes ? proxyRes.statusCode : null,
duration,
requestHeaders: ctx.requestHeaders,
responseHeaders,
isNetease: ctx.isNeteaseDomain || false,
hostname: parse(reqUrl).hostname || req.headers.host || '',
};
// 尝试读取响应体 (仅对文本类响应,且大小限制 512KB)
const isTextResponse = contentType.includes('json') || contentType.includes('text') || contentType.includes('javascript') || contentType.includes('xml');
const contentLength = parseInt(proxyRes && proxyRes.headers['content-length'] || '0', 10);
if (proxyRes && isTextResponse && contentLength < 512 * 1024) {
return request.read(proxyRes, true)
.then((buffer) => {
if (buffer && buffer.length > 0 && buffer.length < 512 * 1024) {
const bodyStr = buffer.toString();
try {
dataToSend.response = JSON.parse(bodyStr);
} catch {
dataToSend.responseBody = bodyStr.slice(0, 10000); // 限制长度
}
}
})
.catch(() => {})
.then(() => {
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
.catch(err => logger.error('Failed to send capture data:', err.message));
});
} else {
// 没有响应体或非文本,直接发送基础信息
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
.catch(err => logger.error('Failed to send capture data:', err.message));
}
}
}; };
hook.connect.before = (ctx) => { hook.connect.before = (ctx) => {
const { req } = ctx; const { req } = ctx;
const url = parse('https://' + req.url); const url = parse('https://' + req.url);
if ( const hostname = url.hostname || '';
[url.hostname, req.headers.host].some((host) =>
// 网易云域名: 走本地 MITM 代理 (原有逻辑)
const isNetease = [url.hostname, req.headers.host].some((host) =>
hook.target.host.has(host) hook.target.host.has(host)
) );
) {
if (isNetease) {
if (parseInt(url.port) === 80) { if (parseInt(url.port) === 80) {
req.url = `${global.address || 'localhost'}:${global.port[0]}`; req.url = `${global.address || 'localhost'}:${global.port[0]}`;
req.local = true; req.local = true;
@ -474,7 +562,18 @@ hook.connect.before = (ctx) => {
} else { } else {
ctx.decision = 'blank'; ctx.decision = 'blank';
} }
} else if (url.href.includes(global.endpoint)) ctx.decision = 'proxy'; } else if (url.href.includes(global.endpoint)) {
ctx.decision = 'proxy';
} else if (isFullCapture()) {
// 完整抓包模式: 非网易云域名也走本地 MITM 代理
// 这样就能捕获所有 HTTPS 流量
if (global.port[1]) {
req.url = `${global.address || 'localhost'}:${global.port[1]}`;
req.local = true;
} else {
ctx.decision = 'blank';
}
}
}; };
hook.negotiate.before = (ctx) => { hook.negotiate.before = (ctx) => {
@ -482,6 +581,12 @@ hook.negotiate.before = (ctx) => {
const url = parse('https://' + req.url); const url = parse('https://' + req.url);
const target = hook.target.host; const target = hook.target.host;
if (req.local || decision) return; if (req.local || decision) return;
// 完整抓包: 非网易云域名直接 MITM (sni 域名自动加入 target set)
if (isFullCapture() && socket.sni && !target.has(socket.sni)) {
target.add(socket.sni);
ctx.decision = 'blank';
return;
}
if (target.has(socket.sni) && !target.has(url.hostname)) { if (target.has(socket.sni) && !target.has(url.hostname)) {
target.add(url.hostname); target.add(url.hostname);
ctx.decision = 'blank'; ctx.decision = 'blank';

View File

@ -1,83 +0,0 @@
const { CancelRequest } = require('./cancel');
const request = require('./request');
const RequestCancelled = require('./exceptions/RequestCancelled');
describe('request()', () => {
test('will throw RequestCancelled when the CancelRequest has been cancelled', async () => {
const cancelRequest = new CancelRequest();
cancelRequest.cancel();
try {
await request(
'GET',
'https://www.example.com',
undefined,
undefined,
undefined,
cancelRequest
);
} catch (e) {
console.log(e);
expect(e).toBeInstanceOf(RequestCancelled);
return;
}
throw new Error('It should not be fulfilled.');
});
test('will NOT throw RequestCancelled when the CancelRequest has not been cancelled', async () => {
const cancelRequest = new CancelRequest();
return request(
'GET',
'https://www.example.com',
undefined,
undefined,
undefined,
cancelRequest
);
}, 15000);
test('headers should be in the response', async () => {
const response = await request('GET', 'https://www.example.com');
expect(response.headers).toBeDefined();
}, 15000);
test('.body(raw: false) should returns the string', async () => {
const response = await request('GET', 'https://www.example.com');
const body = await response.body(false);
expect(typeof body === 'string').toBeTruthy();
}, 15000);
test('.body(raw: true) should returns the Buffer', async () => {
const response = await request('GET', 'https://www.example.com');
const body = await response.body(true);
expect(body).toBeInstanceOf(Buffer);
}, 15000);
// FIXME: re-enable after api.opensource.org becomes online
//
// test('.json() should returns the deserialized data', async () => {
// const response = await request(
// 'GET',
// 'https://api.opensource.org/licenses/'
// );
// const body = await response.json();
// expect(Array.isArray(body)).toBeTruthy();
// }, 15000);
// test('.url should be the request URL', async () => {
// const response = await request(
// 'GET',
// 'https://api.opensource.org/licenses/'
// );
// expect(response.url).toStrictEqual(
// url.parse('https://api.opensource.org/licenses/')
// );
// }, 15000);
});

View File

@ -0,0 +1,55 @@
const { isHost, cookieToMap, mapToCookie } = require('./utilities');
describe('utilities', () => {
describe('isHost', () => {
test('should match host in URL', () => {
expect(isHost('https://music.163.com/api/playlist', 'music.163.com')).toBe(true);
});
test('should not match different host', () => {
expect(isHost('https://example.com/api', 'music.163.com')).toBe(false);
});
test('should match host in subdomain', () => {
expect(isHost('https://interface.music.163.com/api', 'music.163.com')).toBe(true);
});
});
describe('cookieToMap', () => {
test('should parse cookie string to map', () => {
const result = cookieToMap('key1=value1; key2=value2');
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
});
test('should handle empty string', () => {
const result = cookieToMap('');
expect(result).toEqual({});
});
test('should handle cookies without space around =', () => {
const result = cookieToMap('key1=value1; key2=value2');
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
});
});
describe('mapToCookie', () => {
test('should convert map to cookie string', () => {
const result = mapToCookie({ key1: 'value1', key2: 'value2' });
expect(result).toBe('key1=value1; key2=value2');
});
test('should handle empty map', () => {
const result = mapToCookie({});
expect(result).toBe('');
});
});
describe('cookieToMap <-> mapToCookie round trip', () => {
test('should be reversible', () => {
const original = { a: '1', b: '2', c: '3' };
const cookieStr = mapToCookie(original);
const parsed = cookieToMap(cookieStr);
expect(parsed).toEqual(original);
});
});
});