// ==UserScript== // @name 网络调用查看器(查看调用) // @namespace http://tampermonkey.net/ // @license MIT // @version 0.1.0 // @description 油猴脚本,捕获并搜索页面所有接口请求,提供“查看调用”按钮与详细信息面板。 // @author hn // @match *://*/* // @run-at document-start // @grant none // @downloadURL https://update.greasyfork.icu/scripts/553843/%E7%BD%91%E7%BB%9C%E8%B0%83%E7%94%A8%E6%9F%A5%E7%9C%8B%E5%99%A8%EF%BC%88%E6%9F%A5%E7%9C%8B%E8%B0%83%E7%94%A8%EF%BC%89.user.js // @updateURL https://update.greasyfork.icu/scripts/553843/%E7%BD%91%E7%BB%9C%E8%B0%83%E7%94%A8%E6%9F%A5%E7%9C%8B%E5%99%A8%EF%BC%88%E6%9F%A5%E7%9C%8B%E8%B0%83%E7%94%A8%EF%BC%89.meta.js // ==/UserScript== (function() { 'use strict'; const LOG_PREFIX = '[TM-NetworkInspector]'; const MAX_KEEP = 2000; // 最多保留 2000 条,防止内存过大 const IS_TOP = window === window.top; // 单例守卫,防止重复注入与重复 UI if (window.__TMNI_INSTALLED) { console.warn(LOG_PREFIX, 'Script already installed in this frame, skip.'); return; } window.__TMNI_INSTALLED = true; const state = { requests: [], // {id, type, url, method, requestHeaders, requestBody, requestBodyText, startTime, endTime, duration, status, statusText, responseHeaders, responseBody, responseBodyText} expanded: new Set(), search: '', nextId: 1, ui: { shadowRoot: null, btn: null, panel: null, list: null, searchInput: null, clearBtn: null, statusTextEl: null, }, }; // 工具函数 function headersToObject(headers) { const obj = {}; try { if (headers && typeof headers.forEach === 'function') { headers.forEach((v, k) => { obj[k] = v; }); } else if (headers && typeof headers === 'object') { Object.entries(headers).forEach(([k, v]) => { obj[k] = v; }); } } catch (e) { console.warn(LOG_PREFIX, 'headersToObject error:', e); } return obj; } function formatJSONMaybe(str) { try { const obj = JSON.parse(str); return JSON.stringify(obj, null, 2); } catch(e) { return str; } } function isJSONContentType(ct) { return ct && ct.toLowerCase().includes('application/json'); } function parseByContentType(text, contentType) { if (text == null) return null; if (isJSONContentType(contentType)) { try { return JSON.parse(text); } catch(e) { return text; } } // 对于非 JSON,直接返回原始文本 return text; } function serializeBody(body, headers) { if (!body) return { text: null }; try { if (typeof body === 'string') { return { text: body }; } if (body instanceof URLSearchParams) { return { text: body.toString() }; } if (body instanceof FormData) { const obj = {}; for (const [k, v] of body.entries()) { obj[k] = v && typeof v === 'object' && 'name' in v ? `[File:${v.name}]` : v; } return { text: JSON.stringify(obj) }; } if (body instanceof Blob) { return { text: `[Blob:${body.type}, size=${body.size}]` }; } // 对象尝试 JSON 序列化 return { text: JSON.stringify(body) }; } catch (e) { return { text: String(body) }; } } function nowTs() { return Date.now(); } function formatTime(ts) { try { const d = new Date(ts); const pad = n => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`; } catch(e) { return String(ts); } } // 顶层收集,子 frame 转发到顶层 function addRequest(item) { if (!IS_TOP) { try { window.top.postMessage({ __TMNI_MSG: 'TMNI_CAPTURE', item }, '*'); console.log(LOG_PREFIX, 'Forwarded to top window:', item.method, item.url); } catch (e) { console.warn(LOG_PREFIX, 'postMessage to top failed', e); } return; } state.requests.push(item); if (state.requests.length > MAX_KEEP) { state.requests.splice(0, state.requests.length - MAX_KEEP); } console.log(LOG_PREFIX, 'Captured', item.type, item.method, item.url, `status=${item.status}`, `duration=${item.duration}ms`); renderList(); } // 拦截 fetch function hookFetch() { if (!window.fetch) return; if (window.fetch && window.fetch.__TMNI_WRAPPED) { console.info(LOG_PREFIX, 'Fetch already hooked, skip.'); return; } const originalFetch = window.fetch; const wrapped = async function(input, init) { let reqUrl = '', reqMethod = 'GET', reqHeaders = {}, reqBodyText = null; const startTime = nowTs(); const id = state.nextId++; try { if (input instanceof Request) { reqUrl = input.url; reqMethod = input.method || (init && init.method) || 'GET'; reqHeaders = headersToObject(input.headers); try { const cloneReq = input.clone(); const txt = await cloneReq.text(); reqBodyText = formatJSONMaybe(txt); } catch (e) { reqBodyText = null; } } else { reqUrl = typeof input === 'string' ? input : String(input); reqMethod = (init && init.method) || 'GET'; reqHeaders = headersToObject((init && init.headers) || {}); const ser = serializeBody(init && init.body, reqHeaders); reqBodyText = ser.text ? formatJSONMaybe(ser.text) : null; } console.log(LOG_PREFIX, `fetch start [${id}]`, reqMethod, reqUrl); } catch (e) { console.warn(LOG_PREFIX, 'fetch pre-extract error', e); } try { const resp = await originalFetch.call(this, input, init); const endTime = nowTs(); let respText = null; let respJsonOrText = null; let respHeadersObj = {}; try { const clone = resp.clone(); respText = await clone.text(); respJsonOrText = parseByContentType(respText, resp.headers.get('content-type')); respHeadersObj = headersToObject(resp.headers); } catch (e) { respText = null; respJsonOrText = null; } addRequest({ id, type: 'fetch', url: reqUrl, method: reqMethod, requestHeaders: reqHeaders, requestBody: reqBodyText ? parseByContentType(reqBodyText, 'application/json') : reqBodyText, requestBodyText: reqBodyText, startTime, endTime, duration: endTime - startTime, status: resp.status, statusText: resp.statusText, responseHeaders: respHeadersObj, responseBody: respJsonOrText, responseBodyText: respText, }); return resp; } catch (e) { const endTime = nowTs(); addRequest({ id, type: 'fetch', url: reqUrl, method: reqMethod, requestHeaders: reqHeaders, requestBody: reqBodyText, requestBodyText: reqBodyText, startTime, endTime, duration: endTime - startTime, status: -1, statusText: String(e), responseHeaders: {}, responseBody: null, responseBodyText: null, }); console.error(LOG_PREFIX, 'fetch error', e); throw e; } }; wrapped.__TMNI_WRAPPED = true; window.fetch = wrapped; console.info(LOG_PREFIX, 'Fetch hooked'); } // 拦截 XHR function hookXHR() { const XHR = window.XMLHttpRequest; if (!XHR) return; if (XHR.prototype.open && XHR.prototype.open.__TMNI_WRAPPED) { console.info(LOG_PREFIX, 'XHR already hooked, skip.'); return; } const open = XHR.prototype.open; const send = XHR.prototype.send; const setRequestHeader = XHR.prototype.setRequestHeader; XHR.prototype.open = function(method, url, async, user, password) { this.__tmni = this.__tmni || {}; this.__tmni.method = method; this.__tmni.url = url; this.__tmni.requestHeaders = {}; this.__tmni.startTime = nowTs(); this.addEventListener('loadend', () => { try { const id = (this.__tmni && this.__tmni.id) || (state.nextId++); const endTime = nowTs(); const respHeadersRaw = this.getAllResponseHeaders ? this.getAllResponseHeaders() : ''; const respHeadersObj = {}; if (respHeadersRaw) { respHeadersRaw.trim().split(/\r?\n/).forEach(line => { const idx = line.indexOf(':'); if (idx > -1) { const k = line.slice(0, idx).trim().toLowerCase(); const v = line.slice(idx + 1).trim(); respHeadersObj[k] = v; } }); } const ct = respHeadersObj['content-type'] || ''; const txt = (this.responseType && this.responseType !== 'text') ? null : this.responseText; let respParsed = null; if (txt != null) { respParsed = parseByContentType(txt, ct); } else if (this.response && typeof this.response === 'string') { respParsed = parseByContentType(this.response, ct); } else { respParsed = this.response || null; } addRequest({ id, type: 'xhr', url: this.__tmni.url, method: this.__tmni.method, requestHeaders: this.__tmni.requestHeaders || {}, requestBody: this.__tmni.bodyText || null, requestBodyText: this.__tmni.bodyText || null, startTime: this.__tmni.startTime, endTime, duration: endTime - (this.__tmni.startTime || endTime), status: this.status, statusText: this.statusText, responseHeaders: respHeadersObj, responseBody: respParsed, responseBodyText: txt, }); } catch (e) { console.warn(LOG_PREFIX, 'XHR loadend handler error', e); } }); return open.apply(this, arguments); }; XHR.prototype.setRequestHeader = function(header, value) { try { this.__tmni = this.__tmni || {}; this.__tmni.requestHeaders = this.__tmni.requestHeaders || {}; this.__tmni.requestHeaders[header] = value; } catch (e) {} return setRequestHeader.apply(this, arguments); }; XHR.prototype.send = function(body) { try { const ser = serializeBody(body, this.__tmni && this.__tmni.requestHeaders); this.__tmni.bodyText = ser.text ? formatJSONMaybe(ser.text) : null; console.log(LOG_PREFIX, `xhr start`, this.__tmni.method, this.__tmni.url); } catch (e) { console.warn(LOG_PREFIX, 'XHR send body serialize error', e); } return send.apply(this, arguments); }; XHR.prototype.open.__TMNI_WRAPPED = true; console.info(LOG_PREFIX, 'XHR hooked'); } // 拦截 sendBeacon(常用于埋点/心跳,无法获取响应) function hookBeacon() { try { const nav = navigator; if (!nav || typeof nav.sendBeacon !== 'function') return; if (nav.sendBeacon && nav.sendBeacon.__TMNI_WRAPPED) { console.info(LOG_PREFIX, 'Beacon already hooked, skip.'); return; } const original = nav.sendBeacon.bind(nav); const wrapped = function(url, data) { const startTime = nowTs(); const id = state.nextId++; let bodyText = null; try { const ser = serializeBody(data, {}); bodyText = ser.text ? formatJSONMaybe(ser.text) : null; } catch(e) {} const strUrl = typeof url === 'string' ? url : String(url); console.log(LOG_PREFIX, 'beacon start', strUrl); const ok = original(url, data); const endTime = nowTs(); addRequest({ id, type: 'beacon', url: strUrl, method: 'POST', requestHeaders: {}, requestBody: bodyText, requestBodyText: bodyText, startTime, endTime, duration: endTime - startTime, status: ok ? 0 : -1, // 无响应,0 表示已发送,-1 失败 statusText: ok ? 'sent' : 'failed', responseHeaders: {}, responseBody: null, responseBodyText: null, }); return ok; }; wrapped.__TMNI_WRAPPED = true; nav.sendBeacon = wrapped; console.info(LOG_PREFIX, 'Beacon hooked'); } catch (e) { console.warn(LOG_PREFIX, 'Beacon hook error', e); } } // 初次安装钩子 hookFetch(); hookXHR(); hookBeacon(); // 钩子自愈:若业务代码覆盖 fetch/XHR,则自动重挂钩 setInterval(() => { try { if (window.fetch && !window.fetch.__TMNI_WRAPPED) { console.warn(LOG_PREFIX, 'Fetch replaced by page code, re-hooking'); hookFetch(); } if (window.XMLHttpRequest && window.XMLHttpRequest.prototype && !window.XMLHttpRequest.prototype.open.__TMNI_WRAPPED) { console.warn(LOG_PREFIX, 'XHR open replaced by page code, re-hooking'); hookXHR(); } if (navigator && navigator.sendBeacon && !navigator.sendBeacon.__TMNI_WRAPPED) { console.warn(LOG_PREFIX, 'Beacon replaced by page code, re-hooking'); hookBeacon(); } } catch (e) { console.warn(LOG_PREFIX, 'Hook watchdog error', e); } }, 2000); // 构建 UI(按钮 + 面板) function buildUI() { // 仅顶层窗口显示 UI,子 frame 不创建面板与按钮 if (!IS_TOP) return; if (document.getElementById('tmni-container')) { console.info(LOG_PREFIX, 'UI already exists, skip building again.'); return; } const container = document.createElement('div'); container.id = 'tmni-container'; container.style.position = 'fixed'; container.style.zIndex = '2147483647'; container.style.top = '0'; container.style.left = '0'; container.style.width = '0'; container.style.height = '0'; document.documentElement.appendChild(container); const shadow = container.attachShadow({ mode: 'open' }); state.ui.shadowRoot = shadow; const style = document.createElement('style'); style.textContent = ` .tmni-btn { position: fixed; right: 16px; bottom: 16px; background: #1677ff; color: #fff; border-radius: 20px; padding: 8px 12px; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); cursor: pointer; user-select: none; } .tmni-panel { position: fixed; right: 24px; top: 80px; width: 640px; height: 70vh; background: #fff; border: 1px solid #e5e5e5; box-shadow: 0 12px 24px rgba(0,0,0,0.2); border-radius: 8px; display: none; flex-direction: column; overflow: hidden; } .tmni-panel.visible { display: flex; } .tmni-header { background: #f7f8fa; padding: 8px; display: flex; gap: 8px; align-items: center; border-bottom: 1px solid #eee; cursor: move; } .tmni-title { font-weight: 600; color: #333; } .tmni-search { flex: 1; } .tmni-search input { width: 100%; padding: 6px 8px; border: 1px solid #d0d0d0; border-radius: 6px; outline: none; } .tmni-clear { padding: 6px 10px; background: #ff4d4f; color: #fff; border: none; border-radius: 6px; cursor: pointer; } .tmni-close { padding: 6px 10px; background: #999; color: #fff; border: none; border-radius: 6px; cursor: pointer; } .tmni-status { color: #888; font-size: 12px; } .tmni-list { flex: 1; overflow: auto; padding: 8px; background: #fff; } .tmni-item { border-bottom: 1px dashed #eee; padding: 6px 4px; } .tmni-row { display: flex; align-items: center; gap: 8px; cursor: pointer; } .tmni-badge { font-size: 12px; padding: 2px 6px; border-radius: 4px; color: #fff; } .tmni-badge.ok { background: #52c41a; } .tmni-badge.err { background: #ff4d4f; } .tmni-type { font-size: 11px; padding: 1px 4px; border-radius: 3px; background: #e6f7ff; color: #1677ff; border: 1px solid #91caff; } .tmni-type.beacon { background: #fff2e8; color: #fa541c; border: 1px solid #ffbb96; } .tmni-pin-new { display: flex; align-items: center; font-size: 12px; color: #666; cursor: pointer; user-select: none; } .tmni-url { flex: 1; font-size: 13px; color: #1677ff; word-break: break-all; cursor: pointer; position: relative; } .tmni-url:hover { text-decoration: underline; } .tmni-url:active::after { content: '已复制'; position: absolute; right: -60px; top: 0; background: rgba(0,0,0,0.7); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; } .tmni-time { font-size: 12px; color: #666; } .tmni-detail { padding: 6px; background: #fafafa; border-radius: 6px; margin-top: 6px; } .tmni-detail pre { max-height: 240px; overflow: auto; background: #fff; padding: 6px; border: 1px solid #eee; border-radius: 6px; } `; shadow.appendChild(style); const btn = document.createElement('div'); btn.className = 'tmni-btn'; btn.textContent = '查看调用'; shadow.appendChild(btn); const panel = document.createElement('div'); panel.className = 'tmni-panel'; panel.innerHTML = `