XHR/Fetch Error Notifier

Notification Center: auto-minimize to corner button with badge, expand for error list; only show unread count as badge, no 'read' state.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         XHR/Fetch Error Notifier
// @namespace    https://yourdomain.example.com/
// @version      2025-07-10.3
// @description  Notification Center: auto-minimize to corner button with badge, expand for error list; only show unread count as badge, no 'read' state.
// @author       andychai
// @match        *://*/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  if (window.__xhr_fetch_notifycenter_injected) return;
  window.__xhr_fetch_notifycenter_injected = true;

  // ===== CSS =====
  const style = document.createElement('style');
  style.textContent = `
#xff-notify-btn {
  position: fixed;
  right: 32px;
  bottom: 32px;
  z-index: 99998;
  min-width: 54px;
  min-height: 54px;
  border-radius: 18px;
  background: #23272e;
  color: #fff;
  box-shadow: 0 2px 20px #0005, 0 0.5px 2px #0007;
  font-family: 'Segoe UI', 'Menlo', 'monospace', 'Arial', sans-serif;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: box-shadow .18s, background .12s;
  font-size: 17px;
  border: none;
  outline: none;
  padding: 0 18px 0 16px;
  gap: 13px;
  opacity: 0.94;
}
#xff-notify-btn:hover { background: #31384c; }
#xff-notify-badge {
  background: #d93b41;
  color: #fff;
  font-size: 15px;
  font-weight: bold;
  border-radius: 14px;
  padding: 1.5px 10px 1.5px 10px;
  margin-left: 4px;
  min-width: 22px;
  box-shadow: 0 2px 8px #9e1a1e30;
  text-align: center;
  transition: background .18s, color .18s;
}
#xff-notify-btn.xff-hide { display: none !important; }
#xff-notify-center {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: 490px;
  max-width: 97vw;
  background: #23272e;
  border-radius: 0 0 12px 12px;
  box-shadow: 0 6px 40px #1b1b1b88, 0 2px 10px #0004;
  font-family: 'Segoe UI', 'Menlo', 'monospace', 'Arial', sans-serif;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  pointer-events: auto;
  height: 100vh;
  z-index: 99999;
  transition: right .23s, opacity .17s;
}
#xff-notify-center.xff-hide { display: none !important; }
.xff-center-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: linear-gradient(90deg,#23272e 80%,#303842);
  padding: 13px 22px 11px 22px;
  border-bottom: 1.5px solid #31343c;
  user-select: none;
  font-size: 15.5px;
}
.xff-center-title {
  color: #fff;
  font-weight: bold;
  letter-spacing: .03em;
  display: flex;
  align-items: center;
  font-size: 16px;
  gap: 9px;
}
.xff-center-actions {
  display: flex;
  gap: 10px;
  align-items: center;
}
.xff-center-btn {
  background: #32384a;
  color: #fff;
  border: none;
  border-radius: 5px;
  font-size: 13px;
  padding: 4px 16px;
  cursor: pointer;
  opacity: 0.88;
  transition: background .15s;
}
.xff-center-btn:hover {
  background: #5d80d6;
  opacity: 1;
}
#xff-center-list {
  flex: 1 1 auto;
  max-height: 100vh;
  min-height: 70px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 26px;
  padding: 20px 16px 18px 16px;
  background: none;
}
.xff-popup {
  background: #23272e;
  color: #ececec;
  border-radius: 14px;
  box-shadow: 0 4px 18px #181c2088;
  pointer-events: auto;
  font-family: inherit;
  min-width: 220px;
  max-width: 100%;
  border-left: 6px solid #d45d79;
  display: flex;
  flex-direction: column;
  animation: xff-fade-in 0.45s;
  border-right: 3px solid transparent;
  transition: background .2s, border-right .2s;
  position: relative;
}
@keyframes xff-fade-in {
  from { transform: translateY(20px) scale(0.95); opacity:0 }
  to { transform: translateY(0) scale(1); opacity:1 }
}
.xff-popup-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 15px 5px 15px;
  background: none;
}
.xff-popup-title {
  font-weight: bold;
  font-size: 13.5px;
  color: #fff;
}
.xff-popup-status {
  font-weight: bold;
  margin-left: 11px;
  color: #ffbcb5;
  letter-spacing: 1.2px;
  font-size: 12.5px;
}
.xff-popup-btns {
  display: flex;
  gap: 3px;
  align-items: center;
}
.xff-popup-btn {
  background: #32384a;
  color: #fff;
  border: none;
  outline: none;
  border-radius: 5px;
  padding: 2px 9px;
  font-size: 12px;
  cursor: pointer;
  transition: background .15s;
  opacity: 0.80;
  margin-left: 0;
}
.xff-popup-btn:hover {
  background: #5d80d6;
  color: #fff;
  opacity: 1;
}
.xff-popup-section {
  padding: 8px 16px 4px 16px;
  border-bottom: 1px solid #292c35;
  background: none;
}
.xff-popup-section:last-child { border-bottom: none; }
.xff-label {
  font-size: 11px;
  font-weight: bold;
  color: #aeb2b7;
  margin-bottom: 2px;
  display: block;
}
.xff-code {
  background: #181a21;
  color: #e6e9ef;
  border-radius: 6px;
  padding: 6px 7px;
  margin: 2px 0 8px 0;
  font-size: 12px;
  font-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;
  overflow-x: auto;
  white-space: pre-wrap;
  max-height: 110px;
  line-height: 1.5;
  box-sizing: border-box;
  word-break: break-all;
  transition: max-height .3s;
}
.xff-code.collapsed {
  max-height: 22px;
  overflow-y: hidden;
  cursor: pointer;
  filter: blur(0.5px);
}
.xff-toggle {
  color: #4ea7ff;
  font-size: 11px;
  cursor: pointer;
  padding-left: 3px;
  user-select: none;
}
.xff-popup-url {
  color: #8cd7ff;
  cursor: pointer;
  text-decoration: underline dotted #8cd7ff;
  word-break: break-all;
}
@media (max-width: 600px) {
  #xff-notify-btn { right: 4vw; bottom: 4vw; }
  #xff-notify-center { right: 0; top:0; width: 100vw; height: 100vh; }
  #xff-center-list { max-height: 98vh; }
}
`;
  document.head.appendChild(style);

  // ===== 通知按钮与中心区域 =====
  let unreadCount = 0;
  let lastCardId = 1;

  function ensureNotifyBtn() {
    let btn = document.getElementById('xff-notify-btn');
    if (!btn) {
      btn = document.createElement('button');
      btn.id = 'xff-notify-btn';
      btn.innerHTML = `<span>🔔</span><span id="xff-notify-badge">0</span>`;
      btn.onclick = openCenter;
      document.body.appendChild(btn);
      btn.style.display = '';
    }
    return btn;
  }
  function updateBadge() {
    let badge = document.getElementById('xff-notify-badge');
    if (!badge) return;
    badge.textContent = unreadCount > 0 ? unreadCount : '0';
    badge.style.visibility = unreadCount > 0 ? 'visible' : 'hidden';
  }

  function ensureCenter() {
    let center = document.getElementById('xff-notify-center');
    if (!center) {
      center = document.createElement('div');
      center.id = 'xff-notify-center';
      center.classList.add('xff-hide');
      // header
      const header = document.createElement('div');
      header.className = 'xff-center-header';
      const title = document.createElement('span');
      title.className = 'xff-center-title';
      title.innerHTML = `🔔 Error Notification Center`;
      // 操作区
      const actions = document.createElement('div');
      actions.className = 'xff-center-actions';
      // 清空全部
      const clearBtn = document.createElement('button');
      clearBtn.className = 'xff-center-btn';
      clearBtn.textContent = 'Clear All';
      clearBtn.onclick = function(e) {
        e.stopPropagation();
        document.getElementById('xff-center-list').innerHTML = '';
      };
      // 收起/关闭
      const closeBtn = document.createElement('button');
      closeBtn.className = 'xff-center-btn';
      closeBtn.textContent = 'Minimize';
      closeBtn.onclick = function(e) {
        e.stopPropagation();
        closeCenter();
      };
      actions.appendChild(clearBtn);
      actions.appendChild(closeBtn);

      header.appendChild(title);
      header.appendChild(actions);

      // 列表
      const list = document.createElement('div');
      list.id = 'xff-center-list';

      center.appendChild(header);
      center.appendChild(list);
      document.body.appendChild(center);
    }
    return center;
  }
  function openCenter() {
    document.getElementById('xff-notify-center').classList.remove('xff-hide');
    document.getElementById('xff-notify-btn').classList.add('xff-hide');
    unreadCount = 0;
    updateBadge();
  }
  function closeCenter() {
    document.getElementById('xff-notify-center').classList.add('xff-hide');
    document.getElementById('xff-notify-btn').classList.remove('xff-hide');
  }
  function scrollToBottom() {
    const list = document.getElementById('xff-center-list');
    if (list) list.scrollTop = list.scrollHeight;
  }

  // ====== 卡片内容生成 ======
  function pretty(txt) {
    if (!txt) return "[Empty]";
    try { const o = JSON.parse(txt); return JSON.stringify(o, null, 2); } catch { return txt; }
  }
  function makeKVBlock(obj) {
    const entries = obj && typeof obj === "object" ? Object.entries(obj) : [];
    const isLong = entries.length > 4;
    const div = document.createElement('div');
    div.className = 'xff-code' + (isLong ? ' collapsed' : '');
    if (entries.length === 0) {
      div.textContent = '[Empty]';
    } else {
      div.innerHTML = entries.map(([k, v]) =>
        `<span style="color:#9cdcfb">${k}:</span> <span style="color:#e7d37d">${v}</span>`
      ).join('<br>');
    }
    if (isLong) {
      div.classList.add('collapsed');
      div.title = 'Click to expand/collapse';
      div.style.cursor = 'pointer';
      div.onclick = function () { div.classList.toggle('collapsed'); };
      const toggle = document.createElement('span');
      toggle.className = 'xff-toggle';
      toggle.textContent = '[Expand]';
      toggle.onclick = (e) => {
        div.classList.toggle('collapsed');
        toggle.textContent = div.classList.contains('collapsed') ? '[Expand]' : '[Collapse]';
        e.stopPropagation();
      };
      return [toggle, div];
    }
    return [div];
  }
  function makeCodeBlock(content) {
    const isLong = content.length > 340;
    const pre = document.createElement('pre');
    pre.className = 'xff-code' + (isLong ? ' collapsed' : '');
    pre.textContent = content || '[Empty]';
    if (isLong) {
      pre.title = 'Click to expand/collapse';
      pre.onclick = () => pre.classList.toggle('collapsed');
      const toggle = document.createElement('span');
      toggle.className = 'xff-toggle';
      toggle.textContent = '[Expand]';
      toggle.onclick = (e) => {
        pre.classList.toggle('collapsed');
        toggle.textContent = pre.classList.contains('collapsed') ? '[Expand]' : '[Collapse]';
        e.stopPropagation();
      };
      return [toggle, pre];
    } else {
      return [pre];
    }
  }
  function parseRespHeaders(raw) {
    const obj = {};
    if (!raw) return obj;
    raw.trim().split(/[\r\n]+/).forEach(line => {
      const parts = line.split(': ');
      if (parts.length >= 2) obj[parts.shift()] = parts.join(': ');
    });
    return obj;
  }

  function createPopup({ type, url, method, status, request, requestHeaders, responseHeaders, requestBody, response, error }) {
    const cardId = "xff-card-" + (lastCardId++);
    const popup = document.createElement('div');
    popup.className = 'xff-popup';
    popup.id = cardId;

    // Header
    const header = document.createElement('div');
    header.className = 'xff-popup-header';
    const title = document.createElement('span');
    title.className = 'xff-popup-title';
    title.textContent = `${type} ${method ? method.toUpperCase() : ''} error`;
    const statusEl = document.createElement('span');
    statusEl.className = 'xff-popup-status';
    statusEl.textContent = status ? `[${status}]` : '';
    const btns = document.createElement('div');
    btns.className = 'xff-popup-btns';
    const btnCopy = document.createElement('button');
    btnCopy.className = 'xff-popup-btn';
    btnCopy.textContent = 'Copy';
    btnCopy.onclick = (e) => {
      let allText =
        `Type: ${type}\n` +
        `URL: ${url}\n` +
        (method ? `Method: ${method}\n` : "") +
        (status ? `Status: ${status}\n` : "") +
        (error ? `Error: ${error}\n` : "") +
        (requestHeaders ? `\nRequest Headers:\n${JSON.stringify(requestHeaders, null, 2)}` : "") +
        (request ? `\nRequest:\n${request}` : "") +
        (requestBody ? `\nRequest Body:\n${requestBody}` : "") +
        (responseHeaders ? `\nResponse Headers:\n${JSON.stringify(responseHeaders, null, 2)}` : "") +
        (response ? `\nResponse:\n${response}` : "");
      navigator.clipboard.writeText(allText);
      btnCopy.textContent = 'Copied!';
      setTimeout(() => btnCopy.textContent = 'Copy', 1500);
      e.stopPropagation();
    };
    const btnClose = document.createElement('button');
    btnClose.className = 'xff-popup-btn';
    btnClose.textContent = 'Close';
    btnClose.onclick = (e) => {
      popup.remove();
      e.stopPropagation();
    };
    btns.appendChild(btnCopy);
    btns.appendChild(btnClose);
    header.appendChild(title);
    header.appendChild(statusEl);
    header.appendChild(btns);

    // Section: URL
    const secUrl = document.createElement('div');
    secUrl.className = 'xff-popup-section';
    const urlLabel = document.createElement('span');
    urlLabel.className = 'xff-label';
    urlLabel.textContent = 'URL';
    const urlValue = document.createElement('span');
    urlValue.className = 'xff-popup-url';
    urlValue.textContent = url;
    urlValue.title = 'Click to copy';
    urlValue.onclick = (e) => {
      navigator.clipboard.writeText(url);
      urlValue.style.background = "#3c4f6a";
      setTimeout(() => urlValue.style.background = "none", 800);
      e.stopPropagation();
    };
    secUrl.appendChild(urlLabel);
    secUrl.appendChild(urlValue);

    // Section: Request Headers
    const secReqHeaders = document.createElement('div');
    secReqHeaders.className = 'xff-popup-section';
    const reqHLabel = document.createElement('span');
    reqHLabel.className = 'xff-label';
    reqHLabel.textContent = 'Request Headers';
    secReqHeaders.appendChild(reqHLabel);
    makeKVBlock(requestHeaders).forEach(node => secReqHeaders.appendChild(node));

    // Section: Request Params/Init
    const secReq = document.createElement('div');
    secReq.className = 'xff-popup-section';
    const reqLabel = document.createElement('span');
    reqLabel.className = 'xff-label';
    reqLabel.textContent = 'Request Params / Options';
    secReq.appendChild(reqLabel);
    makeCodeBlock(request || '').forEach(node => secReq.appendChild(node));

    // Section: Request Body
    let secBody = null;
    if (requestBody) {
      secBody = document.createElement('div');
      secBody.className = 'xff-popup-section';
      const bodyLabel = document.createElement('span');
      bodyLabel.className = 'xff-label';
      bodyLabel.textContent = 'Request Body';
      secBody.appendChild(bodyLabel);
      makeCodeBlock(pretty(requestBody)).forEach(node => secBody.appendChild(node));
    }

    // Section: Response Headers
    const secRespHeaders = document.createElement('div');
    secRespHeaders.className = 'xff-popup-section';
    const respHLabel = document.createElement('span');
    respHLabel.className = 'xff-label';
    respHLabel.textContent = 'Response Headers';
    secRespHeaders.appendChild(respHLabel);
    makeKVBlock(responseHeaders).forEach(node => secRespHeaders.appendChild(node));

    // Section: Response/Err
    const secResp = document.createElement('div');
    secResp.className = 'xff-popup-section';
    const respLabel = document.createElement('span');
    respLabel.className = 'xff-label';
    respLabel.textContent = error ? 'Error Message' : 'Response Body';
    secResp.appendChild(respLabel);
    makeCodeBlock(pretty(response || error || "[Empty]")).forEach(node => secResp.appendChild(node));

    // 组装
    popup.appendChild(header);
    popup.appendChild(secUrl);
    popup.appendChild(secReqHeaders);
    popup.appendChild(secReq);
    if (secBody) popup.appendChild(secBody);
    popup.appendChild(secRespHeaders);
    popup.appendChild(secResp);

    // 容器插入
    ensureCenter();
    const list = document.getElementById('xff-center-list');
    list.appendChild(popup);

    scrollToBottom();

    // 自动消失计时
    let timeoutId, startTime = Date.now(), remain = 30000;
    function startTimer() { timeoutId = setTimeout(() => { popup.remove(); }, remain); }
    function stopTimer() { clearTimeout(timeoutId); remain = remain - (Date.now() - startTime); if (remain < 0) remain = 0; }
    popup.addEventListener('mouseenter', function () { stopTimer(); });
    popup.addEventListener('mouseleave', function () { if (remain > 0 && !timeoutId) { startTime = Date.now(); startTimer(); } });
    startTimer();
  }

  // ===== fetch/xhr代理 =====
  const origFetch = window.fetch;
  window.fetch = async function (...args) {
    let url = (typeof args[0] === "string" ? args[0] : args[0].url || "");
    let reqInit = args[1] || {};
    let reqHeaders = reqInit.headers || {};
    let reqBody = reqInit.body ? (typeof reqInit.body === "string" ? reqInit.body : "[object body]") : "";
    try {
      const res = await origFetch.apply(this, args);
      if (!res.ok) {
        let resHeaders = {};
        res.headers.forEach((val, key) => resHeaders[key] = val);
        let resText = "";
        try { resText = await res.clone().text(); } catch {}
        // 状态判断,若为最小化则+1,否则不计数
        let btn = ensureNotifyBtn();
        if (document.getElementById('xff-notify-center').classList.contains('xff-hide')) {
          unreadCount++;
          updateBadge();
        }
        createPopup({
          type: "Fetch",
          url,
          method: reqInit.method || "GET",
          status: res.status,
          request: JSON.stringify(reqInit, null, 2),
          requestHeaders: reqHeaders,
          responseHeaders: resHeaders,
          requestBody: reqBody,
          response: resText
        });
      }
      return res;
    } catch (err) {
      if (document.getElementById('xff-notify-center').classList.contains('xff-hide')) {
        unreadCount++;
        updateBadge();
      }
      createPopup({
        type: "Fetch",
        url,
        method: reqInit.method || "GET",
        error: err + "",
        request: JSON.stringify(reqInit, null, 2),
        requestHeaders: reqHeaders
      });
      throw err;
    }
  };

  const origOpen = XMLHttpRequest.prototype.open;
  const origSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._xff_url = url;
    this._xff_method = method;
    return origOpen.call(this, method, url, ...rest);
  };
  XMLHttpRequest.prototype.send = function (body) {
    this._xff_body = body;
    const xhr = this;
    const origSetRequestHeader = xhr.setRequestHeader;
    xhr._xff_headers = {};
    xhr.setRequestHeader = function(key, val) {
      xhr._xff_headers[key] = val;
      return origSetRequestHeader.call(this, key, val);
    };
    xhr.addEventListener('readystatechange', function () {
      if (this.readyState === 4 && (this.status < 200 || this.status >= 300)) {
        let resHeaders = parseRespHeaders(this.getAllResponseHeaders());
        if (document.getElementById('xff-notify-center').classList.contains('xff-hide')) {
          unreadCount++;
          updateBadge();
        }
        createPopup({
          type: "XHR",
          url: this._xff_url,
          method: this._xff_method,
          status: this.status,
          request: "",
          requestHeaders: xhr._xff_headers,
          responseHeaders: resHeaders,
          requestBody: xhr._xff_body ? (typeof xhr._xff_body === "string" ? xhr._xff_body : "[object body]") : "",
          response: this.responseText
        });
      }
    });
    return origSend.apply(this, arguments);
  };

  // 初始化
  ensureNotifyBtn();
  ensureCenter();

})();