Compare commits

..

No commits in common. "48f35502ba25542f8da618ffb6a2c6453b2bed02" and "9f07f7d89fd2c0d1071edc903cc038e3a4ec7562" have entirely different histories.

12 changed files with 483 additions and 4202 deletions

3
.gitignore vendored
View File

@ -70,9 +70,6 @@ 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

View File

@ -1,119 +0,0 @@
#!/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,23 +1,16 @@
{ {
"name": "ncm-api-clawer", "name": "api-clawer",
"version": "0.5.1", "version": "0.4.0",
"description": "网易云音乐客户端抓包工具", "description": "网易云音乐客户端抓包工具",
"main": "bin/cli.js", "main": "src/server/app.js",
"bin": {
"api-clawer": "./bin/cli.js"
},
"scripts": { "scripts": {
"start": "node bin/cli.js", "start": "node src/index.js"
"test": "jest"
}, },
"keywords": [ "keywords": [
"netease", "netease",
"music", "music",
"api", "api",
"clawer", "clawer"
"capture",
"proxy",
"mitm"
], ],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
@ -30,8 +23,5 @@
"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,311 +1,77 @@
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 CAPTURE_FILE = path.join(__dirname, '..', '..', 'captures.jsonl'); const app = express();
const PORT = process.env.PORT || 3000;
let capturedData = []; let capturedData = [];
let clients = []; let clients = [];
// ======================== 数据持久化 ======================== app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
/** app.post('/api/capture', (req, res) => {
* 从文件加载历史数据 const data = req.body;
*/ capturedData.push(data);
function loadFromFile() { console.log('Captured data:', data.path);
// 通知所有 SSE 客户端有新数据
broadcastData();
res.status(200).send('OK');
});
app.get('/api/data', (req, res) => {
res.json(capturedData);
});
// 版本信息端点
app.get('/api/version', (req, res) => {
try { try {
if (fs.existsSync(CAPTURE_FILE)) { const packageJson = require('../../package.json');
const content = fs.readFileSync(CAPTURE_FILE, 'utf-8').trim(); res.json({ version: packageJson.version });
if (content) { } catch (error) {
capturedData = content.split('\n') console.error('Failed to read package.json:', error);
.filter(line => line.trim()) res.json({ version: '0.1.0' });
.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);
} }
} });
/** // 清空数据端点
* 追加一条数据到文件 app.post('/api/clear', (req, res) => {
*/ capturedData = [];
function appendToFile(data) { broadcastData();
try { res.json({ success: true });
fs.appendFileSync(CAPTURE_FILE, JSON.stringify(data) + '\n', 'utf-8'); });
} catch (e) {
console.error('Failed to append capture to file:', e.message);
}
}
/** // SSE 端点
* 清空文件 app.get('/api/events', (req, res) => {
*/ res.setHeader('Content-Type', 'text/event-stream');
function clearFile() { res.setHeader('Cache-Control', 'no-cache');
try { res.setHeader('Connection', 'keep-alive');
fs.writeFileSync(CAPTURE_FILE, '', 'utf-8');
} catch (e) {
console.error('Failed to clear capture file:', e.message);
}
}
// ======================== 请求重放 ======================== // 立即发送当前数据
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
/** // 添加到客户端列表
* 重放一个被抓包的请求 clients.push(res);
* @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;
}
}
// 合并原始请求头 req.on('close', () => {
if (requestHeaders) { clients = clients.filter(client => client !== res);
['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.post('/api/capture', (req, res) => {
const data = req.body;
capturedData.push(data);
console.log('Captured data:', data.path);
// 持久化
appendToFile(data);
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 = [];
clearFile();
broadcastData();
res.json({ success: true });
});
// 请求重放端点
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) => {
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);
});
});
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, () => {
* 启动前端服务器 console.log(`Frontend server running at http://localhost:${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}`);
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,17 +1,203 @@
// 主入口文件,同时启动 proxy server 和 frontend client // 主入口文件,同时启动 server 和 client
// 确保 Ctrl+C 时能够优雅地关闭所有服务 // 确保 Ctrl+C 时能够优雅地关闭所有服务
require('dotenv').config(); require('dotenv').config();
/** @type {import('http').Server[]} */ const http = require('http');
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(() => {
@ -19,7 +205,9 @@ const gracefulShutdown = async (signal) => {
resolve(); resolve();
}); });
// 设置超时,强制关闭 // 设置超时,强制关闭
setTimeout(() => resolve(), 5000); setTimeout(() => {
resolve();
}, 5000);
}); });
}); });
@ -35,24 +223,10 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// 启动所有服务 // 启动所有服务
const startAll = async () => { const startAll = async () => {
try { try {
const { startServer } = require('./server/app'); await Promise.all([
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);
@ -60,4 +234,4 @@ const startAll = async () => {
} }
}; };
startAll(); startAll();

View File

@ -1,108 +1,92 @@
const packageJson = require('../../package.json'); const packageJson = require('../../package.json');
const config = require('./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);
/** require('dotenv').config();
* 启动代理服务器
* @returns {Promise<{httpServer: import('http').Server|null, httpsServer: import('http').Server|null}>}
*/
function startServer(cliArgs) {
const config = require('./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(cliArgs || process.argv);
require('dotenv').config(); 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);
}
global.address = config.address; // 确保 PORT 环境变量可用,供 hook.js 发送数据使用
config.port = (config.port || process.env.HOOK_PORT || '9000') if (!process.env.PORT) {
.split(':') process.env.PORT = process.env.PORT || '3000';
.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);
}
// 确保 PORT 环境变量可用,供 hook.js 发送数据使用 const { logScope } = require('./logger');
if (!process.env.PORT) { const parse = require('url').parse;
process.env.PORT = process.env.PORT || '3000'; const hook = require('./hook');
} const server = require('./server');
const logger = logScope('app');
const target = Array.from(hook.target.host);
const { logScope } = require('./logger'); global.port = config.port;
const parse = require('url').parse; global.proxy = null;
const hook = require('./hook'); global.hosts = {};
const server = require('./server'); global.endpoint = 'https://music.163.com';
const logger = logScope('app');
const target = Array.from(hook.target.host);
global.port = config.port; server.whitelist = [
global.proxy = null; '://[\\w.]*music\\.126\\.net',
global.hosts = {}; '://[\\w.]*vod\\.126\\.net',
global.endpoint = 'https://music.163.com'; '://acstatic-dun.126.net',
'://[\\w.]*\\.netease.com',
'://[\\w.]*\\.163yun.com',
];
server.whitelist = [ if (config.endpoint) server.whitelist.push(escape(config.endpoint));
'://[\\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))
)
);
const dns = (host) => Promise.all(target.map(dns))
new Promise((resolve, reject) => .then((result) => {
require('dns').lookup(host, { all: true }, (error, records) => const { host } = hook.target;
error result.forEach((array) => array.forEach(host.add, host));
? reject(error) server.whitelist = server.whitelist.concat(
: resolve(records.map((record) => record.address)) Array.from(host).map(escape)
)
); );
const log = (type) =>
return Promise.all(target.map(dns)) logger.info(
.then((result) => { `${['HTTP', 'HTTPS'][type]} Server running @ http://${
const { host } = hook.target; address || '0.0.0.0'
result.forEach((array) => array.forEach(host.add, host)); }:${port[type]}`
server.whitelist = server.whitelist.concat(
Array.from(host).map(escape)
); );
const log = (type) => if (port[0])
logger.info( server.http
`${['HTTP', 'HTTPS'][type]} Server running @ http://${ .listen(port[0], address)
address || '0.0.0.0' .once('listening', () => log(0));
}:${port[type]}` if (port[1])
); server.https
let httpServer = null, httpsServer = null; .listen(port[1], address)
if (port[0]) .once('listening', () => log(1));
httpServer = server.http })
.listen(port[0], address) .catch((error) => {
.once('listening', () => log(0)); console.log(error);
if (port[1]) process.exit(1);
httpsServer = server.https });
.listen(port[1], address)
.once('listening', () => log(1));
return { httpServer, httpsServer };
})
.catch((error) => {
console.log(error);
process.exit(1);
});
}
// 直接运行时启动
if (require.main === module) {
startServer();
}
module.exports = { startServer };

