Network Logger

带悬浮面板的网络请求监听器

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Network Logger
// @namespace    https://tampermonkey.net/
// @version      1.0
// @description  带悬浮面板的网络请求监听器
// @match        *://*/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  if (window.__nw_logger_installed__) return;
  window.__nw_logger_installed__ = true;

  // ============================================================
  //  配置
  // ============================================================
  let config = {
    enabled: true,
    keywords: [],
    maxBodyLength: 2000,
    logXHR: true,
    logFetch: true,
  };

  // 日志存储
  const logs = [];
  let MAX_LOGS = 200;

  // ============================================================
  //  Hook 必须最早执行
  // ============================================================

  function shouldLog(url) {
    if (!config.enabled) return false;
    if (config.keywords.length === 0) return true;
    return config.keywords.some(kw => kw && url.includes(kw));
  }

  function parseBody(body) {
    try {
      return { type: 'json', data: JSON.parse(body) };
    } catch {
      return { type: 'text', data: String(body) };
    }
  }

  function addLog(entry) {
    logs.unshift(entry);
    if (logs.length > MAX_LOGS) logs.pop();
    renderLogs();
  }

  // ===== XHR Hook =====
  const origOpen = XMLHttpRequest.prototype.open;
  const origSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this.__nw_method__ = method;
    this.__nw_url__ = url;
    return origOpen.apply(this, [method, url, ...rest]);
  };

  XMLHttpRequest.prototype.send = function (...sendArgs) {
    this.addEventListener('load', function () {
      const url = this.responseURL || this.__nw_url__ || '';
      if (!shouldLog(url)) return;

      const rawBody = (this.responseText != null && this.responseText !== '')
        ? this.responseText
        : (typeof this.response === 'string'
          ? this.response
          : JSON.stringify(this.response));

      addLog({
        id: Date.now() + Math.random(),
        type: 'XHR',
        method: (this.__nw_method__ || 'GET').toUpperCase(),
        url,
        status: this.status,
        time: new Date().toLocaleTimeString(),
        body: rawBody,
        parsed: parseBody(rawBody),
      });
    });

    return origSend.apply(this, sendArgs);
  };

  // ===== Fetch Hook =====
  const origFetch = window.fetch;
  window.fetch = async function (...args) {
    const req = args[0];
    const url = req instanceof Request ? req.url : String(req || '');
    const method = (args[1]?.method || (req instanceof Request ? req.method : 'GET')).toUpperCase();

    const response = await origFetch.apply(this, args);

    if (shouldLog(url)) {
      response.clone().text()
        .then(text => {
          addLog({
            id: Date.now() + Math.random(),
            type: 'Fetch',
            method,
            url,
            status: response.status,
            time: new Date().toLocaleTimeString(),
            body: text,
            parsed: parseBody(text),
          });
        })
        .catch(() => { });
    }

    return response;
  };

  // ============================================================
  //  UI 创建
  // ============================================================
  function initUI() {

    // ---------- 样式 ----------
    const style = document.createElement('style');
    style.textContent = `
      /* 容器 */
      #nw-logger-root * {
        box-sizing: border-box;
        font-family: 'Consolas', 'Monaco', monospace;
      }

      /* 悬浮按钮 */
      #nw-logger-fab {
        position: fixed;
        bottom: 24px;
        right: 24px;
        z-index: 2147483646;
        width: 52px;
        height: 52px;
        border-radius: 50%;
        background: linear-gradient(135deg, #0f3460, #e94560);
        color: #fff;
        font-size: 22px;
        border: none;
        cursor: pointer;
        box-shadow: 0 4px 16px rgba(0,0,0,0.4);
        display: flex;
        align-items: center;
        justify-content: center;
        transition: transform 0.2s, box-shadow 0.2s;
        user-select: none;
      }
      #nw-logger-fab:hover {
        transform: scale(1.1);
        box-shadow: 0 6px 24px rgba(233,69,96,0.5);
      }

      /* 徽标 */
      #nw-logger-badge {
        position: absolute;
        top: -4px;
        right: -4px;
        background: #ff4757;
        color: #fff;
        font-size: 10px;
        font-family: sans-serif;
        min-width: 18px;
        height: 18px;
        border-radius: 9px;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 0 4px;
        font-weight: bold;
        display: none;
      }

      /* 主面板 */
      #nw-logger-panel {
        position: fixed;
        bottom: 90px;
        right: 24px;
        z-index: 2147483645;
        width: 700px;
        max-width: calc(100vw - 48px);
        height: 520px;
        max-height: calc(100vh - 120px);
        background: #0d1117;
        border: 1px solid #30363d;
        border-radius: 12px;
        box-shadow: 0 16px 48px rgba(0,0,0,0.6);
        display: flex;
        flex-direction: column;
        overflow: hidden;
        transition: opacity 0.2s, transform 0.2s;
      }
      #nw-logger-panel.hidden {
        opacity: 0;
        transform: translateY(12px) scale(0.98);
        pointer-events: none;
      }

      /* 顶部栏 */
      #nw-logger-header {
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 10px 14px;
        background: #161b22;
        border-bottom: 1px solid #30363d;
        flex-shrink: 0;
      }
      #nw-logger-header h3 {
        margin: 0;
        font-size: 13px;
        color: #e6edf3;
        flex: 1;
        letter-spacing: 0.5px;
      }

      /* 顶部按钮 */
      .nw-hbtn {
        padding: 3px 10px;
        border-radius: 5px;
        border: 1px solid #30363d;
        background: #21262d;
        color: #c9d1d9;
        font-size: 11px;
        cursor: pointer;
        transition: background 0.15s;
        white-space: nowrap;
      }
      .nw-hbtn:hover { background: #30363d; }
      .nw-hbtn.danger:hover { background: #5a1a1a; border-color: #e94560; color: #e94560; }
      .nw-hbtn.active { background: #1f6feb; border-color: #388bfd; color: #fff; }

      /* 开关 */
      #nw-toggle-wrap {
        display: flex;
        align-items: center;
        gap: 5px;
        font-size: 11px;
        color: #8b949e;
      }
      #nw-toggle {
        position: relative;
        width: 32px;
        height: 17px;
        flex-shrink: 0;
      }
      #nw-toggle input { opacity: 0; width: 0; height: 0; }
      #nw-toggle-slider {
        position: absolute;
        inset: 0;
        background: #30363d;
        border-radius: 17px;
        cursor: pointer;
        transition: background 0.2s;
      }
      #nw-toggle-slider::before {
        content: '';
        position: absolute;
        width: 11px; height: 11px;
        left: 3px; top: 3px;
        background: #fff;
        border-radius: 50%;
        transition: transform 0.2s;
      }
      #nw-toggle input:checked + #nw-toggle-slider { background: #238636; }
      #nw-toggle input:checked + #nw-toggle-slider::before { transform: translateX(15px); }

      /* 过滤栏 */
      #nw-logger-toolbar {
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 7px 14px;
        background: #161b22;
        border-bottom: 1px solid #30363d;
        flex-shrink: 0;
        flex-wrap: wrap;
      }
      #nw-search {
        flex: 1;
        min-width: 120px;
        padding: 4px 8px;
        background: #0d1117;
        border: 1px solid #30363d;
        border-radius: 5px;
        color: #c9d1d9;
        font-size: 12px;
        outline: none;
      }
      #nw-search:focus { border-color: #1f6feb; }
      #nw-keywords {
        flex: 1.5;
        min-width: 150px;
        padding: 4px 8px;
        background: #0d1117;
        border: 1px solid #30363d;
        border-radius: 5px;
        color: #c9d1d9;
        font-size: 12px;
        outline: none;
      }
      #nw-keywords:focus { border-color: #e94560; }
      .nw-filter-btn {
        padding: 4px 8px;
        border-radius: 5px;
        border: 1px solid #30363d;
        background: #21262d;
        color: #8b949e;
        font-size: 11px;
        cursor: pointer;
        transition: all 0.15s;
      }
      .nw-filter-btn.on {
        background: #1f3a5f;
        border-color: #388bfd;
        color: #79c0ff;
      }

      /* 日志列表 */
      #nw-logger-list {
        flex: 1;
        overflow-y: auto;
        padding: 6px 0;
        scrollbar-width: thin;
        scrollbar-color: #30363d #0d1117;
      }
      #nw-logger-list::-webkit-scrollbar { width: 5px; }
      #nw-logger-list::-webkit-scrollbar-track { background: #0d1117; }
      #nw-logger-list::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }

      /* 空状态 */
      #nw-empty {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        height: 100%;
        color: #484f58;
        font-size: 13px;
        gap: 8px;
      }
      #nw-empty span { font-size: 36px; }

      /* 日志条目 */
      .nw-log-item {
        border-bottom: 1px solid #21262d;
        cursor: pointer;
        transition: background 0.12s;
      }
      .nw-log-item:hover { background: #161b22; }
      .nw-log-item.expanded { background: #161b22; }

      /* 条目头部 */
      .nw-log-head {
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 6px 14px;
        font-size: 12px;
      }

      /* 类型标签 */
      .nw-badge {
        padding: 1px 6px;
        border-radius: 4px;
        font-size: 10px;
        font-weight: bold;
        flex-shrink: 0;
      }
      .nw-badge.xhr { background: #0d419d; color: #79c0ff; }
      .nw-badge.fetch { background: #3d1d00; color: #ffa657; }

      /* 方法标签 */
      .nw-method {
        font-size: 10px;
        font-weight: bold;
        flex-shrink: 0;
        width: 38px;
        text-align: center;
      }
      .nw-method.get { color: #3fb950; }
      .nw-method.post { color: #ffa657; }
      .nw-method.put { color: #79c0ff; }
      .nw-method.delete { color: #e94560; }
      .nw-method.other { color: #8b949e; }

      /* 状态码 */
      .nw-status {
        font-size: 10px;
        font-weight: bold;
        flex-shrink: 0;
        width: 28px;
      }
      .nw-status.ok { color: #3fb950; }
      .nw-status.redirect { color: #e3b341; }
      .nw-status.error { color: #e94560; }

      /* URL */
      .nw-url {
        flex: 1;
        color: #c9d1d9;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        font-size: 12px;
      }

      /* 时间 */
      .nw-time {
        color: #484f58;
        font-size: 10px;
        flex-shrink: 0;
      }

      /* 展开箭头 */
      .nw-arrow {
        color: #484f58;
        font-size: 10px;
        flex-shrink: 0;
        transition: transform 0.2s;
      }
      .nw-log-item.expanded .nw-arrow { transform: rotate(90deg); }

      /* 展开内容 */
      .nw-log-body {
        display: none;
        padding: 0 14px 10px 14px;
      }
      .nw-log-item.expanded .nw-log-body { display: block; }

      /* URL 完整显示 */
      .nw-full-url {
        font-size: 11px;
        color: #8b949e;
        word-break: break-all;
        margin-bottom: 8px;
        padding: 6px 8px;
        background: #161b22;
        border-radius: 4px;
        border: 1px solid #21262d;
      }

      /* body 区域 */
      .nw-body-wrap {
        position: relative;
      }
      .nw-body-label {
        font-size: 10px;
        color: #484f58;
        margin-bottom: 4px;
        display: flex;
        align-items: center;
        justify-content: space-between;
      }
      .nw-copy-btn {
        padding: 1px 7px;
        border-radius: 3px;
        border: 1px solid #30363d;
        background: #21262d;
        color: #8b949e;
        font-size: 10px;
        cursor: pointer;
        transition: all 0.15s;
      }
      .nw-copy-btn:hover { background: #30363d; color: #c9d1d9; }
      .nw-copy-btn.copied { border-color: #238636; color: #3fb950; }

      pre.nw-pre {
        margin: 0;
        padding: 8px;
        background: #010409;
        border: 1px solid #21262d;
        border-radius: 5px;
        font-size: 11px;
        color: #c9d1d9;
        overflow-x: auto;
        white-space: pre-wrap;
        word-break: break-all;
        max-height: 220px;
        overflow-y: auto;
        scrollbar-width: thin;
        scrollbar-color: #30363d #010409;
        line-height: 1.5;
      }

      /* 底部状态栏 */
      #nw-logger-footer {
        padding: 5px 14px;
        background: #161b22;
        border-top: 1px solid #30363d;
        font-size: 10px;
        color: #484f58;
        display: flex;
        justify-content: space-between;
        align-items: center;
        flex-shrink: 0;
      }
    `;
    document.head.appendChild(style);

    // ---------- 根容器 ----------
    const root = document.createElement('div');
    root.id = 'nw-logger-root';

    // ---------- 悬浮按钮 ----------
    root.innerHTML = `
      <button id="nw-logger-fab" title="Network Logger">
        🌐
        <span id="nw-logger-badge"></span>
      </button>

      <div id="nw-logger-panel" class="hidden">

        <!-- 顶部 -->
        <div id="nw-logger-header">
          <h3>🌐 Network Logger</h3>

          <!-- 开关 -->
          <div id="nw-toggle-wrap">
            <label id="nw-toggle">
              <input type="checkbox" id="nw-enabled-chk" ${config.enabled ? 'checked' : ''}>
              <span id="nw-toggle-slider"></span>
            </label>
            <span id="nw-toggle-label">${config.enabled ? '监听中' : '已暂停'}</span>
          </div>

          <button class="nw-hbtn" id="nw-xhr-btn"  title="XHR开关">XHR</button>
          <button class="nw-hbtn" id="nw-fetch-btn" title="Fetch开关">Fetch</button>
          <button class="nw-hbtn danger" id="nw-clear-btn">清空</button>
        </div>

        <!-- 过滤栏 -->
        <div id="nw-logger-toolbar">
          <input id="nw-search"   placeholder="🔍 搜索 URL..." />
          <input id="nw-keywords" placeholder="📌 监听关键词(逗号分隔,空=全部)" />
          <button class="nw-filter-btn" data-m="GET">GET</button>
          <button class="nw-filter-btn" data-m="POST">POST</button>
        </div>

        <!-- 列表 -->
        <div id="nw-logger-list">
          <div id="nw-empty"><span>📭</span>暂无请求记录</div>
        </div>

        <!-- 底部 -->
        <div id="nw-logger-footer">
          <span id="nw-footer-count">共 0 条</span>
          <span>最多显示 ${MAX_LOGS} 条</span>
        </div>
      </div>
    `;
    document.body.appendChild(root);

    // ---------- 获取元素 ----------
    const fab       = root.querySelector('#nw-logger-fab');
    const badge     = root.querySelector('#nw-logger-badge');
    const panel     = root.querySelector('#nw-logger-panel');
    const list      = root.querySelector('#nw-logger-list');
    const empty     = root.querySelector('#nw-empty');
    const footerCnt = root.querySelector('#nw-footer-count');
    const chk       = root.querySelector('#nw-enabled-chk');
    const toggleLbl = root.querySelector('#nw-toggle-label');
    const searchEl  = root.querySelector('#nw-search');
    const kwEl      = root.querySelector('#nw-keywords');
    const xhrBtn    = root.querySelector('#nw-xhr-btn');
    const fetchBtn  = root.querySelector('#nw-fetch-btn');
    const clearBtn  = root.querySelector('#nw-clear-btn');
    const filterBtns = root.querySelectorAll('.nw-filter-btn');

    // ---------- 状态 ----------
    let panelOpen   = false;
    let searchQ     = '';
    let methodFilter = new Set(); // 空 = 全部

    // ---------- 初始化按钮状态 ----------
    if (config.logXHR)   xhrBtn.classList.add('active');
    if (config.logFetch) fetchBtn.classList.add('active');

    // ---------- 悬浮按钮点击 ----------
    fab.addEventListener('click', () => {
      panelOpen = !panelOpen;
      panel.classList.toggle('hidden', !panelOpen);
      if (panelOpen) {
        badge.style.display = 'none';
        renderLogs();
      }
    });

    // ---------- 监听开关 ----------
    chk.addEventListener('change', () => {
      config.enabled = chk.checked;
      toggleLbl.textContent = config.enabled ? '监听中' : '已暂停';
    });

    // ---------- XHR / Fetch 开关 ----------
    xhrBtn.addEventListener('click', () => {
      config.logXHR = !config.logXHR;
      xhrBtn.classList.toggle('active', config.logXHR);
    });
    fetchBtn.addEventListener('click', () => {
      config.logFetch = !config.logFetch;
      fetchBtn.classList.toggle('active', config.logFetch);
    });

    // ---------- 清空 ----------
    clearBtn.addEventListener('click', () => {
      logs.length = 0;
      renderLogs();
    });

    // ---------- 搜索 ----------
    searchEl.addEventListener('input', () => {
      searchQ = searchEl.value.trim().toLowerCase();
      renderLogs();
    });

    // ---------- 关键词----------
    kwEl.addEventListener('change', () => {
      config.keywords = kwEl.value.split(',').map(s => s.trim()).filter(Boolean);
    });

    // ---------- 方法过滤 ----------
    filterBtns.forEach(btn => {
      btn.addEventListener('click', () => {
        const m = btn.dataset.m;
        if (methodFilter.has(m)) {
          methodFilter.delete(m);
          btn.classList.remove('on');
        } else {
          methodFilter.add(m);
          btn.classList.add('on');
        }
        renderLogs();
      });
    });

    // ---------- 渲染 ----------
    window.__nw_render__ = function () {
      // 过滤
      const filtered = logs.filter(log => {
        if (methodFilter.size > 0 && !methodFilter.has(log.method)) return false;
        if (searchQ && !log.url.toLowerCase().includes(searchQ)) return false;
        return true;
      });

      // 更新徽标
      if (!panelOpen && logs.length > 0) {
        badge.style.display = 'flex';
        badge.textContent = logs.length > 99 ? '99+' : logs.length;
      }

      footerCnt.textContent = `共 ${filtered.length} 条${searchQ || methodFilter.size ? '(已过滤)' : ''}`;

      if (!panelOpen) return;

      if (filtered.length === 0) {
        empty.style.display = 'flex';
        // 移除旧条目
        list.querySelectorAll('.nw-log-item').forEach(el => el.remove());
        return;
      }
      empty.style.display = 'none';

      // 构建 id => DOM 映射
      const existing = {};
      list.querySelectorAll('.nw-log-item').forEach(el => {
        existing[el.dataset.id] = el;
      });

      // 按顺序重建
      const frag = document.createDocumentFragment();
      filtered.forEach(log => {
        let item = existing[log.id];
        if (!item) {
          item = buildLogItem(log);
        }
        delete existing[log.id];
        frag.appendChild(item);
      });

      // 删除不再显示的
      Object.values(existing).forEach(el => el.remove());

      list.appendChild(frag);
    };

    // 构建单条日志 DOM
    function buildLogItem(log) {
      const item = document.createElement('div');
      item.className = 'nw-log-item';
      item.dataset.id = log.id;

      // 状态码颜色
      const statusClass = log.status >= 400 ? 'error' : log.status >= 300 ? 'redirect' : 'ok';
      // 方法颜色
      const methodClass = { GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete' }[log.method] || 'other';
      // body 内容
      const bodyStr = log.parsed.type === 'json'
        ? JSON.stringify(log.parsed.data, null, 2)
        : String(log.body).substring(0, config.maxBodyLength);

      item.innerHTML = `
        <div class="nw-log-head">
          <span class="nw-arrow">▶</span>
          <span class="nw-badge ${log.type.toLowerCase()}">${log.type}</span>
          <span class="nw-method ${methodClass}">${log.method}</span>
          <span class="nw-status ${statusClass}">${log.status}</span>
          <span class="nw-url" title="${log.url}">${log.url}</span>
          <span class="nw-time">${log.time}</span>
        </div>
        <div class="nw-log-body">
          <div class="nw-full-url">${log.url}</div>
          <div class="nw-body-wrap">
            <div class="nw-body-label">
              <span>响应体 ${log.parsed.type === 'json' ? '(JSON)' : '(Text)'}</span>
              <button class="nw-copy-btn">复制</button>
            </div>
            <pre class="nw-pre">${escHtml(bodyStr)}</pre>
          </div>
        </div>
      `;

      // 展开/收起
      item.querySelector('.nw-log-head').addEventListener('click', () => {
        item.classList.toggle('expanded');
      });

      // 复制
      const copyBtn = item.querySelector('.nw-copy-btn');
      copyBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        navigator.clipboard.writeText(log.body || '').then(() => {
          copyBtn.textContent = '✅ 已复制';
          copyBtn.classList.add('copied');
          setTimeout(() => {
            copyBtn.textContent = '复制';
            copyBtn.classList.remove('copied');
          }, 1500);
        });
      });

      return item;
    }

    // HTML 转义
    function escHtml(str) {
      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
    }
  }; // end initUI

  // ============================================================
  //  renderLogs 代理
  // ============================================================
  function renderLogs() {
    if (window.__nw_render__) window.__nw_render__();
  }

  // ============================================================
  //  DOM 就绪后初始化 UI
  // ============================================================
  if (document.body) {
    initUI();
  } else {
    document.addEventListener('DOMContentLoaded', initUI);
  }

})();