mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-clawer.git
synced 2026-08-12 17:10:36 +00:00
Compare commits
No commits in common. "48f35502ba25542f8da618ffb6a2c6453b2bed02" and "9f07f7d89fd2c0d1071edc903cc038e3a4ec7562" have entirely different histories.
48f35502ba
...
9f07f7d89f
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||||
|
|||||||
119
bin/cli.js
119
bin/cli.js
@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
20
package.json
20
package.json
@ -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
2561
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
218
src/index.js
218
src/index.js
@ -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();
|
||||||
@ -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 };
|
|
||||||
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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';
|
||||||
|
|||||||
83
src/server/request.test.js
Normal file
83
src/server/request.test.js
Normal 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);
|
||||||
|
});
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user