Skip to content

Instantly share code, notes, and snippets.

@lyrl
Created July 22, 2026 04:03
Show Gist options
  • Select an option

  • Save lyrl/86c99a5d6ef36d308970af18258888f8 to your computer and use it in GitHub Desktop.

Select an option

Save lyrl/86c99a5d6ef36d308970af18258888f8 to your computer and use it in GitHub Desktop.
后台接口请求实时弹窗提醒
// ==UserScript==
// @name 后台接口请求实时弹窗提醒
// @namespace http://tampermonkey.net
// @version 1.2
// @description 左下角单行列表实时显示 fetch/XHR 请求,悬停暂停消失,点击查看请求/响应详情
// @author YourName
// @match *://*/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const MAX = 12; // 同屏最多行数,超出丢最旧
const TTL = 4000; // 响应结束后存活毫秒(不是从发起算)
const CLIP = 50 * 1024; // 单个 body 最多留 50KB
const SKIP_OVER = 512 * 1024; // 响应超过 512KB 直接不抓
const style = document.createElement('style');
style.innerHTML = `
#api-notifier {
position: fixed; bottom: 16px; left: 16px; z-index: 999999;
display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
pointer-events: none;
font: 11px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #e5e5e5;
}
#api-notifier-list {
width: 380px;
display: flex; flex-direction: column;
background: rgba(17, 17, 17, 0.95);
backdrop-filter: blur(8px);
border-radius: 4px; overflow: hidden; padding: 4px 0;
}
#api-notifier-list:empty { display: none; }
.api-toast {
display: grid; grid-template-columns: 34px 1fr auto; gap: 6px;
padding: 0 8px;
pointer-events: auto; cursor: pointer;
animation: apiFadeIn 0.15s ease;
}
.api-toast:hover { background: rgba(255, 255, 255, 0.1); }
/* 1fr 默认 min-width:auto,不加这行省略号不生效 */
.api-path { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.toast-xhr b { color: #60a5fa; }
.toast-fetch b { color: #34d399; }
.api-count { color: #fbbf24; margin-right: 6px; }
.api-status { color: #9ca3af; }
.api-status.bad { color: #f87171; }
@keyframes apiFadeIn { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
#api-notifier-detail {
display: none;
width: 560px; max-height: 60vh; overflow: auto;
pointer-events: auto;
background: rgba(17, 17, 17, 0.97);
backdrop-filter: blur(8px);
border-radius: 4px; padding: 8px 10px;
}
.api-d-head { display: flex; justify-content: space-between; align-items: center; }
.api-d-close {
all: unset; cursor: pointer; color: #9ca3af; font-size: 14px; padding: 0 4px;
}
.api-d-close:hover { color: #fff; }
.api-d-url { color: #93c5fd; word-break: break-all; margin: 2px 0 6px; }
.api-d-label { color: #6b7280; margin-top: 6px; }
.api-d-pre {
margin: 2px 0 0; white-space: pre-wrap; word-break: break-all;
max-height: 220px; overflow: auto;
background: rgba(255, 255, 255, 0.04); border-radius: 3px; padding: 4px 6px;
font: inherit; color: #d1d5db;
}
`;
document.documentElement.appendChild(style);
const root = document.createElement('div');
root.id = 'api-notifier';
const detail = document.createElement('div');
detail.id = 'api-notifier-detail';
const list = document.createElement('div');
list.id = 'api-notifier-list';
root.append(detail, list);
if (document.body) {
document.body.appendChild(root);
} else {
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(root));
}
const live = new Map(); // key -> rec
let paused = false;
function drop(key) {
const r = live.get(key);
if (!r) return;
clearTimeout(r.timer);
r.el.remove();
live.delete(key);
}
function arm(rec) {
clearTimeout(rec.timer);
if (!paused && rec.done) rec.timer = setTimeout(() => drop(rec.key), TTL);
}
// 鼠标在列表上时不消失;移开后所有已完成的行重新计时
list.addEventListener('mouseenter', () => {
paused = true;
live.forEach(r => clearTimeout(r.timer));
});
list.addEventListener('mouseleave', () => {
paused = false;
live.forEach(arm);
});
function clip(s) {
return s.length > CLIP ? s.slice(0, CLIP) + '\n…[已截断]' : s;
}
function pretty(s) {
if (s == null || s === '') return '(空)';
try { return JSON.stringify(JSON.parse(s), null, 2); } catch (e) { return s; }
}
function section(title, body) {
const w = document.createElement('div');
const h = document.createElement('div');
h.className = 'api-d-label';
h.textContent = title;
const pre = document.createElement('pre');
pre.className = 'api-d-pre';
pre.textContent = pretty(body);
w.append(h, pre);
return w;
}
function openDetail(rec) {
detail.textContent = '';
detail.__rec = rec;
const head = document.createElement('div');
head.className = 'api-d-head';
const t = document.createElement('span');
t.textContent = rec.method + ' · ' + (rec.done ? rec.status + ' · ' + rec.ms + 'ms' : '请求中…')
+ (rec.n > 1 ? ' (×' + rec.n + ',显示最近一次)' : '');
const x = document.createElement('button');
x.className = 'api-d-close';
x.textContent = '×';
x.onclick = () => { detail.style.display = 'none'; detail.__rec = null; };
head.append(t, x);
const url = document.createElement('div');
url.className = 'api-d-url';
url.textContent = rec.url;
detail.append(head, url, section('REQUEST', rec.reqBody), section('RESPONSE', rec.resBody));
detail.style.display = 'block';
}
function finish(rec, status, ms) {
rec.done = true;
rec.status = status;
rec.ms = ms;
rec.statusEl.textContent = status + ' · ' + ms + 'ms';
rec.statusEl.classList.toggle('bad', !(status >= 200 && status < 400));
arm(rec);
if (detail.__rec === rec) openDetail(rec); // 详情开着就刷新,body 是异步到的
}
function track(type, method, url, reqBody) {
if (url.startsWith('data:') || url.startsWith('blob:')) return null;
let u;
try { u = new URL(url, location.href); } catch (e) { return null; }
// 静态资源看 pathname 后缀判断,避免 query 里带 .js 被误杀
if (/\.(js|mjs|css|map|png|jpe?g|gif|svg|ico|woff2?|ttf)$/i.test(u.pathname)) return null;
// 同源只留 path+query,省掉每行重复的 origin
const short = (u.origin === location.origin ? '' : u.host) + u.pathname + u.search;
const key = type + ' ' + method + ' ' + short;
const hit = live.get(key);
if (hit) {
// 同 URL 重复请求合并计数,详情只留最近一次
hit.countEl.textContent = '×' + (++hit.n);
hit.done = false;
hit.reqBody = reqBody;
hit.resBody = null;
hit.statusEl.textContent = '…';
hit.statusEl.classList.remove('bad');
clearTimeout(hit.timer);
return hit;
}
const el = document.createElement('div');
el.className = 'api-toast toast-' + type.toLowerCase();
el.dataset.k = key;
const m = document.createElement('b');
m.textContent = method;
const p = document.createElement('span');
p.className = 'api-path';
p.textContent = short;
const meta = document.createElement('span');
const countEl = document.createElement('span');
countEl.className = 'api-count';
const statusEl = document.createElement('span');
statusEl.className = 'api-status';
statusEl.textContent = '…';
meta.append(countEl, statusEl);
el.append(m, p, meta);
const rec = { key, el, countEl, statusEl, n: 1, type, method, url: u.href, reqBody, resBody: null, done: false, timer: null };
el.onclick = () => openDetail(rec);
list.appendChild(el);
live.set(key, rec);
while (list.children.length > MAX) drop(list.firstElementChild.dataset.k);
return rec;
}
function bodyOf(b) {
if (b == null) return null;
if (typeof b === 'string') return clip(b);
if (b instanceof URLSearchParams) return clip(String(b));
return '[' + (b.constructor ? b.constructor.name : typeof b) + ',未抓取]';
}
// SSE 流 clone 后 .text() 永远不 resolve,还会把整条流缓存进内存 —— 必须挡掉
function grabResponse(res, rec) {
const ct = res.headers.get('content-type') || '';
const len = +(res.headers.get('content-length') || 0);
if (ct.includes('text/event-stream')) { rec.resBody = '[SSE 流,未抓取]'; return; }
if (len > SKIP_OVER) { rec.resBody = '[响应 ' + len + ' 字节,超过上限,未抓取]'; return; }
res.clone().text().then(t => {
rec.resBody = clip(t);
if (detail.__rec === rec) openDetail(rec);
}, e => { rec.resBody = '[读取失败: ' + e + ']'; });
}
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
try { this.__api = { method: String(method).toUpperCase(), url: String(url) }; } catch (e) {}
return originalOpen.apply(this, [method, url, ...args]);
};
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(body) {
try {
const i = this.__api;
const rec = i && track('XHR', i.method, i.url, bodyOf(body));
if (rec) {
const t0 = performance.now();
this.addEventListener('loadend', () => {
try {
const rt = this.responseType;
rec.resBody = (rt === '' || rt === 'text') ? clip(this.responseText || '')
: (rt === 'json' ? clip(JSON.stringify(this.response)) : '[responseType=' + rt + ',未抓取]');
} catch (e) { rec.resBody = '[读取失败: ' + e + ']'; }
finish(rec, this.status, Math.round(performance.now() - t0));
});
}
} catch (e) {}
return originalSend.apply(this, arguments);
};
const originalFetch = window.fetch;
window.fetch = function(input, init) {
let rec = null;
try {
let url = '', method = 'GET', body = init && init.body;
if (typeof input === 'string') url = input;
else if (input instanceof Request) { url = input.url; method = input.method || 'GET'; }
else if (input instanceof URL) url = input.href;
if (init && init.method) method = init.method;
rec = track('Fetch', method.toUpperCase(), url, bodyOf(body));
} catch (e) {}
const p = originalFetch.apply(this, arguments);
if (rec) {
const t0 = performance.now();
// 挂旁路观察者,返回原 promise,不动调用方的链路
p.then(res => {
try { grabResponse(res, rec); } catch (e) {}
finish(rec, res.status, Math.round(performance.now() - t0));
}, err => {
rec.resBody = '[请求失败: ' + err + ']';
finish(rec, 0, Math.round(performance.now() - t0));
});
}
return p;
};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment