带悬浮面板的网络请求监听器
// ==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, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
}; // end initUI
// ============================================================
// renderLogs 代理
// ============================================================
function renderLogs() {
if (window.__nw_render__) window.__nw_render__();
}
// ============================================================
// DOM 就绪后初始化 UI
// ============================================================
if (document.body) {
initUI();
} else {
document.addEventListener('DOMContentLoaded', initUI);
}
})();