- background.js
let dashboardTabId = null;
let targetTabs = {};
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 || "*";
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);
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';
setTimeout(() => {
chrome.runtime.sendMessage({
type: 'STATUS_UPDATE',
pattern: currentPattern,
urlA: urlA,
urlB: urlB,
tabs: { A: tabA.id, B: tabB.id }
}).catch(() => {});
}, 1500);
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);
}
async function attachDebugger(tabId, pattern) {
let tab;
try {
tab = await chrome.tabs.get(tabId);
} catch (e) {
console.log(`Tab ${tabId} 可能已关闭,跳过挂载`);
return;
}
if (tab.url.startsWith("chrome:") || tab.url.startsWith("chrome-extension:") || tab.url.startsWith("edge:")) {
console.warn(`⚠️ 跳过挂载: 无法调试受限页面 -> ${tab.url}`);
console.warn("提示: 如果这是 API 接口,请检查是否被 'JSON Viewer' 类插件接管了页面。");
return;
}
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");
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] || "未知";
if (method === "Network.requestWillBeSent") {
console.log(`[Network流] [${tabType}] ${params.request.url}`);
return;
}
if (method === "Fetch.requestPaused") {
const requestId = params.requestId;
const request = params.request;
console.log(`⚡️ [Fetch捕获] [${tabType}] 收到请求: ${request.url}`);
const cleanPattern = (currentPattern || "").trim();
const urlClean = request.url.split('?')[0].split('#')[0];
const escapeRegex = (str) => str.replace(/([.+?^=!:${}()|\[\]\/\\])/g, "\\$1");
const patternString = "^" + cleanPattern.split("*").map(escapeRegex).join(".*") + "$";
const regexPattern = new RegExp(patternString);
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 {
await chrome.debugger.sendCommand(source, "Fetch.continueRequest", {
requestId: requestId
});
}
}
});
chrome.tabs.onRemoved.addListener((tabId) => {
if (targetTabs[tabId]) {
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 { 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-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; }
.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; }
.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; }
.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;
}
</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
const store = { A: [], B: [] };
let selectedIndexA = -1;
let selectedIndexB = -1;
let currentConfig = { urlA: "", urlB: "", pattern: "" };
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);
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);
}
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);
}
});
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;
}
}
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>';
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') {
rawTextForIndent = item.textA;
leftClass = 'bg-mod';
rightClass = 'bg-mod';
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;
}
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>';
}
const buildContent = (html) => `<div class="d-cell-wrap">${foldToggle}${html}</div>`;
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);
let diffData = computeDiff(textA, textB, '\n');
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);
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;
}
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') {
const similarity = calculateSimilarity(curr.text, next.text);
if (similarity > 0.4) {
const charDiffs = computeDiff(curr.text, next.text, '');
result.push({
type: 'mod',
diffs: charDiffs,
textA: curr.text,
textB: next.text
});
i++;
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;
let matchCount = 0;
for (let char of shorter) {
if (longer.includes(char)) matchCount++;
}
return matchCount / longer.length;
}