View File

@ -1,97 +0,0 @@
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,35 +98,8 @@ 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://')
? '' ? ''
@ -138,8 +111,12 @@ 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 (
ctx.decision = 'proxy'; [url.hostname, req.headers.host].some((host) =>
isHost(host, 'music.163.com')
)
)
ctx.decision = 'proxy';
if (process.env.NETEASE_COOKIE && url.path.includes('url')) { if (process.env.NETEASE_COOKIE && url.path.includes('url')) {
var cookies = cookieToMap(req.headers.cookie); var cookies = cookieToMap(req.headers.cookie);
@ -344,7 +321,7 @@ hook.request.before = (ctx) => {
} }
} }
ctx.netease = netease; ctx.netease = netease;
logger.info({ path: netease.path, params: netease.param }, 'Captured request') console.log(netease.path, netease.param) // 这里输出了网易云音乐的抓包数据, 重点看这里
} }
}) })
.catch( .catch(
@ -392,12 +369,6 @@ 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) => {
@ -445,10 +416,7 @@ 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));
@ -462,12 +430,9 @@ hook.request.after = (ctx) => {
crypto: netease.crypto || null, crypto: netease.crypto || null,
param: netease.param, param: netease.param,
response: null, response: null,
statusCode: proxyRes ? proxyRes.statusCode : null, statusCode: proxyRes.statusCode,
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));
@ -490,69 +455,16 @@ 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);
const hostname = url.hostname || ''; if (
[url.hostname, req.headers.host].some((host) =>
// 网易云域名: 走本地 MITM 代理 (原有逻辑) hook.target.host.has(host)
const isNetease = [url.hostname, req.headers.host].some((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;
@ -562,18 +474,7 @@ hook.connect.before = (ctx) => {
} else { } else {
ctx.decision = 'blank'; ctx.decision = 'blank';
} }
} else if (url.href.includes(global.endpoint)) { } else if (url.href.includes(global.endpoint)) ctx.decision = 'proxy';
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) => {
@ -581,12 +482,6 @@ 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

@ -0,0 +1,83 @@
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

@ -1,55 +0,0 @@
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);
});
});
});