源码
自行复制下载
-
html2canvas.js 手动下载
-
manifest.json
{
"manifest_version": 3,
"name": "NetDiff - Request Blocker & Compare",
"version": "2.0",
"permissions": [
"debugger",
"tabs",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js"
}
}
- background.js
let dashboardTabId = null;
let targetTabs = {}; // { tabId: 'A' | 'B' }
let currentPattern = "";
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'INIT_SESSION') {
startSession(message.data);
}
});
async function startSession({ urlA, urlB, pattern }) {
currentPattern = pattern || "*";
// 1. 打开 Dashboard
const dashboardWindow = await chrome.windows.create({
url: 'dashboard.html',
type: 'popup',
width: 1000,
height: 800
});
setTimeout(async () => {
const tabs = await chrome.tabs.query({ windowId: dashboardWindow.id });
if (tabs.length > 0) dashboardTabId = tabs[0].id;
}, 500);
// 2. 打开两个目标页面
const tabA = await chrome.tabs.create({ url: urlA, active: false });
const tabB = await chrome.tabs.create({ url: urlB, active: false });
targetTabs = {};
targetTabs[tabA.id] = 'A';
targetTabs[tabB.id] = 'B';
// 3. 通知 Dashboard
setTimeout(() => {
chrome.runtime.sendMessage({
type: 'STATUS_UPDATE',
pattern: currentPattern,
urlA: urlA, // <--- 新增
urlB: urlB, // <--- 新增
tabs: { A: tabA.id, B: tabB.id }
}).catch(() => {});
}, 1500);
// 4. 附加 Debugger 并 【自动刷新】
setTimeout(async () => {
// 先挂载拦截器
await attachDebugger(tabA.id, currentPattern);
await attachDebugger(tabB.id, currentPattern);
// === 【新增核心代码】 ===
// 挂载完成后,强制刷新页面,确保从头开始的请求都能被抓到
console.log(">>> 拦截器就绪,正在重新加载页面以捕获请求...");
chrome.tabs.reload(tabA.id);
chrome.tabs.reload(tabB.id);
// ======================
}, 800); // 这里的延迟可以稍微短一点,只要 tab 创建了就行
}
async function attachDebugger(tabId, pattern) {
// 1. 先获取 Tab 的详细信息,检查 URL 协议
let tab;
try {
tab = await chrome.tabs.get(tabId);
} catch (e) {
console.log(`Tab ${tabId} 可能已关闭,跳过挂载`);
return;
}
// 【新增安全检查】防止挂载到受保护的页面(如 chrome:// 或其他插件页面)
if (tab.url.startsWith("chrome:") || tab.url.startsWith("chrome-extension:") || tab.url.startsWith("edge:")) {
console.warn(`⚠️ 跳过挂载: 无法调试受限页面 -> ${tab.url}`);
console.warn("提示: 如果这是 API 接口,请检查是否被 'JSON Viewer' 类插件接管了页面。");
return;
}
// 2. 正常的挂载逻辑
const debuggee = { tabId: tabId };
try {
await chrome.debugger.attach(debuggee, "1.3");
console.log(`>>> Debugger attached to Tab ${tabId}`);
await chrome.debugger.sendCommand(debuggee, "Network.enable");
// 这里使用 * 配合 JS 正则过滤,确保能抓到
await chrome.debugger.sendCommand(debuggee, "Fetch.enable", {
patterns: [{ urlPattern: "*", requestStage: "Request" }]
});
console.log(`>>> Fetch & Network enabled for Tab ${tabId}`);
} catch (err) {
// 捕获并打印具体错误,防止插件崩溃
console.error(`!!! Debugger attach failed for Tab ${tabId}:`, err.message);
if (err.message.includes("chrome-extension")) {
console.error(">>> 原因: 目标页面被其他扩展程序接管(通常是 JSON Formatter/Viewer)。请禁用相关插件后重试。");
}
}
}
chrome.debugger.onEvent.addListener(async (source, method, params) => {
const tabType = targetTabs[source.tabId] || "未知";
// 【修复 1】恢复 Network 日志,不再直接 return
if (method === "Network.requestWillBeSent") {
console.log(`[Network流] [${tabType}] ${params.request.url}`);
return;
}
if (method === "Fetch.requestPaused") {
const requestId = params.requestId;
const request = params.request;
// 【修复 2】只要进入这里,先打印,证明拦截生效了
console.log(`⚡️ [Fetch捕获] [${tabType}] 收到请求: ${request.url}`);
// 1. 预处理:去除用户输入前后的空格
const cleanPattern = (currentPattern || "").trim();
// 2. 预处理:去除请求 URL 的参数(?) 和 Hash(#)
const urlClean = request.url.split('?')[0].split('#')[0];
// 3. 构建正则
const escapeRegex = (str) => str.replace(/([.+?^=!:${}()|\[\]\/\\])/g, "\\$1");
const patternString = "^" + cleanPattern.split("*").map(escapeRegex).join(".*") + "$";
const regexPattern = new RegExp(patternString);
// 4. 匹配测试
const isMatch = regexPattern.test(request.url) || regexPattern.test(urlClean);
// --- 诊断日志 ---
if (request.url.includes("share/content")) {
console.group(`🔍 匹配诊断 [${tabType}]`);
console.log(`规则: "${cleanPattern}"`);
console.log(`正则: ${patternString}`);
console.log(`URL : "${request.url}"`);
console.log(`结果: ${isMatch ? "✅ 匹配" : "❌ 不匹配"}`);
console.groupEnd();
}
// ----------------
if (isMatch) {
console.log(`🛑 [BLOCKED] 拦截成功: ${request.url}`);
chrome.runtime.sendMessage({
type: 'REQUEST_CAPTURED',
data: {
tabType: tabType,
url: request.url,
method: request.method,
postData: request.postData || "",
headers: request.headers,
timestamp: Date.now()
}
}).catch(() => {});
await chrome.debugger.sendCommand(source, "Fetch.failRequest", {
requestId: requestId,
errorReason: "BlockedByClient"
});
} else {
// console.log(`[放行] ${request.url}`);
await chrome.debugger.sendCommand(source, "Fetch.continueRequest", {
requestId: requestId
});
}
}
});
// 清理
chrome.tabs.onRemoved.addListener((tabId) => {
if (targetTabs[tabId]) {
// 自动 detach,不需要手动调用 chrome.debugger.detach,Chrome 会自己处理
delete targetTabs[tabId];
}
});
- dashboard.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>NetDiff 深度对比面板</title>
<script src="html2canvas.js"></script>
<style>
/* === 全局布局 === */
body { margin: 0; display: flex; flex-direction: column; height: 100vh; font-family: "Consolas", "Menlo", monospace; background: #fff; overflow: hidden; font-size: 12px; }
/* ... (顶部 config-info-bar 和 top-container 样式保持不变,此处省略以节约篇幅) ... */
.config-info-bar { background: #e6f7ff; border-bottom: 1px solid #91d5ff; padding: 8px 15px; font-size: 12px; color: #0050b3; display: flex; flex-direction: column; gap: 4px; }
.config-row { display: flex; align-items: center; }
.config-label { font-weight: bold; width: 60px; color: #1890ff; flex-shrink: 0;}
.config-val { font-family: "Consolas", monospace; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.top-container { height: 35%; min-height: 200px; display: flex; border-bottom: 1px solid #ccc; background: #f8f9fa; flex-shrink: 0; }
.list-panel { flex: 1; display: flex; flex-direction: column; border-right: 1px solid #ccc; overflow: hidden; }
.list-panel:last-child { border-right: none; }
.panel-header { padding: 8px 10px; background: #eee; border-bottom: 1px solid #ddd; font-weight: bold; color: #333; display: flex; justify-content: space-between;}
.list-content { flex: 1; overflow-y: auto; background: #fff; }
.req-item { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; cursor: pointer; }
.req-item:hover { background-color: #f0f5ff; }
.req-item.active { background-color: #e6f7ff; border-left: 3px solid #1890ff; }
.req-method { font-weight: bold; color: #555; margin-right: 5px;}
.req-url { color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
/* === Diff 区域核心样式 (重点修改) === */
.diff-container { flex: 1; display: flex; flex-direction: column; background: #fff; overflow: hidden; /* 确保自身不溢出 */ }
.diff-toolbar { flex-shrink: 0; padding: 8px 15px; background: #fff; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 10px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); z-index: 10; }
/* ... (toolbar 内部元素样式保持不变) ... */
.diff-toolbar label { color: #555; font-weight: bold; font-family: sans-serif; }
.diff-toolbar input { flex: 1; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font-family: "Consolas", monospace; font-size: 12px; }
.diff-toolbar input:focus { outline: none; border-color: #1890ff; }
.btn-icon { cursor: pointer; margin-left: 10px; }
.btn-export-cfg { background-color: #fa8c16; color: white; border:none; padding: 6px 12px; border-radius: 4px; cursor: pointer;}
.btn-export-cfg:hover { background-color: #d46b08; }
.btn-dl { padding: 6px 12px; background-color: #1890ff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.btn-dl:hover { background-color: #40a9ff; }
/* 截图区域容器 */
.capture-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #fff; }
.diff-headers { flex-shrink: 0; display: flex; border-bottom: 1px solid #ddd; background: #f1f1f1; color: #555; font-weight: bold; padding: 5px 0; }
.diff-hdr-cell { flex: 1; padding-left: 30px; /* 增加左边距给折叠图标 */ border-right: 1px solid #ddd; }
/* === 滚动核心修复 === */
.diff-content {
flex: 1;
overflow-y: auto; /* 确保垂直滚动 */
overflow-x: auto; /* 允许水平滚动以防万一 */
background: #fff;
}
/* 确保行不会被压缩,保证内容完整显示 */
.d-row { display: flex; width: 100%; flex-shrink: 0; border-bottom: 1px solid #f8f8f8; }
/* 单元格样式 */
.d-cell { flex: 1; padding: 2px 5px; line-height: 1.5; border-right: 1px solid #eee; position: relative; white-space: pre; /* 保持空格缩进 */ overflow-x: hidden; }
/* === 新增:折叠功能样式 === */
/* 内容包裹器,用于放置折叠按钮和文本 */
.d-cell-wrap { display: flex; }
/* 折叠按钮样式 */
.fold-toggle {
display: inline-block;
width: 20px;
text-align: center;
cursor: pointer;
color: #999;
font-weight: bold;
user-select: none;
margin-right: 5px;
}
.fold-toggle:hover { color: #1890ff; }
/* 可折叠行的头部样式 */
.fold-header { cursor: pointer; background-color: #fcfcfc; }
.fold-header:hover { background-color: #f0f5ff; }
/* 被折叠隐藏的行 */
.d-row.hidden-by-fold { display: none; }
/* Diff 颜色定义 */
.bg-del { background-color: #ffebe9; color: #24292e; }
.bg-add { background-color: #e6ffec; color: #24292e; }
.bg-empty { background-color: #fcfcfc; background-image: linear-gradient(45deg, #f5f5f5 25%, transparent 25%, transparent 75%, #f5f5f5 75%, #f5f5f5), linear-gradient(45deg, #f5f5f5 25%, transparent 25%, transparent 75%, #f5f5f5 75%, #f5f5f5); background-size: 10px 10px; background-position: 0 0, 5px 5px; }
.d-info { padding: 20px; text-align: center; color: #999; width: 100%; font-family: sans-serif; margin-top: 50px;}
/* 滚动条美化 */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 5px; border: 2px solid #fff; }
::-webkit-scrollbar-track { background: #f1f1f1; }
/* === 新增:IDEA 模式样式 === */
/* 修改行背景 (淡蓝色/紫色,类似 IDEA 的修改色) */
.bg-mod { background-color: #e2efff; color: #24292e; }
/* 字符级差异:删除的字符 (深红背景 + 删除线) */
.char-del {
background-color: #ffc0c0;
color: #8a0404;
text-decoration: line-through;
border-radius: 2px;
}
/* 字符级差异:新增的字符 (深绿背景 + 粗体) */
.char-add {
background-color: #acf2bd;
color: #005c18;
font-weight: bold;
border-radius: 2px;
}
/* 修复:在 IDEA 模式下,相同的内容可能也需要稍微区别一下背景,这里保持白色即可 */
</style>
</head>
<body>
<div class="config-info-bar" id="configBar">
<div class="config-row">
<span class="config-label">URL A:</span>
<span class="config-val" id="dispUrlA">waiting...</span>
</div>
<div class="config-row">
<span class="config-label">URL B:</span>
<span class="config-val" id="dispUrlB">waiting...</span>
</div>
<div class="config-row">
<span class="config-label">Pattern:</span>
<span class="config-val" id="dispPattern">waiting...</span>
</div>
</div>
<div class="top-container">
<div class="list-panel">
<div class="panel-header">
<span>Tab A (基准)</span>
<span id="countA">0</span>
</div>
<div class="list-content" id="listA"></div>
</div>
<div class="list-panel">
<div class="panel-header">
<span>Tab B (对比)</span>
<span id="countB">0</span>
</div>
<div class="list-content" id="listB"></div>
</div>
</div>
<div class="diff-container">
<div class="diff-toolbar">
<label>Ignore Keys:</label>
<input type="text" id="ignoreKeys" placeholder="timestamp, traceId..." />
<label style="margin-left: 15px;">视图模式:</label>
<select id="diffMode" style="padding: 6px; border-radius: 4px; border: 1px solid #ccc;">
<option value="git">Git 风格 (整行增删)</option>
<option value="idea" selected>IDEA 风格 (行内高亮)</option>
</select>
<button id="btnExportConfig" class="btn-export-cfg">⚙️ 导出配置</button>
<button id="btnDownload" class="btn-dl">📥 下载结果</button>
</div>
<div class="capture-area" id="captureTarget">
<div class="diff-headers">
<div class="diff-hdr-cell">Tab A Content</div>
<div class="diff-hdr-cell">Tab B Content</div>
</div>
<div class="diff-content" id="diffOutput">
<div class="d-info">请在上方列表选择两个请求进行对比...</div>
</div>
</div>
</div>
<script src="dashboard.js"></script>
</body>
</html>
- dashboard.js
// === dashboard.js 全新版本 ===
const store = { A: [], B: [] };
let selectedIndexA = -1;
let selectedIndexB = -1;
let currentConfig = { urlA: "", urlB: "", pattern: "" };
// --- 工具函数:生成格式化时间戳 (YYYY-MM-DD_HH-mm-ss) ---
function getFormattedTimestamp() {
const now = new Date();
const yyyy = now.getFullYear();
const MM = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const HH = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
// 使用下划线和连字符替代空格和冒号,确保文件名安全
return `${yyyy}-${MM}-${dd}_${HH}-${mm}-${ss}`;
}
// --- 监听器 ---
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'STATUS_UPDATE') {
currentConfig = { ...message };
document.getElementById('dispUrlA').innerText = message.urlA;
document.getElementById('dispUrlA').title = message.urlA;
document.getElementById('dispUrlB').innerText = message.urlB;
document.getElementById('dispUrlB').title = message.urlB;
document.getElementById('dispPattern').innerText = message.pattern;
}
if (message.type === 'REQUEST_CAPTURED') addRequest(message.data);
});
// --- 导出与下载逻辑 (使用新时间戳格式) ---
document.getElementById('btnExportConfig').addEventListener('click', () => {
if (!currentConfig.urlA) { alert("配置信息尚未加载"); return; }
const jsonStr = JSON.stringify(currentConfig, null, 2);
const timeStr = getFormattedTimestamp();
downloadString(jsonStr, `netdiff_config_${timeStr}.json`);
});
document.getElementById('btnDownload').addEventListener('click', async () => {
if (selectedIndexA === -1 || selectedIndexB === -1) { alert("请先选择请求"); return; }
const btn = document.getElementById('btnDownload');
const originalText = btn.innerText;
btn.innerText = "⏳ 处理中...";
btn.disabled = true;
try {
const timeStr = getFormattedTimestamp();
const ignoreInput = document.getElementById('ignoreKeys').value;
const ignoreList = ignoreInput.split(/[,,]/).map(k => k.trim()).filter(k => k);
const reqA = store.A[selectedIndexA];
const reqB = store.B[selectedIndexB];
const textA = prepareTextForDiff(reqA.postData, ignoreList);
const textB = prepareTextForDiff(reqB.postData, ignoreList);
// 下载 JSON
downloadString(textA, `diff_A_${timeStr}.json`);
downloadString(textB, `diff_B_${timeStr}.json`);
// 下载截图
const target = document.getElementById('captureTarget');
const canvas = await html2canvas(target, { scale: 1.5, backgroundColor: "#ffffff", logging: false });
const imgUrl = canvas.toDataURL("image/png");
const link = document.createElement('a');
link.href = imgUrl;
link.download = `diff_view_${timeStr}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e) {
console.error(e);
alert("下载失败: " + e.message);
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
});
function downloadString(text, fileName) {
const blob = new Blob([text], { type: "application/json" });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// --- 核心 Diff 渲染逻辑 (包含折叠功能) ---
// 1. 事件委托:处理折叠点击
document.getElementById('diffOutput').addEventListener('click', (e) => {
// 点击折叠按钮 或 点击折叠头部
const toggleBtn = e.target.closest('.fold-toggle');
const headerRow = e.target.closest('.fold-header');
if (toggleBtn || headerRow) {
const row = headerRow || toggleBtn.closest('.d-row');
if (row) toggleFolding(row);
}
});
// 2. 执行折叠/展开
function toggleFolding(headerRow) {
const isCollapsed = headerRow.classList.contains('collapsed');
const baseIndent = parseInt(headerRow.dataset.indent, 10);
// 切换当前行状态
headerRow.classList.toggle('collapsed');
// 更新按钮图标
headerRow.querySelectorAll('.fold-toggle').forEach(el => {
el.innerText = isCollapsed ? '[-]' : '[+]';
});
// 递归处理兄弟行
let nextSibling = headerRow.nextElementSibling;
while (nextSibling) {
// 如果遇到缩进小于等于自己的行,说明当前块结束了
const siblingIndent = parseInt(nextSibling.dataset.indent, 10);
if (isNaN(siblingIndent) || siblingIndent <= baseIndent) break;
if (!isCollapsed) {
// 如果是执行折叠操作,直接隐藏所有子行
nextSibling.classList.add('hidden-by-fold');
} else {
// 如果是执行展开操作,需要判断子行是否被其自身的父级折叠了
// 简便做法:恢复显示,但如果它自己也是个折叠了的头部,保持其折叠状态(不递归展开子子级)
nextSibling.classList.remove('hidden-by-fold');
if (nextSibling.classList.contains('fold-header') && nextSibling.classList.contains('collapsed')) {
// 如果这个子行本身是折叠状态,跳过它的所有孩子
const currentHeaderIndent = siblingIndent;
let skipper = nextSibling.nextElementSibling;
while(skipper) {
const skipperIndent = parseInt(skipper.dataset.indent, 10);
if (isNaN(skipperIndent) || skipperIndent <= currentHeaderIndent) break;
skipper = skipper.nextElementSibling;
}
nextSibling = skipper; // 跳过指针
continue; // 继续外层循环
}
}
nextSibling = nextSibling.nextElementSibling;
}
}
// 3. 渲染 Side-by-Side 视图 (集成折叠逻辑)
function renderSideBySide(diffList) {
const container = document.getElementById('diffOutput');
container.innerHTML = '';
if (diffList.length === 0) {
container.innerHTML = '<div class="d-info">内容完全一致</div>';
return;
}
diffList.forEach(item => {
const row = document.createElement('div');
row.className = 'd-row';
let leftHtml = '', rightHtml = '';
let leftClass = '', rightClass = '';
let rawTextForIndent = ""; // 用于计算缩进
let foldToggle = '<span class="fold-toggle" style="visibility:hidden"> </span>';
// === 1. 构建 HTML 内容 ===
if (item.type === 'same') {
// 相同
rawTextForIndent = item.text;
leftHtml = escapeHtml(item.text);
rightHtml = escapeHtml(item.text);
}
else if (item.type === 'del') {
// 纯删除
rawTextForIndent = item.text;
leftHtml = escapeHtml(item.text);
rightHtml = '';
leftClass = 'bg-del';
rightClass = 'bg-empty';
}
else if (item.type === 'add') {
// 纯新增
rawTextForIndent = item.text;
leftHtml = '';
rightHtml = escapeHtml(item.text);
leftClass = 'bg-empty';
rightClass = 'bg-add';
}
else if (item.type === 'mod') {
// === IDEA 修改模式 ===
rawTextForIndent = item.textA; // 使用左边的缩进
leftClass = 'bg-mod';
rightClass = 'bg-mod';
// 根据 charDiffs 拼接高亮 HTML
// item.diffs 是一个字符级 diff 数组: [{type:'same', text:'a'}, {type:'del', text:'b'}...]
let lStr = "", rStr = "";
item.diffs.forEach(charChunk => {
const escText = escapeHtml(charChunk.text);
if (charChunk.type === 'same') {
lStr += escText;
rStr += escText;
} else if (charChunk.type === 'del') {
lStr += `<span class="char-del">${escText}</span>`;
} else if (charChunk.type === 'add') {
rStr += `<span class="char-add">${escText}</span>`;
}
});
leftHtml = lStr;
rightHtml = rStr;
}
// === 2. 处理缩进和折叠 ===
const indentMatch = rawTextForIndent.match(/^(\s*)/);
const indentLevel = indentMatch ? indentMatch[1].length : 0;
row.dataset.indent = indentLevel;
const isFoldable = /[\{\[]\s*$/.test(rawTextForIndent);
if (isFoldable) {
row.classList.add('fold-header');
foldToggle = '<span class="fold-toggle">[-]</span>';
}
// 封装 HTML
const buildContent = (html) => `<div class="d-cell-wrap">${foldToggle}${html}</div>`;
// 注意:如果某一边是空的(add/del场景),是否需要显示 toggle?
// 通常只需在一边显示即可,或者两边都占位
// 为了对齐,建议两边都放 wrapper,但只有有内容的那边显示 toggle 图标
// 优化:如果是 IDEA 模式的 MOD,两边都有内容,折叠逻辑应跟随 textA
row.innerHTML = `
<div class="d-cell ${leftClass}">${leftHtml ? buildContent(leftHtml) : ''}</div>
<div class="d-cell ${rightClass}">${rightHtml ? buildContent(rightHtml) : ''}</div>
`;
container.appendChild(row);
});
}
// --- 其他基础逻辑 (保持不变) ---
document.getElementById('ignoreKeys').addEventListener('input', tryCompare);
document.getElementById('diffMode').addEventListener('change', tryCompare);
function addRequest(data) {
const type = data.tabType;
store[type].unshift({ ...data, id: Date.now() + Math.random() });
document.getElementById(type === 'A' ? 'countA' : 'countB').innerText = store[type].length;
renderList(type);
if (type === 'A' && selectedIndexA === -1) selectItem('A', 0);
if (type === 'B' && selectedIndexB === -1) selectItem('B', 0);
}
function renderList(type) {
const listId = type === 'A' ? 'listA' : 'listB';
const container = document.getElementById(listId);
container.innerHTML = '';
store[type].forEach((req, index) => {
const el = document.createElement('div');
el.className = `req-item ${(type === 'A' ? selectedIndexA : selectedIndexB) === index ? 'active' : ''}`;
el.innerHTML = `
<div style="display:flex;justify-content:space-between;">
<span class="req-method">${req.method}</span>
<span style="color:#aaa">${new Date(req.timestamp).toLocaleTimeString()}</span>
</div>
<span class="req-url" title="${req.url}">${req.url.split('?')[0].split('/').pop() || req.url}</span>
`;
el.onclick = () => selectItem(type, index);
container.appendChild(el);
});
}
function selectItem(type, index) {
if (type === 'A') selectedIndexA = index;
if (type === 'B') selectedIndexB = index;
renderList(type);
tryCompare();
}
function tryCompare() {
if (selectedIndexA === -1 || selectedIndexB === -1) return;
const ignoreInput = document.getElementById('ignoreKeys').value;
const ignoreList = ignoreInput.split(/[,,]/).map(k => k.trim()).filter(k => k);
const textA = prepareTextForDiff(store.A[selectedIndexA].postData, ignoreList);
const textB = prepareTextForDiff(store.B[selectedIndexB].postData, ignoreList);
// 1. 计算基础 Diff (行级)
let diffData = computeDiff(textA, textB, '\n');
// 2. 如果是 IDEA 模式,进行二次处理 (合并修改行)
const mode = document.getElementById('diffMode').value;
if (mode === 'idea') {
diffData = processDiffForIdea(diffData);
}
renderSideBySide(diffData);
}
function prepareTextForDiff(rawBody, ignoreList = []) {
if (!rawBody) return "";
try {
const obj = JSON.parse(rawBody);
// 确保格式化为2空格缩进,这对于折叠逻辑至关重要
return JSON.stringify(sortAndFilterObject(obj, ignoreList), null, 2);
} catch (e) { return rawBody; }
}
function sortAndFilterObject(obj, ignoreList) {
if (Array.isArray(obj)) {
return obj.map(item => sortAndFilterObject(item, ignoreList));
} else if (obj !== null && typeof obj === 'object') {
return Object.keys(obj)
.filter(key => !ignoreList.includes(key))
.sort()
.reduce((acc, key) => {
acc[key] = sortAndFilterObject(obj[key], ignoreList);
return acc;
}, {});
}
return obj;
}
function escapeHtml(text) {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function computeDiff(text1, text2, separator = '\n') {
const lines1 = text1.split(separator);
const lines2 = text2.split(separator);
const matrix = [];
for (let i = 0; i <= lines1.length; i++) matrix[i] = [0];
for (let j = 0; j <= lines2.length; j++) matrix[0][j] = 0;
for (let i = 1; i <= lines1.length; i++) {
for (let j = 1; j <= lines2.length; j++) {
if (lines1[i - 1] === lines2[j - 1]) matrix[i][j] = matrix[i - 1][j - 1] + 1;
else matrix[i][j] = Math.max(matrix[i - 1][j], matrix[i][j - 1]);
}
}
const result = [];
let i = lines1.length;
let j = lines2.length;
while (i > 0 && j > 0) {
if (lines1[i - 1] === lines2[j - 1]) {
result.unshift({ type: 'same', text: lines1[i - 1] });
i--; j--;
} else if (matrix[i - 1][j] > matrix[i][j - 1]) {
result.unshift({ type: 'del', text: lines1[i - 1] });
i--;
} else {
result.unshift({ type: 'add', text: lines2[j - 1] });
j--;
}
}
while (i > 0) { result.unshift({ type: 'del', text: lines1[i - 1] }); i--; }
while (j > 0) { result.unshift({ type: 'add', text: lines2[j - 1] }); j--; }
return result;
}
// === 核心算法 2: IDEA 模式后处理 (合并 + 字符Diff) ===
function processDiffForIdea(lineDiffs) {
const result = [];
for (let i = 0; i < lineDiffs.length; i++) {
const curr = lineDiffs[i];
const next = lineDiffs[i + 1];
// 检测模式:【删除】紧接着【新增】
if (curr.type === 'del' && next && next.type === 'add') {
// 计算相似度 (简单算法:相同字符数 / 较长字符串长度)
// 只有相似度大于阈值(例如 0.4)才认为是“修改”,否则还是算作“重写”
const similarity = calculateSimilarity(curr.text, next.text);
if (similarity > 0.4) {
// >>> 判定为修改 (MOD) <<<
// 对这两行进行 字符级 Diff (分隔符为空字符串)
const charDiffs = computeDiff(curr.text, next.text, '');
result.push({
type: 'mod',
diffs: charDiffs, // 存储字符级的 diff 数组
textA: curr.text,
textB: next.text
});
i++; // 跳过下一个 add,因为已经合并处理了
continue;
}
}
// 否则保持原样
result.push(curr);
}
return result;
}
// 简单的相似度计算
function calculateSimilarity(s1, s2) {
if (!s1 && !s2) return 1;
if (!s1 || !s2) return 0;
const longer = s1.length > s2.length ? s1 : s2;
const shorter = s1.length > s2.length ? s2 : s1;
// 简单的包含判断或编辑距离,这里为了性能使用简单的公共字符占比
// 实际生产环境可以用 Levenshtein Distance,但这里简化处理
let matchCount = 0;
for (let char of shorter) {
if (longer.includes(char)) matchCount++;
}
return matchCount / longer.length;
}
- popup.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
body { width: 350px; padding: 15px; font-family: "Microsoft YaHei", sans-serif; }
.input-group { margin-bottom: 12px; }
label { display: block; font-weight: bold; margin-bottom: 5px; font-size: 12px;}
input[type="text"] { width: 100%; padding: 8px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; }
/* 按钮组样式 */
.btn-group { display: flex; gap: 10px; margin-top: 15px; }
button { flex: 1; padding: 10px; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 13px;}
#startBtn { background: #d93025; }
#startBtn:hover { background: #b31412; }
#importBtn { background: #1890ff; flex: 0 0 30%; }
#importBtn:hover { background: #096dd9; }
/* 隐藏文件输入框 */
#fileInput { display: none; }
.desc { font-size: 12px; color: #666; margin-bottom: 10px; }
h3 { margin-top: 0; display: flex; justify-content: space-between; align-items: center; }
</style>
</head>
<body>
<h3>NetDiff 拦截配置</h3>
<p class="desc">配置完成后将打开监控面板和目标网页。</p>
<div class="input-group">
<label>URL A (基准页面)</label>
<input type="text" id="urlA" value="https://www.baidu.com">
</div>
<div class="input-group">
<label>URL B (对比页面)</label>
<input type="text" id="urlB" value="https://www.baidu.com">
</div>
<div class="input-group">
<label>拦截规则 (通配符)</label>
<input type="text" id="filterPattern" value="*/content*" placeholder="例如: */api/*">
</div>
<div class="btn-group">
<button id="importBtn" type="button">📂 导入配置</button>
<button id="startBtn">启动拦截</button>
</div>
<input type="file" id="fileInput" accept=".json">
<script src="popup.js"></script>
</body>
</html>
6. popup.js
// === popup.js ===
// 1. 启动拦截逻辑 (保持不变)
document.getElementById('startBtn').addEventListener('click', () => {
const urlA = document.getElementById('urlA').value;
const urlB = document.getElementById('urlB').value;
const pattern = document.getElementById('filterPattern').value;
if (!urlA || !urlB || !pattern) {
alert("请填写完整信息");
return;
}
chrome.runtime.sendMessage({
type: 'INIT_SESSION',
data: { urlA, urlB, pattern }
});
window.close();
});
// 2. 新增:导入配置逻辑
const importBtn = document.getElementById('importBtn');
const fileInput = document.getElementById('fileInput');
// 点击按钮触发文件选择
importBtn.addEventListener('click', () => {
fileInput.click();
});
// 监听文件选择
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const config = JSON.parse(e.target.result);
// 简单校验
if (config.urlA && config.urlB) {
document.getElementById('urlA').value = config.urlA;
document.getElementById('urlB').value = config.urlB;
document.getElementById('filterPattern').value = config.pattern || "*";
// 视觉反馈
importBtn.innerText = "✅ 导入成功";
setTimeout(() => { importBtn.innerText = "📂 导入配置"; }, 1500);
} else {
alert("配置文件格式错误: 缺少 urlA 或 urlB");
}
} catch (err) {
alert("JSON 解析失败: " + err.message);
}
};
reader.readAsText(file);
// 清空 value,保证下次选同一个文件也能触发 change
fileInput.value = '';
});