// ==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();
})();