mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-clawer.git
synced 2026-08-12 08:50:33 +00:00
feat: 支持更多功能
This commit is contained in:
parent
9f07f7d89f
commit
0fe0500ae1
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
captures.jsonl
|
||||||
|
.env
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -70,6 +70,9 @@ web_modules/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Captured data persistence
|
||||||
|
captures.jsonl
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
.parcel-cache
|
.parcel-cache
|
||||||
|
|||||||
119
bin/cli.js
Normal file
119
bin/cli.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* api-clawer — 网易云音乐客户端抓包工具
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* npx api-clawer # 启动 (默认端口 3000 + 9000:9001)
|
||||||
|
* npx api-clawer --help # 查看帮助
|
||||||
|
* npx api-clawer --version # 查看版本
|
||||||
|
* npx api-clawer -p 8080 # 指定 HTTP 代理端口
|
||||||
|
* npx api-clawer -p 8080:8443 # 指定 HTTP + HTTPS 代理端口
|
||||||
|
* npx api-clawer -a 127.0.0.1 # 绑定地址
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 确保 dotenv 加载项目根目录的 .env
|
||||||
|
process.env.DOTENV_CONFIG_PATH = path.resolve(__dirname, '..', '.env');
|
||||||
|
require('dotenv').config({ path: process.env.DOTENV_CONFIG_PATH });
|
||||||
|
|
||||||
|
const packageJson = require('../package.json');
|
||||||
|
|
||||||
|
// 简易参数解析
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const flags = {
|
||||||
|
help: args.includes('--help') || args.includes('-h'),
|
||||||
|
version: args.includes('--version') || args.includes('-v'),
|
||||||
|
port: null,
|
||||||
|
address: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析 -p/--port
|
||||||
|
const portIdx = args.findIndex(a => a === '-p' || a === '--port');
|
||||||
|
if (portIdx !== -1 && args[portIdx + 1] && !args[portIdx + 1].startsWith('-')) {
|
||||||
|
flags.port = args[portIdx + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 -a/--address
|
||||||
|
const addrIdx = args.findIndex(a => a === '-a' || a === '--address');
|
||||||
|
if (addrIdx !== -1 && args[addrIdx + 1] && !args[addrIdx + 1].startsWith('-')) {
|
||||||
|
flags.address = args[addrIdx + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.help) {
|
||||||
|
console.log(`
|
||||||
|
api-clawer v${packageJson.version} — 网易云音乐客户端抓包工具
|
||||||
|
|
||||||
|
用法:
|
||||||
|
npx api-clawer [选项]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
-p, --port <http[:https]> 指定代理端口 (默认: 9000:9001)
|
||||||
|
-a, --address <address> 绑定监听地址 (默认: 0.0.0.0)
|
||||||
|
-v, --version 输出版本号
|
||||||
|
-h, --help 输出帮助信息
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
PORT=3000 前端界面端口
|
||||||
|
HOOK_PORT=9000:9001 代理服务器端口
|
||||||
|
LOG_LEVEL=info 日志级别 (debug/info/warn/error)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
npx api-clawer
|
||||||
|
npx api-clawer -p 8080
|
||||||
|
npx api-clawer -p 8080:8443 -a 127.0.0.1
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.version) {
|
||||||
|
console.log(packageJson.version);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 CLI 参数写入环境变量,供 server/app.js 读取
|
||||||
|
if (flags.port) process.env.HOOK_PORT = flags.port;
|
||||||
|
if (flags.address) process.env.ADDRESS = flags.address;
|
||||||
|
if (!process.env.PORT) process.env.PORT = '3000';
|
||||||
|
|
||||||
|
const { startServer } = require('../src/server/app');
|
||||||
|
const { startClient } = require('../src/client/app');
|
||||||
|
|
||||||
|
/** @type {import('http').Server[]} */
|
||||||
|
const servers = [];
|
||||||
|
|
||||||
|
const gracefulShutdown = async (signal) => {
|
||||||
|
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
|
||||||
|
const closePromises = servers.map(s => new Promise(resolve => {
|
||||||
|
s.close(() => resolve());
|
||||||
|
setTimeout(() => resolve(), 5000);
|
||||||
|
}));
|
||||||
|
await Promise.all(closePromises);
|
||||||
|
console.log('所有服务已关闭');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [proxyResult, clientServer] = await Promise.all([
|
||||||
|
startServer(),
|
||||||
|
startClient()
|
||||||
|
]);
|
||||||
|
if (proxyResult) {
|
||||||
|
if (proxyResult.httpServer) servers.push(proxyResult.httpServer);
|
||||||
|
if (proxyResult.httpsServer) servers.push(proxyResult.httpsServer);
|
||||||
|
}
|
||||||
|
servers.push(clientServer);
|
||||||
|
console.log('所有服务启动完成!');
|
||||||
|
console.log(` 前端界面: http://localhost:${process.env.PORT || 3000}`);
|
||||||
|
console.log(` HTTP 代理: http://localhost:${(flags.port || process.env.HOOK_PORT || '9000').split(':')[0]}`);
|
||||||
|
console.log(' 按 Ctrl+C 停止服务');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('启动服务失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
20
package.json
20
package.json
@ -1,16 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "api-clawer",
|
"name": "ncm-api-clawer",
|
||||||
"version": "0.4.0",
|
"version": "0.5.1",
|
||||||
"description": "网易云音乐客户端抓包工具",
|
"description": "网易云音乐客户端抓包工具",
|
||||||
"main": "src/server/app.js",
|
"main": "bin/cli.js",
|
||||||
|
"bin": {
|
||||||
|
"api-clawer": "./bin/cli.js"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js"
|
"start": "node bin/cli.js",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"netease",
|
"netease",
|
||||||
"music",
|
"music",
|
||||||
"api",
|
"api",
|
||||||
"clawer"
|
"clawer",
|
||||||
|
"capture",
|
||||||
|
"proxy",
|
||||||
|
"mitm"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -23,5 +30,8 @@
|
|||||||
"pino": "^6.14.0",
|
"pino": "^6.14.0",
|
||||||
"pino-pretty": "^7.6.1",
|
"pino-pretty": "^7.6.1",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^30.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2561
pnpm-lock.yaml
generated
2561
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,33 +1,196 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const axios = require('axios');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
const CAPTURE_FILE = path.join(__dirname, '..', '..', 'captures.jsonl');
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
let capturedData = [];
|
let capturedData = [];
|
||||||
let clients = [];
|
let clients = [];
|
||||||
|
|
||||||
app.use(express.json());
|
// ======================== 数据持久化 ========================
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
app.post('/api/capture', (req, res) => {
|
/**
|
||||||
|
* 从文件加载历史数据
|
||||||
|
*/
|
||||||
|
function loadFromFile() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CAPTURE_FILE)) {
|
||||||
|
const content = fs.readFileSync(CAPTURE_FILE, 'utf-8').trim();
|
||||||
|
if (content) {
|
||||||
|
capturedData = content.split('\n')
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
try { return JSON.parse(line); }
|
||||||
|
catch { return null; }
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
console.log(`Loaded ${capturedData.length} historical records from ${CAPTURE_FILE}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load capture file:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加一条数据到文件
|
||||||
|
*/
|
||||||
|
function appendToFile(data) {
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(CAPTURE_FILE, JSON.stringify(data) + '\n', 'utf-8');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to append capture to file:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空文件
|
||||||
|
*/
|
||||||
|
function clearFile() {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(CAPTURE_FILE, '', 'utf-8');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to clear capture file:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 请求重放 ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重放一个被抓包的请求
|
||||||
|
* @param {object} param0 { path, method, params, crypto, rawPath, requestHeaders }
|
||||||
|
* @returns {Promise<object>} 重放结果
|
||||||
|
*/
|
||||||
|
async function replayRequest({ path: apiPath, method, params, crypto, rawPath, requestHeaders }) {
|
||||||
|
const cryptoModule = require('../server/crypto');
|
||||||
|
const url = require('url');
|
||||||
|
|
||||||
|
const baseUrl = 'https://music.163.com';
|
||||||
|
const origPath = rawPath || apiPath;
|
||||||
|
|
||||||
|
// 根据加密类型构造请求
|
||||||
|
let requestUrl, requestBody, requestHeadersObj = {};
|
||||||
|
|
||||||
|
switch (crypto) {
|
||||||
|
case 'eapi': {
|
||||||
|
const eapiPath = '/eapi' + apiPath.replace(/^\/api/, '');
|
||||||
|
const encrypted = cryptoModule.eapi.encryptRequest(baseUrl + eapiPath, params);
|
||||||
|
requestUrl = baseUrl + eapiPath;
|
||||||
|
requestBody = encrypted.body;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'linuxapi': {
|
||||||
|
const encrypted = cryptoModule.linuxapi.encryptRequest(apiPath, params);
|
||||||
|
requestUrl = baseUrl + '/api/linux/forward';
|
||||||
|
requestBody = encrypted.body;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'api': {
|
||||||
|
requestUrl = baseUrl + apiPath;
|
||||||
|
requestBody = new url.URLSearchParams(params).toString();
|
||||||
|
requestHeadersObj['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// weapi / 未知 -> 直接 POST JSON
|
||||||
|
requestUrl = baseUrl + apiPath;
|
||||||
|
requestBody = params;
|
||||||
|
requestHeadersObj['Content-Type'] = 'application/json';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并原始请求头
|
||||||
|
if (requestHeaders) {
|
||||||
|
['cookie', 'user-agent', 'referer', 'origin', 'x-real-ip'].forEach(key => {
|
||||||
|
if (requestHeaders[key]) requestHeadersObj[key] = requestHeaders[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!requestHeadersObj['User-Agent'] && !requestHeadersObj['user-agent']) {
|
||||||
|
requestHeadersObj['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
|
||||||
|
}
|
||||||
|
requestHeadersObj['X-Real-IP'] = '118.88.88.88';
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const response = await axios.post(requestUrl, requestBody, {
|
||||||
|
headers: requestHeadersObj,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 15000,
|
||||||
|
httpsAgent: new (require('https').Agent)({ rejectUnauthorized: false }),
|
||||||
|
});
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 尝试解密响应
|
||||||
|
let responseData = null;
|
||||||
|
const resBuffer = Buffer.from(response.data);
|
||||||
|
const contentType = response.headers['content-type'] || '';
|
||||||
|
|
||||||
|
if (contentType.includes('json') || contentType.includes('text')) {
|
||||||
|
try {
|
||||||
|
responseData = JSON.parse(resBuffer.toString());
|
||||||
|
} catch {
|
||||||
|
// 非 JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 eapi 解密
|
||||||
|
if (!responseData && resBuffer.length > 0) {
|
||||||
|
try {
|
||||||
|
const decrypted = cryptoModule.eapi.decrypt(resBuffer).toString();
|
||||||
|
responseData = JSON.parse(decrypted);
|
||||||
|
} catch {
|
||||||
|
// 无法解密
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: apiPath,
|
||||||
|
rawPath: origPath,
|
||||||
|
crypto: crypto || 'replay',
|
||||||
|
param: params,
|
||||||
|
response: responseData,
|
||||||
|
statusCode: response.status,
|
||||||
|
method: method || 'POST',
|
||||||
|
duration,
|
||||||
|
requestHeaders: requestHeadersObj,
|
||||||
|
responseHeaders: { ...response.headers },
|
||||||
|
replay: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Express App ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建前端 Express app (共享 capturedData 和 clients)
|
||||||
|
*/
|
||||||
|
function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// 启动时加载历史数据
|
||||||
|
loadFromFile();
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ limit: '10mb', extended: true }));
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
app.post('/api/capture', (req, res) => {
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
capturedData.push(data);
|
capturedData.push(data);
|
||||||
console.log('Captured data:', data.path);
|
console.log('Captured data:', data.path);
|
||||||
|
// 持久化
|
||||||
// 通知所有 SSE 客户端有新数据
|
appendToFile(data);
|
||||||
broadcastData();
|
broadcastData();
|
||||||
|
|
||||||
res.status(200).send('OK');
|
res.status(200).send('OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/data', (req, res) => {
|
app.get('/api/data', (req, res) => {
|
||||||
res.json(capturedData);
|
res.json(capturedData);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 版本信息端点
|
app.get('/api/version', (req, res) => {
|
||||||
app.get('/api/version', (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const packageJson = require('../../package.json');
|
const packageJson = require('../../package.json');
|
||||||
res.json({ version: packageJson.version });
|
res.json({ version: packageJson.version });
|
||||||
@ -35,43 +198,114 @@ app.get('/api/version', (req, res) => {
|
|||||||
console.error('Failed to read package.json:', error);
|
console.error('Failed to read package.json:', error);
|
||||||
res.json({ version: '0.1.0' });
|
res.json({ version: '0.1.0' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清空数据端点
|
app.post('/api/clear', (req, res) => {
|
||||||
app.post('/api/clear', (req, res) => {
|
|
||||||
capturedData = [];
|
capturedData = [];
|
||||||
|
clearFile();
|
||||||
broadcastData();
|
broadcastData();
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE 端点
|
// 请求重放端点
|
||||||
app.get('/api/events', (req, res) => {
|
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('Content-Type', 'text/event-stream');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
// 立即发送当前数据
|
|
||||||
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
||||||
|
|
||||||
// 添加到客户端列表
|
|
||||||
clients.push(res);
|
clients.push(res);
|
||||||
|
|
||||||
req.on('close', () => {
|
req.on('close', () => {
|
||||||
clients = clients.filter(client => client !== res);
|
clients = clients.filter(client => client !== res);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
function broadcastData() {
|
function broadcastData() {
|
||||||
clients.forEach(client => {
|
clients.forEach(client => {
|
||||||
try {
|
try {
|
||||||
client.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
client.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 连接已断开,移除客户端
|
|
||||||
clients = clients.filter(c => c !== client);
|
clients = clients.filter(c => c !== client);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
/**
|
||||||
|
* 启动前端服务器
|
||||||
|
* @returns {Promise<import('http').Server>}
|
||||||
|
*/
|
||||||
|
function startClient(port) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const PORT = port || process.env.PORT || 3000;
|
||||||
|
const app = createApp();
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`Frontend server running at http://localhost:${PORT}`);
|
console.log(`Frontend server running at http://localhost:${PORT}`);
|
||||||
});
|
resolve(server);
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接运行时启动
|
||||||
|
if (require.main === module) {
|
||||||
|
startClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startClient, createApp };
|
||||||
@ -583,6 +583,138 @@
|
|||||||
}
|
}
|
||||||
.empty-list p { margin-bottom: 4px; }
|
.empty-list p { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* ======================== Headers Table ======================== */
|
||||||
|
.headers-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.headers-table th,
|
||||||
|
.headers-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.headers-table th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
width: 180px;
|
||||||
|
min-width: 120px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.headers-table td {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.headers-table tr:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
.headers-table .header-key {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Filter Chips ======================== */
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: all var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.filter-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
}
|
||||||
|
.filter-chip.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.filter-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Duration Badge ======================== */
|
||||||
|
.duration-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.duration-badge.slow { color: var(--warning); }
|
||||||
|
.duration-badge.very-slow { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ======================== Auto Scroll Toggle ======================== */
|
||||||
|
.auto-scroll-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.auto-scroll-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Batch Ops ======================== */
|
||||||
|
.batch-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--status-bg);
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
.batch-bar .batch-count {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.batch-bar .batch-count strong { color: var(--fg); }
|
||||||
|
.item-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sort-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.sort-btn:hover { color: var(--fg); background: var(--hover-bg); }
|
||||||
|
.sort-btn.active { color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
/* ======================== Detail Panel ======================== */
|
/* ======================== Detail Panel ======================== */
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@ -885,6 +1017,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="status-stat">
|
<span class="status-stat">
|
||||||
<strong id="capture-count">0</strong> 条记录
|
<strong id="capture-count">0</strong> 条记录
|
||||||
|
<span id="stats-breakdown" style="margin-left:8px;font-size:11px;"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -896,6 +1029,60 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="tool-btn" onclick="exportAll()" title="导出所有数据为 JSON">导出</button>
|
<button class="tool-btn" onclick="exportAll()" title="导出所有数据为 JSON">导出</button>
|
||||||
<button class="tool-btn danger" onclick="confirmClear()" title="清空所有抓包数据">清空</button>
|
<button class="tool-btn danger" onclick="confirmClear()" title="清空所有抓包数据">清空</button>
|
||||||
|
<button class="auto-scroll-btn active" id="auto-scroll-btn" onclick="toggleAutoScroll()" title="自动滚动到新数据">自动滚动</button>
|
||||||
|
<button class="auto-scroll-btn" id="full-capture-btn" onclick="toggleFullCapture()" title="切换完整抓包模式(捕获全部域名流量)">完整抓包</button>
|
||||||
|
</div>
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="toolbar" style="padding:5px 18px;flex-wrap:wrap;">
|
||||||
|
<div class="filter-group" id="method-filters">
|
||||||
|
<span style="font-size:10px;color:var(--muted);margin-right:2px;">方法</span>
|
||||||
|
<button class="filter-chip active" data-group="method" data-value="" onclick="setFilter('method', '')">全部</button>
|
||||||
|
<button class="filter-chip" data-group="method" data-value="GET" onclick="setFilter('method', 'GET')">GET</button>
|
||||||
|
<button class="filter-chip" data-group="method" data-value="POST" onclick="setFilter('method', 'POST')">POST</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-sep"></div>
|
||||||
|
<div class="filter-group" id="status-filters">
|
||||||
|
<span style="font-size:10px;color:var(--muted);margin-right:2px;">状态</span>
|
||||||
|
<button class="filter-chip active" data-group="status" data-value="" onclick="setFilter('status', '')">全部</button>
|
||||||
|
<button class="filter-chip" data-group="status" data-value="2" onclick="setFilter('status', '2')">2xx</button>
|
||||||
|
<button class="filter-chip" data-group="status" data-value="4" onclick="setFilter('status', '4')">4xx</button>
|
||||||
|
<button class="filter-chip" data-group="status" data-value="5" onclick="setFilter('status', '5')">5xx</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-sep"></div>
|
||||||
|
<div class="filter-group" id="crypto-filters">
|
||||||
|
<span style="font-size:10px;color:var(--muted);margin-right:2px;">加密</span>
|
||||||
|
<button class="filter-chip active" data-group="crypto" data-value="" onclick="setFilter('crypto', '')">全部</button>
|
||||||
|
<button class="filter-chip" data-group="crypto" data-value="eapi" onclick="setFilter('crypto', 'eapi')">EAPI</button>
|
||||||
|
<button class="filter-chip" data-group="crypto" data-value="xeapi" onclick="setFilter('crypto', 'xeapi')">XEAPI</button>
|
||||||
|
<button class="filter-chip" data-group="crypto" data-value="linuxapi" onclick="setFilter('crypto', 'linuxapi')">LinuxAPI</button>
|
||||||
|
<button class="filter-chip" data-group="crypto" data-value="weapi" onclick="setFilter('crypto', 'weapi')">WEAPI</button>
|
||||||
|
<button class="filter-chip" data-group="crypto" data-value="api" onclick="setFilter('crypto', 'api')">API</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-sep"></div>
|
||||||
|
<div class="filter-group" id="netease-filter">
|
||||||
|
<button class="filter-chip active" data-group="netease" data-value="" onclick="setFilter('netease', '')">全部</button>
|
||||||
|
<button class="filter-chip" data-group="netease" data-value="true" onclick="setFilter('netease', 'true')">仅网易云</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-sep"></div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span style="font-size:10px;color:var(--muted);margin-right:2px;">排序</span>
|
||||||
|
<button class="sort-btn" data-sort="time" onclick="setSort('time')">时间</button>
|
||||||
|
<button class="sort-btn" data-sort="duration" onclick="setSort('duration')">耗时</button>
|
||||||
|
<button class="sort-btn" data-sort="path" onclick="setSort('path')">路径</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch Bar -->
|
||||||
|
<div class="batch-bar" id="batch-bar">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll(this.checked)" style="accent-color:var(--accent);">
|
||||||
|
<span style="font-size:12px;">全选</span>
|
||||||
|
</label>
|
||||||
|
<span class="batch-count">
|
||||||
|
已选 <strong id="selected-count">0</strong> 条
|
||||||
|
</span>
|
||||||
|
<button class="tool-btn danger" onclick="batchDelete()" style="font-size:11px;padding:3px 8px;">批量删除</button>
|
||||||
|
<button class="tool-btn" onclick="batchExport()" style="font-size:11px;padding:3px 8px;">导出选中</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Capture List -->
|
<!-- Capture List -->
|
||||||
@ -931,11 +1118,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================== REPLAY MODAL ======================== -->
|
||||||
|
<div class="modal-overlay" id="replay-modal">
|
||||||
|
<div class="modal" style="min-width:480px;max-width:640px;">
|
||||||
|
<h3 id="replay-title" style="margin-bottom:12px;">重放请求</h3>
|
||||||
|
<div style="margin-bottom:12px;font-size:12px;color:var(--fg-secondary);">
|
||||||
|
<span id="replay-method" style="font-weight:600;color:var(--accent);"></span>
|
||||||
|
<span id="replay-path" style="margin-left:8px;"></span>
|
||||||
|
<span id="replay-crypto" style="margin-left:8px;color:var(--muted);"></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--fg);display:block;margin-bottom:4px;">请求参数 (可修改):</label>
|
||||||
|
<textarea id="replay-params" style="width:100%;height:200px;padding:10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--code-bg);color:var(--fg);font-family:'JetBrains Mono','Courier New',monospace;font-size:12px;resize:vertical;outline:none;"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button class="modal-btn modal-btn-cancel" onclick="closeReplayModal()">取消</button>
|
||||||
|
<button class="modal-btn modal-btn-confirm" id="replay-exec-btn" onclick="executeReplay()" style="background:var(--success);">执行重放</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ======================== State ========================
|
// ======================== State ========================
|
||||||
let capturedData = [];
|
let capturedData = [];
|
||||||
let selectedIndex = -1;
|
let selectedIndex = -1;
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
|
var filterMethod = '';
|
||||||
|
var filterStatus = '';
|
||||||
|
var filterCrypto = '';
|
||||||
|
var filterNetease = '';
|
||||||
|
let autoScroll = true;
|
||||||
|
let eventSource = null;
|
||||||
|
let selectedItems = new Set();
|
||||||
|
let sortBy = 'time';
|
||||||
|
let sortAsc = false;
|
||||||
|
|
||||||
// ======================== Theme ========================
|
// ======================== Theme ========================
|
||||||
function getPreferredTheme() {
|
function getPreferredTheme() {
|
||||||
@ -1003,6 +1219,9 @@
|
|||||||
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
||||||
if (e.target.id === 'modal-overlay') closeModal();
|
if (e.target.id === 'modal-overlay') closeModal();
|
||||||
});
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'replay-modal') closeReplayModal();
|
||||||
|
});
|
||||||
|
|
||||||
// ======================== Search ========================
|
// ======================== Search ========================
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
@ -1010,17 +1229,210 @@
|
|||||||
renderCaptureList();
|
renderCaptureList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesSearch(item) {
|
function matchesFilters(item) {
|
||||||
if (!searchQuery) return true;
|
// 搜索过滤
|
||||||
const path = (item.path || '').toLowerCase();
|
if (searchQuery) {
|
||||||
if (path.includes(searchQuery)) return true;
|
const q = searchQuery;
|
||||||
const param = item.param;
|
// 基础字段
|
||||||
if (param && typeof param === 'object') {
|
if ((item.path || '').toLowerCase().includes(q)) return true;
|
||||||
const paramStr = JSON.stringify(param).toLowerCase();
|
if ((item.hostname || '').toLowerCase().includes(q)) return true;
|
||||||
if (paramStr.includes(searchQuery)) return true;
|
if ((item.rawPath || '').toLowerCase().includes(q)) return true;
|
||||||
|
if ((item.crypto || '').toLowerCase().includes(q)) return true;
|
||||||
|
if ((item.method || '').toLowerCase().includes(q)) return true;
|
||||||
|
if (String(item.statusCode || '').includes(q)) return true;
|
||||||
|
if ((item.error || '').toLowerCase().includes(q)) return true;
|
||||||
|
// 对象字段 -> JSON 字符串搜索
|
||||||
|
const objFields = ['param', 'response', 'requestHeaders', 'responseHeaders'];
|
||||||
|
for (const key of objFields) {
|
||||||
|
const val = item[key];
|
||||||
|
if (val && typeof val === 'object') {
|
||||||
|
try {
|
||||||
|
if (JSON.stringify(val).toLowerCase().includes(q)) return true;
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 响应体纯文本
|
||||||
|
if (item.responseBody && item.responseBody.toLowerCase().includes(q)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function matchesFiltersStrict(item) {
|
||||||
|
// 方法过滤
|
||||||
|
if (filterMethod && (item.method || '').toUpperCase() !== filterMethod) return false;
|
||||||
|
// 状态码过滤
|
||||||
|
if (filterStatus) {
|
||||||
|
const codeGroup = String(item.statusCode || '')[0];
|
||||||
|
if (codeGroup !== filterStatus) return false;
|
||||||
|
}
|
||||||
|
// 加密类型过滤
|
||||||
|
if (filterCrypto && (item.crypto || '') !== filterCrypto) return false;
|
||||||
|
// 网易云过滤
|
||||||
|
if (filterNetease === 'true' && !item.isNetease) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Filter Functions ========================
|
||||||
|
function setFilter(group, value) {
|
||||||
|
const key = 'filter' + group.charAt(0).toUpperCase() + group.slice(1);
|
||||||
|
if (window[key] === value) return;
|
||||||
|
window[key] = value;
|
||||||
|
// 更新 chip 高亮
|
||||||
|
document.querySelectorAll(`.filter-chip[data-group="${group}"]`).forEach(el => {
|
||||||
|
el.classList.toggle('active', el.dataset.value === value);
|
||||||
|
});
|
||||||
|
renderCaptureList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullCapture() {
|
||||||
|
const btn = document.getElementById('full-capture-btn');
|
||||||
|
const newState = !btn.classList.contains('active');
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fullCapture: newState }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
btn.classList.toggle('active', data.fullCapture);
|
||||||
|
btn.textContent = data.fullCapture ? '完整抓包' : '普通模式';
|
||||||
|
showToast(data.fullCapture ? '完整抓包模式已开启,将捕获所有域名流量' : '已切换为普通模式,仅捕获网易云域名', 'info');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => showToast('设置失败: ' + e.message, 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoScroll() {
|
||||||
|
autoScroll = !autoScroll;
|
||||||
|
const btn = document.getElementById('auto-scroll-btn');
|
||||||
|
btn.classList.toggle('active', autoScroll);
|
||||||
|
btn.textContent = autoScroll ? '自动滚动' : '手动';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Sort Functions ========================
|
||||||
|
function setSort(field) {
|
||||||
|
if (sortBy === field) {
|
||||||
|
sortAsc = !sortAsc;
|
||||||
|
} else {
|
||||||
|
sortBy = field;
|
||||||
|
sortAsc = field === 'time' ? false : false;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.sort-btn').forEach(el => {
|
||||||
|
const isActive = el.dataset.sort === sortBy;
|
||||||
|
el.classList.toggle('active', isActive);
|
||||||
|
if (isActive) {
|
||||||
|
el.textContent = { time: '时间', duration: '耗时', path: '路径' }[sortBy] + (sortAsc ? ' ↑' : ' ↓');
|
||||||
|
} else {
|
||||||
|
el.textContent = { time: '时间', duration: '耗时', path: '路径' }[el.dataset.sort];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderCaptureList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedData(data) {
|
||||||
|
const arr = [...data];
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
switch(sortBy) {
|
||||||
|
case 'time':
|
||||||
|
cmp = (a.timestamp || '').localeCompare(b.timestamp || '');
|
||||||
|
break;
|
||||||
|
case 'duration':
|
||||||
|
cmp = (a.duration || 0) - (b.duration || 0);
|
||||||
|
break;
|
||||||
|
case 'path':
|
||||||
|
cmp = (a.path || '').localeCompare(b.path || '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Batch Functions ========================
|
||||||
|
function toggleSelectAll(checked) {
|
||||||
|
const filtered = capturedData.filter(item => matchesFilters(item) && matchesFiltersStrict(item));
|
||||||
|
filtered.forEach(item => {
|
||||||
|
const idx = capturedData.indexOf(item);
|
||||||
|
if (checked) selectedItems.add(idx);
|
||||||
|
else selectedItems.delete(idx);
|
||||||
|
});
|
||||||
|
updateBatchUI();
|
||||||
|
renderCaptureList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleItemSelection(index) {
|
||||||
|
if (selectedItems.has(index)) selectedItems.delete(index);
|
||||||
|
else selectedItems.add(index);
|
||||||
|
updateBatchUI();
|
||||||
|
renderCaptureList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBatchUI() {
|
||||||
|
const count = selectedItems.size;
|
||||||
|
document.getElementById('selected-count').textContent = count;
|
||||||
|
const allCheck = document.getElementById('select-all-checkbox');
|
||||||
|
if (allCheck) {
|
||||||
|
const filtered = capturedData.filter(item => matchesFilters(item) && matchesFiltersStrict(item));
|
||||||
|
allCheck.checked = filtered.length > 0 && filtered.every(item => selectedItems.has(capturedData.indexOf(item)));
|
||||||
|
allCheck.indeterminate = count > 0 && !allCheck.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchDelete() {
|
||||||
|
if (selectedItems.size === 0) return showToast('请先选择要删除的记录', 'info');
|
||||||
|
showConfirm(
|
||||||
|
'批量删除',
|
||||||
|
'确定要删除选中的 ' + selectedItems.size + ' 条记录吗?',
|
||||||
|
() => {
|
||||||
|
const sorted = [...selectedItems].sort((a,b) => b - a);
|
||||||
|
sorted.forEach(idx => capturedData.splice(idx, 1));
|
||||||
|
selectedItems.clear();
|
||||||
|
if (selectedIndex >= capturedData.length) selectedIndex = -1;
|
||||||
|
renderCaptureList();
|
||||||
|
updateBatchUI();
|
||||||
|
if (selectedIndex === -1) {
|
||||||
|
document.getElementById('detail-panel').innerHTML = `
|
||||||
|
<div class="no-selection">
|
||||||
|
<div class="ns-icon">[ -- ]</div>
|
||||||
|
<div class="ns-text">选择一个抓包记录查看详情</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
showToast('已删除 ' + sorted.length + ' 条记录', 'info');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchExport() {
|
||||||
|
if (selectedItems.size === 0) return showToast('请先选择要导出的记录', 'info');
|
||||||
|
const selected = [...selectedItems].sort((a,b) => a-b).map(idx => capturedData[idx]).filter(Boolean);
|
||||||
|
const blob = new Blob([formatJson(selected)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'netease-selected-' + new Date().toISOString().slice(0,19).replace(/[:-]/g,'') + '.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast('已导出 ' + selected.length + ' 条记录', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (ms == null) return '';
|
||||||
|
if (ms < 1000) return ms + 'ms';
|
||||||
|
return (ms / 1000).toFixed(2) + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDurationBadge(ms) {
|
||||||
|
if (ms == null) return '';
|
||||||
|
let cls = '';
|
||||||
|
if (ms > 3000) cls = 'very-slow';
|
||||||
|
else if (ms > 1000) cls = 'slow';
|
||||||
|
return `<span class="duration-badge ${cls}">${formatDuration(ms)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ======================== Crypto helpers ========================
|
// ======================== Crypto helpers ========================
|
||||||
function getCryptoLabel(crypto) {
|
function getCryptoLabel(crypto) {
|
||||||
@ -1059,45 +1471,57 @@
|
|||||||
const container = document.getElementById('capture-list');
|
const container = document.getElementById('capture-list');
|
||||||
const countEl = document.getElementById('capture-count');
|
const countEl = document.getElementById('capture-count');
|
||||||
|
|
||||||
const filtered = capturedData.filter(matchesSearch);
|
const filtered = capturedData.filter(item => matchesFilters(item) && matchesFiltersStrict(item));
|
||||||
|
const sorted = getSortedData(filtered);
|
||||||
|
|
||||||
countEl.textContent = capturedData.length;
|
countEl.textContent = capturedData.length;
|
||||||
|
updateBatchUI();
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (sorted.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="empty-list">
|
<div class="empty-list">
|
||||||
<div class="empty-icon">${searchQuery ? '[ 搜索 ]' : '[ -- ]'}</div>
|
<div class="empty-icon">${searchQuery || filterMethod || filterStatus || filterCrypto ? '[ 筛选 ]' : '[ -- ]'}</div>
|
||||||
<p>${searchQuery ? '没有匹配的抓包记录' : '暂无抓包数据'}</p>
|
<p>${searchQuery || filterMethod || filterStatus || filterCrypto ? '没有匹配的抓包记录' : '暂无抓包数据'}</p>
|
||||||
<p style="font-size:12px;opacity:0.7;">${searchQuery ? '试试其他关键词?' : '请设置代理并开始使用'}</p>
|
<p style="font-size:12px;opacity:0.7;">${searchQuery || filterMethod || filterStatus || filterCrypto ? '试试其他筛选条件?' : '请设置代理并开始使用'}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = filtered.map((item) => {
|
container.innerHTML = sorted.map((item) => {
|
||||||
const realIdx = capturedData.indexOf(item);
|
const realIdx = capturedData.indexOf(item);
|
||||||
const isActive = realIdx === selectedIndex;
|
const isActive = realIdx === selectedIndex;
|
||||||
|
const isChecked = selectedItems.has(realIdx);
|
||||||
const cryptoLabel = item.crypto ? getCryptoLabel(item.crypto) : '';
|
const cryptoLabel = item.crypto ? getCryptoLabel(item.crypto) : '';
|
||||||
const methodBadge = getMethodBadge(item.method);
|
const methodBadge = getMethodBadge(item.method);
|
||||||
const statusBadge = getStatusBadge(item.statusCode);
|
const statusBadge = getStatusBadge(item.statusCode);
|
||||||
const time = formatTime(item.timestamp);
|
const time = formatTime(item.timestamp);
|
||||||
|
const durBadge = getDurationBadge(item.duration);
|
||||||
|
|
||||||
|
const displayPath = item.isNetease ? (item.path || 'Unknown') : (item.hostname ? item.hostname + (item.path ? item.path.replace(/^https?:\/\/[^\/]+/, '') : '') : (item.path || 'Unknown'));
|
||||||
|
const isNeteaseBadge = item.isNetease ? '<span style="font-size:9px;color:var(--accent);border:1px solid var(--accent-border);border-radius:3px;padding:0 4px;margin-left:2px;">网易云</span>' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="capture-list-item ${isActive ? 'active' : ''}"
|
<div class="capture-list-item ${isActive ? 'active' : ''}"
|
||||||
onclick="selectCapture(${realIdx})"
|
data-index="${realIdx}"
|
||||||
data-index="${realIdx}">
|
style="display:flex;align-items:flex-start;gap:6px;">
|
||||||
<div class="item-main">
|
<input type="checkbox" class="item-checkbox" ${isChecked ? 'checked' : ''}
|
||||||
|
onclick="event.stopPropagation();toggleItemSelection(${realIdx})">
|
||||||
|
<div style="flex:1;min-width:0;cursor:pointer;" onclick="selectCapture(${realIdx})">
|
||||||
<div class="item-top">
|
<div class="item-top">
|
||||||
${methodBadge}
|
${methodBadge}
|
||||||
<span class="item-path">${escapeHtml(item.path || 'Unknown')}</span>
|
<span class="item-path">${escapeHtml(displayPath)}</span>
|
||||||
${cryptoLabel}
|
${cryptoLabel}
|
||||||
|
${isNeteaseBadge}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:6px;margin-top:2px;">
|
<div style="display:flex;align-items:center;gap:6px;margin-top:2px;">
|
||||||
${statusBadge}
|
${statusBadge}
|
||||||
<span class="item-time">${time}</span>
|
<span class="item-time">${time}</span>
|
||||||
|
${durBadge}
|
||||||
|
${item.replay ? '<span style="font-size:9px;color:var(--muted);border:1px solid var(--border);border-radius:3px;padding:0 4px;margin-left:2px;">Replay</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="item-del" onclick="event.stopPropagation();deleteItem(${realIdx})" title="删除">x</button>
|
<button class="item-del" onclick="event.stopPropagation();deleteItem(${realIdx})" title="删除" style="margin-top:1px;">x</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -1151,39 +1575,64 @@
|
|||||||
const methodBadge = getMethodBadge(item.method);
|
const methodBadge = getMethodBadge(item.method);
|
||||||
const statusBadge = getStatusBadge(item.statusCode);
|
const statusBadge = getStatusBadge(item.statusCode);
|
||||||
const fullDate = formatFullDate(item.timestamp);
|
const fullDate = formatFullDate(item.timestamp);
|
||||||
|
const durText = item.duration != null ? formatDuration(item.duration) : '';
|
||||||
|
const durCls = item.duration > 3000 ? 'very-slow' : (item.duration > 1000 ? 'slow' : '');
|
||||||
|
const isNeteaseBadge = item.isNetease ? '<span class="crypto-badge crypto-badge-eapi" style="font-size:10px;">网易云</span>' : '<span class="crypto-badge crypto-badge-api" style="font-size:10px;">通用</span>';
|
||||||
|
|
||||||
const filteredParam = filterParam(item.param);
|
const filteredParam = filterParam(item.param);
|
||||||
|
const reqHeaders = item.requestHeaders || {};
|
||||||
|
const resHeaders = item.responseHeaders || {};
|
||||||
|
const reqHCount = countKeys(reqHeaders);
|
||||||
|
const resHCount = countKeys(resHeaders);
|
||||||
|
|
||||||
|
// 非网易云请求显示完整 URL
|
||||||
|
const detailPath = item.isNetease ? (getDisplayPath(item) || 'Unknown') : (item.path || 'Unknown');
|
||||||
|
const detailHost = item.isNetease ? '' : (item.hostname ? escapeHtml(item.hostname) + ' ' : '');
|
||||||
|
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div class="dh-top">
|
<div class="dh-top">
|
||||||
${methodBadge}
|
${methodBadge}
|
||||||
<h2>${escapeHtml(getDisplayPath(item) || 'Unknown')}</h2>
|
<h2>${escapeHtml(detailPath)}</h2>
|
||||||
${item.rawPath ? `<button class="path-toggle" onclick="event.stopPropagation();togglePathDisplay(${index})" title="切换路径显示">${showRawPath ? '/api/' : escapeHtml(getRawPrefix(item.rawPath))}</button>` : ''}
|
${item.rawPath ? `<button class="path-toggle" onclick="event.stopPropagation();togglePathDisplay(${index})" title="切换路径显示">${showRawPath ? '/api/' : escapeHtml(getRawPrefix(item.rawPath))}</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="dh-meta">
|
<div class="dh-meta">
|
||||||
${statusBadge}
|
${statusBadge}
|
||||||
${cryptoLabel}
|
${cryptoLabel}
|
||||||
|
${isNeteaseBadge}
|
||||||
|
<span class="duration-badge ${durCls}">${durText}</span>
|
||||||
<span>${cryptoName ? cryptoName : ''}</span>
|
<span>${cryptoName ? cryptoName : ''}</span>
|
||||||
<span>${fullDate}</span>
|
<span>${fullDate}</span>
|
||||||
|
${detailHost ? `<span style="color:var(--muted);font-size:11px;">${detailHost}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<button class="action-btn" onclick="copyPath(${index})">[复制] 路径</button>
|
<button class="action-btn" onclick="copyPath(${index})">[复制] 路径</button>
|
||||||
<button class="action-btn" onclick="copyCurl(${index})">[复制] cURL</button>
|
<button class="action-btn" onclick="copyCurl(${index})">[复制] cURL</button>
|
||||||
<button class="action-btn" onclick="copyParam(${index})">[复制] 参数</button>
|
${item.param ? `<button class="action-btn" onclick="copyParam(${index})">[复制] 参数</button>` : ''}
|
||||||
<button class="action-btn" onclick="copyResponse(${index})">[复制] 响应</button>
|
<button class="action-btn" onclick="copyResponse(${index})">[复制] 响应</button>
|
||||||
|
<button class="action-btn" onclick="copyHeaders(${index}, 'req')">[复制] 请求头</button>
|
||||||
|
<button class="action-btn" onclick="copyHeaders(${index}, 'res')">[复制] 响应头</button>
|
||||||
|
${item.isNetease ? `<button class="action-btn" onclick="replayCapture(${index})" style="border-color:var(--success);color:var(--success);">[重放]</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-tabs" id="detail-tabs">
|
<div class="detail-tabs" id="detail-tabs">
|
||||||
<button class="detail-tab ${activeTab === 'params' ? 'active' : ''}" onclick="switchTab('params')">
|
${item.param ? `<button class="detail-tab ${activeTab === 'params' ? 'active' : ''}" onclick="switchTab('params')">
|
||||||
请求参数
|
请求参数
|
||||||
<span class="tab-count">${countKeys(filteredParam)}</span>
|
<span class="tab-count">${countKeys(filteredParam)}</span>
|
||||||
|
</button>` : ''}
|
||||||
|
<button class="detail-tab ${activeTab === 'reqHeaders' ? 'active' : ''}" onclick="switchTab('reqHeaders')">
|
||||||
|
请求头
|
||||||
|
<span class="tab-count">${reqHCount}</span>
|
||||||
|
</button>
|
||||||
|
<button class="detail-tab ${activeTab === 'resHeaders' ? 'active' : ''}" onclick="switchTab('resHeaders')">
|
||||||
|
响应头
|
||||||
|
<span class="tab-count">${resHCount}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="detail-tab ${activeTab === 'response' ? 'active' : ''}" onclick="switchTab('response')">
|
<button class="detail-tab ${activeTab === 'response' ? 'active' : ''}" onclick="switchTab('response')">
|
||||||
响应数据
|
响应数据
|
||||||
<span class="tab-count">${item.response ? countKeys(item.response) : 0}</span>
|
<span class="tab-count">${item.response ? countKeys(item.response) : (item.responseBody ? '1' : 0)}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="detail-tab ${activeTab === 'raw' ? 'active' : ''}" onclick="switchTab('raw')">
|
<button class="detail-tab ${activeTab === 'raw' ? 'active' : ''}" onclick="switchTab('raw')">
|
||||||
原始 JSON
|
原始 JSON
|
||||||
@ -1196,6 +1645,17 @@
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderHeadersTable(headers) {
|
||||||
|
if (!headers || countKeys(headers) === 0) {
|
||||||
|
return '<div style="text-align:center;padding:20px;color:var(--muted);">暂无请求头数据</div>';
|
||||||
|
}
|
||||||
|
const rows = Object.entries(headers).map(([key, value]) => {
|
||||||
|
const displayValue = Array.isArray(value) ? value.join(', ') : String(value);
|
||||||
|
return `<tr><td class="header-key">${escapeHtml(key)}</td><td>${escapeHtml(displayValue)}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<table class="headers-table"><tbody>${rows}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTabContent(item, filteredParam) {
|
function renderTabContent(item, filteredParam) {
|
||||||
switch(activeTab) {
|
switch(activeTab) {
|
||||||
case 'params':
|
case 'params':
|
||||||
@ -1205,8 +1665,22 @@
|
|||||||
</strong>
|
</strong>
|
||||||
<pre><code>${formatJson(filteredParam)}</code></pre>
|
<pre><code>${formatJson(filteredParam)}</code></pre>
|
||||||
`;
|
`;
|
||||||
|
case 'reqHeaders':
|
||||||
|
return `
|
||||||
|
<strong style="font-size:12px;color:var(--muted);display:block;margin-bottom:8px;">
|
||||||
|
请求 Headers (${countKeys(item.requestHeaders)} 个)
|
||||||
|
</strong>
|
||||||
|
${renderHeadersTable(item.requestHeaders)}
|
||||||
|
`;
|
||||||
|
case 'resHeaders':
|
||||||
|
return `
|
||||||
|
<strong style="font-size:12px;color:var(--muted);display:block;margin-bottom:8px;">
|
||||||
|
响应 Headers (${countKeys(item.responseHeaders)} 个)
|
||||||
|
</strong>
|
||||||
|
${renderHeadersTable(item.responseHeaders)}
|
||||||
|
`;
|
||||||
case 'response':
|
case 'response':
|
||||||
if (!item.response) {
|
if (!item.response && !item.responseBody) {
|
||||||
return `
|
return `
|
||||||
<div style="text-align:center;padding:30px;color:var(--muted);">
|
<div style="text-align:center;padding:30px;color:var(--muted);">
|
||||||
<div style="font-size:24px;margin-bottom:8px;opacity:0.35;">[ 空 ]</div>
|
<div style="font-size:24px;margin-bottom:8px;opacity:0.35;">[ 空 ]</div>
|
||||||
@ -1215,6 +1689,7 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
if (item.response) {
|
||||||
return `
|
return `
|
||||||
<strong style="font-size:12px;color:var(--muted);display:block;margin-bottom:8px;">
|
<strong style="font-size:12px;color:var(--muted);display:block;margin-bottom:8px;">
|
||||||
响应数据 (${countKeys(item.response)} 个字段)
|
响应数据 (${countKeys(item.response)} 个字段)
|
||||||
@ -1227,6 +1702,16 @@
|
|||||||
</strong>
|
</strong>
|
||||||
<pre><code id="response-content">${formatJson(item.response)}</code></pre>
|
<pre><code id="response-content">${formatJson(item.response)}</code></pre>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
if (item.responseBody) {
|
||||||
|
return `
|
||||||
|
<strong style="font-size:12px;color:var(--muted);display:block;margin-bottom:8px;">
|
||||||
|
响应体 (文本)
|
||||||
|
</strong>
|
||||||
|
<pre><code>${escapeHtml(item.responseBody)}</code></pre>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
case 'raw':
|
case 'raw':
|
||||||
return `
|
return `
|
||||||
<strong style="font-size:12px;color:var(--muted);display:block;margin-bottom:8px;">
|
<strong style="font-size:12px;color:var(--muted);display:block;margin-bottom:8px;">
|
||||||
@ -1288,8 +1773,9 @@
|
|||||||
|
|
||||||
function copyPath(index) {
|
function copyPath(index) {
|
||||||
const item = capturedData[index];
|
const item = capturedData[index];
|
||||||
const path = getDisplayPath(item);
|
if (!item) return showToast('无数据', 'error');
|
||||||
if (!item || !path) return showToast('无路径可复制', 'error');
|
const path = item.isNetease ? getDisplayPath(item) : (item.path || '');
|
||||||
|
if (!path) return showToast('无路径可复制', 'error');
|
||||||
copyToClipboard(path, '路径已复制');
|
copyToClipboard(path, '路径已复制');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1312,23 +1798,44 @@
|
|||||||
copyToClipboard(formatJson(item), '原始数据已复制');
|
copyToClipboard(formatJson(item), '原始数据已复制');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyHeaders(index, type) {
|
||||||
|
const item = capturedData[index];
|
||||||
|
if (!item) return;
|
||||||
|
const headers = type === 'req' ? item.requestHeaders : item.responseHeaders;
|
||||||
|
if (!headers || countKeys(headers) === 0) return showToast('无头部数据可复制', 'error');
|
||||||
|
const text = Object.entries(headers)
|
||||||
|
.map(([k, v]) => k + ': ' + (Array.isArray(v) ? v.join(', ') : v))
|
||||||
|
.join('\n');
|
||||||
|
copyToClipboard(text, (type === 'req' ? '请求头' : '响应头') + '已复制');
|
||||||
|
}
|
||||||
|
|
||||||
function copyCurl(index) {
|
function copyCurl(index) {
|
||||||
const item = capturedData[index];
|
const item = capturedData[index];
|
||||||
if (!item) return showToast('数据不完整', 'error');
|
if (!item) return showToast('数据不完整', 'error');
|
||||||
const method = (item.method || 'POST').toUpperCase();
|
const method = (item.method || 'POST').toUpperCase();
|
||||||
const url = 'https://music.163.com' + (getDisplayPath(item) || '');
|
let url;
|
||||||
|
if (item.isNetease) {
|
||||||
|
url = 'https://music.163.com' + (getDisplayPath(item) || '');
|
||||||
|
} else {
|
||||||
|
url = item.path || '';
|
||||||
|
}
|
||||||
let curl = 'curl -X ' + method + ' \'' + url + '\'';
|
let curl = 'curl -X ' + method + ' \'' + url + '\'';
|
||||||
|
|
||||||
curl += ' \\\n -H \'Content-Type: application/json\'';
|
// 添加请求头
|
||||||
|
if (item.requestHeaders) {
|
||||||
const filtered = filterParam(item.param);
|
Object.entries(item.requestHeaders).forEach(([k, v]) => {
|
||||||
if (filtered && Object.keys(filtered).length > 0) {
|
if (['host', 'content-length', 'proxy-connection'].includes(k.toLowerCase())) return;
|
||||||
const jsonData = JSON.stringify(filtered);
|
curl += ' \\\n -H \'' + k + ': ' + (Array.isArray(v) ? v.join(', ') : v).replace(/'/g, '\'\\\'\'') + '\'';
|
||||||
const escaped = jsonData.replace(/'/g, '\'\\\'\'');
|
});
|
||||||
curl += ' \\\n -d \'' + escaped + '\'';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
curl += ' \\\n --insecure';
|
if (item.param) {
|
||||||
|
const filtered = filterParam(item.param);
|
||||||
|
if (Object.keys(filtered).length > 0) {
|
||||||
|
const jsonData = JSON.stringify(filtered);
|
||||||
|
curl += ' \\\n -d \'' + jsonData.replace(/'/g, '\'\\\'\'') + '\'';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
copyToClipboard(curl, 'cURL 已复制');
|
copyToClipboard(curl, 'cURL 已复制');
|
||||||
}
|
}
|
||||||
@ -1387,6 +1894,82 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================== Replay ========================
|
||||||
|
let replayFormData = {};
|
||||||
|
let replayIndex = -1;
|
||||||
|
|
||||||
|
function replayCapture(index) {
|
||||||
|
const item = capturedData[index];
|
||||||
|
if (!item) return showToast('数据不完整', 'error');
|
||||||
|
replayIndex = index;
|
||||||
|
replayFormData = JSON.parse(JSON.stringify(item.param || {}));
|
||||||
|
showReplayModal(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReplayModal(item) {
|
||||||
|
const overlay = document.getElementById('replay-modal');
|
||||||
|
document.getElementById('replay-title').textContent = '重放请求: ' + (item.path || 'Unknown');
|
||||||
|
document.getElementById('replay-path').textContent = item.path || '';
|
||||||
|
document.getElementById('replay-method').textContent = item.method || 'POST';
|
||||||
|
document.getElementById('replay-crypto').textContent = getCryptoName(item.crypto);
|
||||||
|
document.getElementById('replay-params').value = formatJson(replayFormData);
|
||||||
|
overlay.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReplayModal() {
|
||||||
|
document.getElementById('replay-modal').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeReplay() {
|
||||||
|
const item = capturedData[replayIndex];
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// 解析用户修改后的参数
|
||||||
|
try {
|
||||||
|
replayFormData = JSON.parse(document.getElementById('replay-params').value);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('参数格式错误,请检查 JSON', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('replay-exec-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '重放中...';
|
||||||
|
|
||||||
|
fetch('/api/replay', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: item.path,
|
||||||
|
method: item.method,
|
||||||
|
params: replayFormData,
|
||||||
|
crypto: item.crypto,
|
||||||
|
rawPath: item.rawPath,
|
||||||
|
requestHeaders: item.requestHeaders,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(result => {
|
||||||
|
closeReplayModal();
|
||||||
|
if (result.error) {
|
||||||
|
showToast('重放失败: ' + result.error, 'error');
|
||||||
|
} else {
|
||||||
|
showToast('重放成功!已添加到列表', 'success');
|
||||||
|
// 滚动到最新(重放的结果会追加到末尾)
|
||||||
|
if (capturedData.length > 0) {
|
||||||
|
selectCapture(capturedData.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
showToast('重放请求失败: ' + e.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '执行重放';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function exportAll() {
|
function exportAll() {
|
||||||
if (!capturedData || capturedData.length === 0) {
|
if (!capturedData || capturedData.length === 0) {
|
||||||
return showToast('暂无数据可导出', 'info');
|
return showToast('暂无数据可导出', 'info');
|
||||||
@ -1481,7 +2064,7 @@
|
|||||||
|
|
||||||
// ======================== SSE & Init ========================
|
// ======================== SSE & Init ========================
|
||||||
function setupSSE() {
|
function setupSSE() {
|
||||||
const eventSource = new EventSource('/api/events');
|
eventSource = new EventSource('/api/events');
|
||||||
const statusDot = document.getElementById('status-dot');
|
const statusDot = document.getElementById('status-dot');
|
||||||
const statusLabel = document.getElementById('status-label');
|
const statusLabel = document.getElementById('status-label');
|
||||||
|
|
||||||
@ -1491,10 +2074,15 @@
|
|||||||
statusDot.className = 'status-dot connected';
|
statusDot.className = 'status-dot connected';
|
||||||
statusLabel.textContent = '已连接';
|
statusLabel.textContent = '已连接';
|
||||||
|
|
||||||
|
const prevLen = capturedData.length;
|
||||||
capturedData = data;
|
capturedData = data;
|
||||||
renderCaptureList();
|
renderCaptureList();
|
||||||
|
|
||||||
if (selectedIndex === -1 && data.length > 0) {
|
// 自动滚动到最新
|
||||||
|
if (autoScroll && data.length > prevLen && data.length > 0) {
|
||||||
|
const lastIdx = data.length - 1;
|
||||||
|
selectCapture(lastIdx);
|
||||||
|
} else if (selectedIndex === -1 && data.length > 0) {
|
||||||
selectCapture(0);
|
selectCapture(0);
|
||||||
} else if (selectedIndex >= data.length) {
|
} else if (selectedIndex >= data.length) {
|
||||||
selectedIndex = -1;
|
selectedIndex = -1;
|
||||||
@ -1519,6 +2107,38 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchSettings() {
|
||||||
|
fetch('/api/settings')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const btn = document.getElementById('full-capture-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.toggle('active', data.fullCapture);
|
||||||
|
btn.textContent = data.fullCapture ? '完整抓包' : '普通模式';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchStats() {
|
||||||
|
fetch('/api/stats')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(stats => {
|
||||||
|
const el = document.getElementById('stats-breakdown');
|
||||||
|
if (!el) return;
|
||||||
|
const parts = [];
|
||||||
|
if (stats.methods) {
|
||||||
|
const total = stats.total || 0;
|
||||||
|
const methods = Object.entries(stats.methods)
|
||||||
|
.sort((a,b) => b[1] - a[1])
|
||||||
|
.slice(0, 3);
|
||||||
|
parts.push(methods.map(([k,v]) => k + '=' + v).join(' '));
|
||||||
|
}
|
||||||
|
el.textContent = parts.length ? '| ' + parts.join(' | ') : '';
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function fetchVersion() {
|
function fetchVersion() {
|
||||||
fetch('/api/version')
|
fetch('/api/version')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@ -1545,6 +2165,11 @@
|
|||||||
initResize();
|
initResize();
|
||||||
renderCaptureList();
|
renderCaptureList();
|
||||||
registerSW();
|
registerSW();
|
||||||
|
fetchStats();
|
||||||
|
fetchSettings();
|
||||||
|
setInterval(fetchStats, 5000);
|
||||||
|
// 初始化排序按钮状态
|
||||||
|
setSort('time');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
216
src/index.js
216
src/index.js
@ -1,203 +1,17 @@
|
|||||||
// 主入口文件,同时启动 server 和 client
|
// 主入口文件,同时启动 proxy server 和 frontend client
|
||||||
// 确保 Ctrl+C 时能够优雅地关闭所有服务
|
// 确保 Ctrl+C 时能够优雅地关闭所有服务
|
||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const http = require('http');
|
/** @type {import('http').Server[]} */
|
||||||
const https = require('https');
|
|
||||||
|
|
||||||
// 保存服务器引用以便关闭
|
|
||||||
let servers = [];
|
let servers = [];
|
||||||
|
|
||||||
// 代理 server.app.js
|
/**
|
||||||
const startServer = () => {
|
* 优雅关闭所有服务器
|
||||||
return new Promise((resolve, reject) => {
|
*/
|
||||||
const packageJson = require('../package.json');
|
|
||||||
const config = require('./server/cli.js')
|
|
||||||
.program({
|
|
||||||
name: packageJson.name.replace(/@.+\//, ''),
|
|
||||||
version: packageJson.version,
|
|
||||||
})
|
|
||||||
.option(['-v', '--version'], { action: 'version' })
|
|
||||||
.option(['-p', '--port'], {
|
|
||||||
metavar: 'http[:https]',
|
|
||||||
help: 'specify server port',
|
|
||||||
})
|
|
||||||
.option(['-a', '--address'], {
|
|
||||||
metavar: 'address',
|
|
||||||
help: 'specify server host',
|
|
||||||
})
|
|
||||||
.option(['-h', '--help'], { action: 'help' })
|
|
||||||
.parse(process.argv);
|
|
||||||
|
|
||||||
global.address = config.address;
|
|
||||||
config.port = (config.port || process.env.HOOK_PORT || '9000')
|
|
||||||
.split(':')
|
|
||||||
.map((string) => parseInt(string));
|
|
||||||
const invalid = (value) => isNaN(value) || value < 1 || value > 65535;
|
|
||||||
if (config.port.some(invalid)) {
|
|
||||||
console.log('Port must be a number higher than 0 and lower than 65535.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.PORT) {
|
|
||||||
process.env.PORT = process.env.PORT || '3000';
|
|
||||||
}
|
|
||||||
|
|
||||||
const { logScope } = require('./server/logger');
|
|
||||||
const escape = require('querystring').escape;
|
|
||||||
const hook = require('./server/hook');
|
|
||||||
const server = require('./server/server');
|
|
||||||
const logger = logScope('app');
|
|
||||||
const target = Array.from(hook.target.host);
|
|
||||||
|
|
||||||
global.port = config.port;
|
|
||||||
global.proxy = null;
|
|
||||||
global.hosts = {};
|
|
||||||
global.endpoint = 'https://music.163.com';
|
|
||||||
|
|
||||||
server.whitelist = [
|
|
||||||
'://[\w.]*music\\.126\\.net',
|
|
||||||
'://[\w.]*vod\\.126\\.net',
|
|
||||||
'://acstatic-dun.126.net',
|
|
||||||
'://[\w.]*\\.netease\\.com',
|
|
||||||
'://[\w.]*\\.163yun\\.com',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (config.endpoint) server.whitelist.push(escape(config.endpoint));
|
|
||||||
|
|
||||||
const dns = (host) =>
|
|
||||||
new Promise((resolve, reject) =>
|
|
||||||
require('dns').lookup(host, { all: true }, (error, records) =>
|
|
||||||
error
|
|
||||||
? reject(error)
|
|
||||||
: resolve(records.map((record) => record.address))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
Promise.all(target.map(dns))
|
|
||||||
.then((result) => {
|
|
||||||
const { host } = hook.target;
|
|
||||||
result.forEach((array) => array.forEach(host.add, host));
|
|
||||||
server.whitelist = server.whitelist.concat(
|
|
||||||
Array.from(host).map(escape)
|
|
||||||
);
|
|
||||||
const log = (type) =>
|
|
||||||
logger.info(
|
|
||||||
`${['HTTP', 'HTTPS'][type]} Server running @ http://${
|
|
||||||
address || '0.0.0.0'
|
|
||||||
}:${port[type]}`
|
|
||||||
);
|
|
||||||
if (port[0]) {
|
|
||||||
const httpServer = server.http
|
|
||||||
.listen(port[0], address)
|
|
||||||
.once('listening', () => {
|
|
||||||
log(0);
|
|
||||||
servers.push(httpServer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (port[1]) {
|
|
||||||
const httpsServer = server.https
|
|
||||||
.listen(port[1], address)
|
|
||||||
.once('listening', () => {
|
|
||||||
log(1);
|
|
||||||
servers.push(httpsServer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 启动 client
|
|
||||||
const startClient = () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const express = require('express');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
let capturedData = [];
|
|
||||||
let clients = [];
|
|
||||||
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
app.use(express.urlencoded({ limit: '10mb', extended: true }));
|
|
||||||
app.use(express.static(path.join(__dirname, 'client/public')));
|
|
||||||
|
|
||||||
app.post('/api/capture', (req, res) => {
|
|
||||||
const data = req.body;
|
|
||||||
capturedData.push(data);
|
|
||||||
console.log('Captured data:', data.path);
|
|
||||||
|
|
||||||
broadcastData();
|
|
||||||
|
|
||||||
res.status(200).send('OK');
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/data', (req, res) => {
|
|
||||||
res.json(capturedData);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/version', (req, res) => {
|
|
||||||
try {
|
|
||||||
const packageJson = require('../package.json');
|
|
||||||
res.json({ version: packageJson.version });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to read package.json:', error);
|
|
||||||
res.json({ version: '0.1.0' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/clear', (req, res) => {
|
|
||||||
capturedData = [];
|
|
||||||
broadcastData();
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/events', (req, res) => {
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
|
|
||||||
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
|
||||||
|
|
||||||
clients.push(res);
|
|
||||||
|
|
||||||
req.on('close', () => {
|
|
||||||
clients = clients.filter(client => client !== res);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function broadcastData() {
|
|
||||||
clients.forEach(client => {
|
|
||||||
try {
|
|
||||||
client.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
|
||||||
} catch (e) {
|
|
||||||
clients = clients.filter(c => c !== client);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientServer = app.listen(PORT, () => {
|
|
||||||
console.log(`Frontend server running at http://localhost:${PORT}`);
|
|
||||||
servers.push(clientServer);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
clientServer.on('error', reject);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 优雅关闭所有服务器
|
|
||||||
const gracefulShutdown = async (signal) => {
|
const gracefulShutdown = async (signal) => {
|
||||||
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
|
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
|
||||||
|
|
||||||
// 关闭所有服务器
|
|
||||||
const closePromises = servers.map(server => {
|
const closePromises = servers.map(server => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
@ -205,9 +19,7 @@ const gracefulShutdown = async (signal) => {
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
// 设置超时,强制关闭
|
// 设置超时,强制关闭
|
||||||
setTimeout(() => {
|
setTimeout(() => resolve(), 5000);
|
||||||
resolve();
|
|
||||||
}, 5000);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -223,10 +35,24 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|||||||
// 启动所有服务
|
// 启动所有服务
|
||||||
const startAll = async () => {
|
const startAll = async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
const { startServer } = require('./server/app');
|
||||||
|
const { startClient } = require('./client/app');
|
||||||
|
|
||||||
|
// 确保环境变量
|
||||||
|
if (!process.env.PORT) process.env.PORT = '3000';
|
||||||
|
|
||||||
|
const [proxyResult, clientServer] = await Promise.all([
|
||||||
startServer(),
|
startServer(),
|
||||||
startClient()
|
startClient()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 收集服务器实例以便优雅关闭
|
||||||
|
if (proxyResult) {
|
||||||
|
if (proxyResult.httpServer) servers.push(proxyResult.httpServer);
|
||||||
|
if (proxyResult.httpsServer) servers.push(proxyResult.httpsServer);
|
||||||
|
}
|
||||||
|
servers.push(clientServer);
|
||||||
|
|
||||||
console.log('所有服务启动完成!');
|
console.log('所有服务启动完成!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('启动服务失败:', error);
|
console.error('启动服务失败:', error);
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
const packageJson = require('../../package.json');
|
const packageJson = require('../../package.json');
|
||||||
const config = require('./cli.js')
|
|
||||||
|
/**
|
||||||
|
* 启动代理服务器
|
||||||
|
* @returns {Promise<{httpServer: import('http').Server|null, httpsServer: import('http').Server|null}>}
|
||||||
|
*/
|
||||||
|
function startServer(cliArgs) {
|
||||||
|
const config = require('./cli.js')
|
||||||
.program({
|
.program({
|
||||||
name: packageJson.name.replace(/@.+\//, ''),
|
name: packageJson.name.replace(/@.+\//, ''),
|
||||||
version: packageJson.version,
|
version: packageJson.version,
|
||||||
@ -14,48 +20,48 @@ const config = require('./cli.js')
|
|||||||
help: 'specify server host',
|
help: 'specify server host',
|
||||||
})
|
})
|
||||||
.option(['-h', '--help'], { action: 'help' })
|
.option(['-h', '--help'], { action: 'help' })
|
||||||
.parse(process.argv);
|
.parse(cliArgs || process.argv);
|
||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
global.address = config.address;
|
global.address = config.address;
|
||||||
config.port = (config.port || process.env.HOOK_PORT || '9000')
|
config.port = (config.port || process.env.HOOK_PORT || '9000')
|
||||||
.split(':')
|
.split(':')
|
||||||
.map((string) => parseInt(string));
|
.map((string) => parseInt(string));
|
||||||
const invalid = (value) => isNaN(value) || value < 1 || value > 65535;
|
const invalid = (value) => isNaN(value) || value < 1 || value > 65535;
|
||||||
if (config.port.some(invalid)) {
|
if (config.port.some(invalid)) {
|
||||||
console.log('Port must be a number higher than 0 and lower than 65535.');
|
console.log('Port must be a number higher than 0 and lower than 65535.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 PORT 环境变量可用,供 hook.js 发送数据使用
|
// 确保 PORT 环境变量可用,供 hook.js 发送数据使用
|
||||||
if (!process.env.PORT) {
|
if (!process.env.PORT) {
|
||||||
process.env.PORT = process.env.PORT || '3000';
|
process.env.PORT = process.env.PORT || '3000';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { logScope } = require('./logger');
|
const { logScope } = require('./logger');
|
||||||
const parse = require('url').parse;
|
const parse = require('url').parse;
|
||||||
const hook = require('./hook');
|
const hook = require('./hook');
|
||||||
const server = require('./server');
|
const server = require('./server');
|
||||||
const logger = logScope('app');
|
const logger = logScope('app');
|
||||||
const target = Array.from(hook.target.host);
|
const target = Array.from(hook.target.host);
|
||||||
|
|
||||||
global.port = config.port;
|
global.port = config.port;
|
||||||
global.proxy = null;
|
global.proxy = null;
|
||||||
global.hosts = {};
|
global.hosts = {};
|
||||||
global.endpoint = 'https://music.163.com';
|
global.endpoint = 'https://music.163.com';
|
||||||
|
|
||||||
server.whitelist = [
|
server.whitelist = [
|
||||||
'://[\\w.]*music\\.126\\.net',
|
'://[\\w.]*music\\.126\\.net',
|
||||||
'://[\\w.]*vod\\.126\\.net',
|
'://[\\w.]*vod\\.126\\.net',
|
||||||
'://acstatic-dun.126.net',
|
'://acstatic-dun.126.net',
|
||||||
'://[\\w.]*\\.netease.com',
|
'://[\\w.]*\\.netease.com',
|
||||||
'://[\\w.]*\\.163yun.com',
|
'://[\\w.]*\\.163yun.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.endpoint) server.whitelist.push(escape(config.endpoint));
|
if (config.endpoint) server.whitelist.push(escape(config.endpoint));
|
||||||
|
|
||||||
const dns = (host) =>
|
const dns = (host) =>
|
||||||
new Promise((resolve, reject) =>
|
new Promise((resolve, reject) =>
|
||||||
require('dns').lookup(host, { all: true }, (error, records) =>
|
require('dns').lookup(host, { all: true }, (error, records) =>
|
||||||
error
|
error
|
||||||
@ -64,7 +70,7 @@ const dns = (host) =>
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
Promise.all(target.map(dns))
|
return Promise.all(target.map(dns))
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
const { host } = hook.target;
|
const { host } = hook.target;
|
||||||
result.forEach((array) => array.forEach(host.add, host));
|
result.forEach((array) => array.forEach(host.add, host));
|
||||||
@ -77,16 +83,26 @@ Promise.all(target.map(dns))
|
|||||||
address || '0.0.0.0'
|
address || '0.0.0.0'
|
||||||
}:${port[type]}`
|
}:${port[type]}`
|
||||||
);
|
);
|
||||||
|
let httpServer = null, httpsServer = null;
|
||||||
if (port[0])
|
if (port[0])
|
||||||
server.http
|
httpServer = server.http
|
||||||
.listen(port[0], address)
|
.listen(port[0], address)
|
||||||
.once('listening', () => log(0));
|
.once('listening', () => log(0));
|
||||||
if (port[1])
|
if (port[1])
|
||||||
server.https
|
httpsServer = server.https
|
||||||
.listen(port[1], address)
|
.listen(port[1], address)
|
||||||
.once('listening', () => log(1));
|
.once('listening', () => log(1));
|
||||||
|
return { httpServer, httpsServer };
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接运行时启动
|
||||||
|
if (require.main === module) {
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startServer };
|
||||||
97
src/server/crypto.test.js
Normal file
97
src/server/crypto.test.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
const crypto = require('./crypto');
|
||||||
|
|
||||||
|
describe('crypto', () => {
|
||||||
|
describe('eapi encrypt/decrypt', () => {
|
||||||
|
test('should encrypt and decrypt correctly', () => {
|
||||||
|
const original = Buffer.from('Hello, World! This is a test message for eapi.');
|
||||||
|
const encrypted = crypto.eapi.encrypt(original);
|
||||||
|
const decrypted = crypto.eapi.decrypt(encrypted);
|
||||||
|
expect(decrypted.toString()).toBe(original.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty buffer', () => {
|
||||||
|
const original = Buffer.from('');
|
||||||
|
const encrypted = crypto.eapi.encrypt(original);
|
||||||
|
const decrypted = crypto.eapi.decrypt(encrypted);
|
||||||
|
expect(decrypted.toString()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('linuxapi encrypt/decrypt', () => {
|
||||||
|
test('should encrypt and decrypt correctly', () => {
|
||||||
|
const original = Buffer.from('{"method":"POST","url":"https://music.163.com/api/test","params":{"id":1}}');
|
||||||
|
const encrypted = crypto.linuxapi.encrypt(original);
|
||||||
|
const decrypted = crypto.linuxapi.decrypt(encrypted);
|
||||||
|
expect(decrypted.toString()).toBe(original.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('base64', () => {
|
||||||
|
test('should encode and decode correctly', () => {
|
||||||
|
const original = 'Hello World';
|
||||||
|
const encoded = crypto.base64.encode(original);
|
||||||
|
const decoded = crypto.base64.decode(encoded);
|
||||||
|
expect(decoded).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle URL-safe characters', () => {
|
||||||
|
const original = 'test+data/with=special?chars';
|
||||||
|
const encoded = crypto.base64.encode(original);
|
||||||
|
expect(encoded).not.toContain('+');
|
||||||
|
expect(encoded).not.toContain('/');
|
||||||
|
const decoded = crypto.base64.decode(encoded);
|
||||||
|
expect(decoded).toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('md5', () => {
|
||||||
|
test('should produce consistent hash', () => {
|
||||||
|
const hash1 = crypto.md5.digest('test');
|
||||||
|
const hash2 = crypto.md5.digest('test');
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
expect(hash1).toHaveLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should produce different hash for different inputs', () => {
|
||||||
|
const hash1 = crypto.md5.digest('hello');
|
||||||
|
const hash2 = crypto.md5.digest('world');
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('random', () => {
|
||||||
|
test('hex should produce correct length', () => {
|
||||||
|
expect(crypto.random.hex(16)).toHaveLength(16);
|
||||||
|
expect(crypto.random.hex(32)).toHaveLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uuid should produce valid format', () => {
|
||||||
|
const uuid = crypto.random.uuid();
|
||||||
|
expect(uuid).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eapi.encryptRequest', () => {
|
||||||
|
test('should produce valid output', () => {
|
||||||
|
const result = crypto.eapi.encryptRequest(
|
||||||
|
'https://music.163.com/eapi/song/enhance/player/url',
|
||||||
|
{ id: '123', br: 320000 }
|
||||||
|
);
|
||||||
|
expect(result).toHaveProperty('url');
|
||||||
|
expect(result).toHaveProperty('body');
|
||||||
|
expect(result.body).toContain('params=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('linuxapi.encryptRequest', () => {
|
||||||
|
test('should produce valid output', () => {
|
||||||
|
const result = crypto.linuxapi.encryptRequest(
|
||||||
|
'https://music.163.com/api/song/enhance/player/url',
|
||||||
|
{ id: '123' }
|
||||||
|
);
|
||||||
|
expect(result).toHaveProperty('url');
|
||||||
|
expect(result).toHaveProperty('body');
|
||||||
|
expect(result.url).toContain('/api/linux/forward');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -98,8 +98,35 @@ const domainList = [
|
|||||||
'interface3.music.163.com',
|
'interface3.music.163.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为网易云相关域名
|
||||||
|
*/
|
||||||
|
function isNeteaseHost(hostname) {
|
||||||
|
if (!hostname) return false;
|
||||||
|
const neteasePatterns = [
|
||||||
|
'music.163.com', 'music.126.net', 'vod.126.net',
|
||||||
|
'iplay.163.com', 'look.163.com', 'y.163.com',
|
||||||
|
'interface.music.163.com', '163yun.com',
|
||||||
|
'163jiasu.com', 'netease.com',
|
||||||
|
];
|
||||||
|
return neteasePatterns.some(p => hostname.includes(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用完整抓包模式 (非网易云流量也捕获)
|
||||||
|
*/
|
||||||
|
function isFullCapture() {
|
||||||
|
return global.fullCapture === true;
|
||||||
|
}
|
||||||
|
|
||||||
hook.request.before = (ctx) => {
|
hook.request.before = (ctx) => {
|
||||||
const { req } = ctx;
|
const { req } = ctx;
|
||||||
|
// 记录请求开始时间和请求头
|
||||||
|
ctx.startTime = Date.now();
|
||||||
|
ctx.requestHeaders = { ...req.headers };
|
||||||
|
// 标记是否网易云
|
||||||
|
ctx.isNeteaseDomain = isNeteaseHost(req.headers.host);
|
||||||
|
|
||||||
req.url =
|
req.url =
|
||||||
(req.url.startsWith('http://')
|
(req.url.startsWith('http://')
|
||||||
? ''
|
? ''
|
||||||
@ -111,11 +138,7 @@ hook.request.before = (ctx) => {
|
|||||||
? req.headers.host
|
? req.headers.host
|
||||||
: null)) + req.url;
|
: null)) + req.url;
|
||||||
const url = parse(req.url);
|
const url = parse(req.url);
|
||||||
if (
|
// 所有请求都走代理 (不再局限网易云)
|
||||||
[url.hostname, req.headers.host].some((host) =>
|
|
||||||
isHost(host, 'music.163.com')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ctx.decision = 'proxy';
|
ctx.decision = 'proxy';
|
||||||
|
|
||||||
if (process.env.NETEASE_COOKIE && url.path.includes('url')) {
|
if (process.env.NETEASE_COOKIE && url.path.includes('url')) {
|
||||||
@ -321,7 +344,7 @@ hook.request.before = (ctx) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.netease = netease;
|
ctx.netease = netease;
|
||||||
console.log(netease.path, netease.param) // 这里输出了网易云音乐的抓包数据, 重点看这里
|
logger.info({ path: netease.path, params: netease.param }, 'Captured request')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(
|
.catch(
|
||||||
@ -369,6 +392,12 @@ hook.request.after = (ctx) => {
|
|||||||
const { req, proxyRes, netease, package: pkg } = ctx;
|
const { req, proxyRes, netease, package: pkg } = ctx;
|
||||||
|
|
||||||
if (netease) {
|
if (netease) {
|
||||||
|
// 计算请求耗时
|
||||||
|
const duration = ctx.startTime ? Date.now() - ctx.startTime : 0;
|
||||||
|
// 捕获响应头
|
||||||
|
const responseHeaders = proxyRes ? { ...proxyRes.headers } : {};
|
||||||
|
delete responseHeaders['transfer-encoding'];
|
||||||
|
|
||||||
return request
|
return request
|
||||||
.read(proxyRes, true)
|
.read(proxyRes, true)
|
||||||
.then((buffer) => {
|
.then((buffer) => {
|
||||||
@ -416,7 +445,10 @@ hook.request.after = (ctx) => {
|
|||||||
param: netease.param,
|
param: netease.param,
|
||||||
response: netease.jsonBody,
|
response: netease.jsonBody,
|
||||||
statusCode: proxyRes.statusCode,
|
statusCode: proxyRes.statusCode,
|
||||||
method: req.method
|
method: req.method,
|
||||||
|
duration,
|
||||||
|
requestHeaders: ctx.requestHeaders,
|
||||||
|
responseHeaders,
|
||||||
};
|
};
|
||||||
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||||
.catch(err => logger.error('Failed to send data to frontend:', err));
|
.catch(err => logger.error('Failed to send data to frontend:', err));
|
||||||
@ -430,9 +462,12 @@ hook.request.after = (ctx) => {
|
|||||||
crypto: netease.crypto || null,
|
crypto: netease.crypto || null,
|
||||||
param: netease.param,
|
param: netease.param,
|
||||||
response: null,
|
response: null,
|
||||||
statusCode: proxyRes.statusCode,
|
statusCode: proxyRes ? proxyRes.statusCode : null,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
method: req.method
|
method: req.method,
|
||||||
|
duration,
|
||||||
|
requestHeaders: ctx.requestHeaders,
|
||||||
|
responseHeaders,
|
||||||
};
|
};
|
||||||
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||||
.catch(err => logger.error('Failed to send data to frontend:', err));
|
.catch(err => logger.error('Failed to send data to frontend:', err));
|
||||||
@ -455,16 +490,69 @@ hook.request.after = (ctx) => {
|
|||||||
proxyRes.headers['content-type'] = 'audio/*';
|
proxyRes.headers['content-type'] = 'audio/*';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 通用抓包: 捕获所有请求 (非网易云也抓) ==========
|
||||||
|
// 只在全抓包模式或网易云域名下捕获
|
||||||
|
if (!netease && !pkg && (isFullCapture() || ctx.isNeteaseDomain)) {
|
||||||
|
const duration = ctx.startTime ? Date.now() - ctx.startTime : 0;
|
||||||
|
const responseHeaders = proxyRes ? { ...proxyRes.headers } : {};
|
||||||
|
delete responseHeaders['transfer-encoding'];
|
||||||
|
const reqUrl = req.url || '';
|
||||||
|
const contentType = (proxyRes && proxyRes.headers['content-type']) || '';
|
||||||
|
|
||||||
|
// 基本数据 (所有请求都有)
|
||||||
|
const dataToSend = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: reqUrl,
|
||||||
|
method: req.method || 'GET',
|
||||||
|
statusCode: proxyRes ? proxyRes.statusCode : null,
|
||||||
|
duration,
|
||||||
|
requestHeaders: ctx.requestHeaders,
|
||||||
|
responseHeaders,
|
||||||
|
isNetease: ctx.isNeteaseDomain || false,
|
||||||
|
hostname: parse(reqUrl).hostname || req.headers.host || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 尝试读取响应体 (仅对文本类响应,且大小限制 512KB)
|
||||||
|
const isTextResponse = contentType.includes('json') || contentType.includes('text') || contentType.includes('javascript') || contentType.includes('xml');
|
||||||
|
const contentLength = parseInt(proxyRes && proxyRes.headers['content-length'] || '0', 10);
|
||||||
|
|
||||||
|
if (proxyRes && isTextResponse && contentLength < 512 * 1024) {
|
||||||
|
return request.read(proxyRes, true)
|
||||||
|
.then((buffer) => {
|
||||||
|
if (buffer && buffer.length > 0 && buffer.length < 512 * 1024) {
|
||||||
|
const bodyStr = buffer.toString();
|
||||||
|
try {
|
||||||
|
dataToSend.response = JSON.parse(bodyStr);
|
||||||
|
} catch {
|
||||||
|
dataToSend.responseBody = bodyStr.slice(0, 10000); // 限制长度
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => {
|
||||||
|
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||||
|
.catch(err => logger.error('Failed to send capture data:', err.message));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 没有响应体或非文本,直接发送基础信息
|
||||||
|
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||||
|
.catch(err => logger.error('Failed to send capture data:', err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
hook.connect.before = (ctx) => {
|
hook.connect.before = (ctx) => {
|
||||||
const { req } = ctx;
|
const { req } = ctx;
|
||||||
const url = parse('https://' + req.url);
|
const url = parse('https://' + req.url);
|
||||||
if (
|
const hostname = url.hostname || '';
|
||||||
[url.hostname, req.headers.host].some((host) =>
|
|
||||||
|
// 网易云域名: 走本地 MITM 代理 (原有逻辑)
|
||||||
|
const isNetease = [url.hostname, req.headers.host].some((host) =>
|
||||||
hook.target.host.has(host)
|
hook.target.host.has(host)
|
||||||
)
|
);
|
||||||
) {
|
|
||||||
|
if (isNetease) {
|
||||||
if (parseInt(url.port) === 80) {
|
if (parseInt(url.port) === 80) {
|
||||||
req.url = `${global.address || 'localhost'}:${global.port[0]}`;
|
req.url = `${global.address || 'localhost'}:${global.port[0]}`;
|
||||||
req.local = true;
|
req.local = true;
|
||||||
@ -474,7 +562,18 @@ hook.connect.before = (ctx) => {
|
|||||||
} else {
|
} else {
|
||||||
ctx.decision = 'blank';
|
ctx.decision = 'blank';
|
||||||
}
|
}
|
||||||
} else if (url.href.includes(global.endpoint)) ctx.decision = 'proxy';
|
} else if (url.href.includes(global.endpoint)) {
|
||||||
|
ctx.decision = 'proxy';
|
||||||
|
} else if (isFullCapture()) {
|
||||||
|
// 完整抓包模式: 非网易云域名也走本地 MITM 代理
|
||||||
|
// 这样就能捕获所有 HTTPS 流量
|
||||||
|
if (global.port[1]) {
|
||||||
|
req.url = `${global.address || 'localhost'}:${global.port[1]}`;
|
||||||
|
req.local = true;
|
||||||
|
} else {
|
||||||
|
ctx.decision = 'blank';
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
hook.negotiate.before = (ctx) => {
|
hook.negotiate.before = (ctx) => {
|
||||||
@ -482,6 +581,12 @@ hook.negotiate.before = (ctx) => {
|
|||||||
const url = parse('https://' + req.url);
|
const url = parse('https://' + req.url);
|
||||||
const target = hook.target.host;
|
const target = hook.target.host;
|
||||||
if (req.local || decision) return;
|
if (req.local || decision) return;
|
||||||
|
// 完整抓包: 非网易云域名直接 MITM (sni 域名自动加入 target set)
|
||||||
|
if (isFullCapture() && socket.sni && !target.has(socket.sni)) {
|
||||||
|
target.add(socket.sni);
|
||||||
|
ctx.decision = 'blank';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (target.has(socket.sni) && !target.has(url.hostname)) {
|
if (target.has(socket.sni) && !target.has(url.hostname)) {
|
||||||
target.add(url.hostname);
|
target.add(url.hostname);
|
||||||
ctx.decision = 'blank';
|
ctx.decision = 'blank';
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
const { CancelRequest } = require('./cancel');
|
|
||||||
const request = require('./request');
|
|
||||||
const RequestCancelled = require('./exceptions/RequestCancelled');
|
|
||||||
|
|
||||||
describe('request()', () => {
|
|
||||||
test('will throw RequestCancelled when the CancelRequest has been cancelled', async () => {
|
|
||||||
const cancelRequest = new CancelRequest();
|
|
||||||
cancelRequest.cancel();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await request(
|
|
||||||
'GET',
|
|
||||||
'https://www.example.com',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
cancelRequest
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
expect(e).toBeInstanceOf(RequestCancelled);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('It should not be fulfilled.');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('will NOT throw RequestCancelled when the CancelRequest has not been cancelled', async () => {
|
|
||||||
const cancelRequest = new CancelRequest();
|
|
||||||
|
|
||||||
return request(
|
|
||||||
'GET',
|
|
||||||
'https://www.example.com',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
cancelRequest
|
|
||||||
);
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
test('headers should be in the response', async () => {
|
|
||||||
const response = await request('GET', 'https://www.example.com');
|
|
||||||
|
|
||||||
expect(response.headers).toBeDefined();
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
test('.body(raw: false) should returns the string', async () => {
|
|
||||||
const response = await request('GET', 'https://www.example.com');
|
|
||||||
const body = await response.body(false);
|
|
||||||
|
|
||||||
expect(typeof body === 'string').toBeTruthy();
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
test('.body(raw: true) should returns the Buffer', async () => {
|
|
||||||
const response = await request('GET', 'https://www.example.com');
|
|
||||||
const body = await response.body(true);
|
|
||||||
|
|
||||||
expect(body).toBeInstanceOf(Buffer);
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
// FIXME: re-enable after api.opensource.org becomes online
|
|
||||||
//
|
|
||||||
// test('.json() should returns the deserialized data', async () => {
|
|
||||||
// const response = await request(
|
|
||||||
// 'GET',
|
|
||||||
// 'https://api.opensource.org/licenses/'
|
|
||||||
// );
|
|
||||||
// const body = await response.json();
|
|
||||||
|
|
||||||
// expect(Array.isArray(body)).toBeTruthy();
|
|
||||||
// }, 15000);
|
|
||||||
|
|
||||||
// test('.url should be the request URL', async () => {
|
|
||||||
// const response = await request(
|
|
||||||
// 'GET',
|
|
||||||
// 'https://api.opensource.org/licenses/'
|
|
||||||
// );
|
|
||||||
|
|
||||||
// expect(response.url).toStrictEqual(
|
|
||||||
// url.parse('https://api.opensource.org/licenses/')
|
|
||||||
// );
|
|
||||||
// }, 15000);
|
|
||||||
});
|
|
||||||
55
src/server/utilities.test.js
Normal file
55
src/server/utilities.test.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
const { isHost, cookieToMap, mapToCookie } = require('./utilities');
|
||||||
|
|
||||||
|
describe('utilities', () => {
|
||||||
|
describe('isHost', () => {
|
||||||
|
test('should match host in URL', () => {
|
||||||
|
expect(isHost('https://music.163.com/api/playlist', 'music.163.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not match different host', () => {
|
||||||
|
expect(isHost('https://example.com/api', 'music.163.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match host in subdomain', () => {
|
||||||
|
expect(isHost('https://interface.music.163.com/api', 'music.163.com')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cookieToMap', () => {
|
||||||
|
test('should parse cookie string to map', () => {
|
||||||
|
const result = cookieToMap('key1=value1; key2=value2');
|
||||||
|
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty string', () => {
|
||||||
|
const result = cookieToMap('');
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle cookies without space around =', () => {
|
||||||
|
const result = cookieToMap('key1=value1; key2=value2');
|
||||||
|
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapToCookie', () => {
|
||||||
|
test('should convert map to cookie string', () => {
|
||||||
|
const result = mapToCookie({ key1: 'value1', key2: 'value2' });
|
||||||
|
expect(result).toBe('key1=value1; key2=value2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty map', () => {
|
||||||
|
const result = mapToCookie({});
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cookieToMap <-> mapToCookie round trip', () => {
|
||||||
|
test('should be reversible', () => {
|
||||||
|
const original = { a: '1', b: '2', c: '3' };
|
||||||
|
const cookieStr = mapToCookie(original);
|
||||||
|
const parsed = cookieToMap(cookieStr);
|
||||||
|
expect(parsed).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user