feat: 支持更多功能

This commit is contained in:
ElyPrism 2026-07-10 13:12:43 +08:00
parent 9f07f7d89f
commit 0fe0500ae1
No known key found for this signature in database
13 changed files with 4060 additions and 486 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.git
.gitignore
*.md
captures.jsonl
.env

3
.gitignore vendored
View File

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

119
bin/cli.js Normal file
View File

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

View File

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

2561
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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>

View File

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

View File

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

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

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

View File

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

View File

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

View File

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