chrome请求对比插件

26 阅读6分钟

源码

自行复制下载

  1. html2canvas.js 手动下载

  2. 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"
  }
}
  1. 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];
  }
});
  1. 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>
  1. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

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;
}
  1. 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 = '';
});

演示图

pupop弹窗

image.png

面板页面

image.png