Created
July 22, 2026 04:03
-
-
Save lyrl/86c99a5d6ef36d308970af18258888f8 to your computer and use it in GitHub Desktop.
后台接口请求实时弹窗提醒
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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