// ==UserScript== // @name 接口出参修改器(Mock Response Modifier) // @namespace mock-response-modifier // @version 1.0.0 // @description 拦截页面 fetch/XHR 请求,按规则替换或修改接口出参,页面拿到的即为修改后的数据。点击悬浮球打开管理面板,支持最近请求列表一键选中接口。 // @match *://*/* // @run-at document-start // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @noframes false // @license MIT // ==/UserScript== /* ============================== 使用说明 ============================== * * 【基本操作】 * 1. 页面右下角蓝色 "M" 悬浮球 → 点击打开管理面板; * 2. 面板"最近请求"列表会记录页面发出的接口,点击某条即可自动填入 URL 和 Method; * (URL 填接口地址包含的"关键字"即可,子串匹配,如 /api/getDetail) * 3. 填入出参 JSON,选择模式,点"添加"; * 4. 触发该接口(无需刷新页面,规则实时生效),页面拿到的即为修改后的出参。 * * 【模式一:替换(replace) —— 完全伪造整个出参】 * - 效果:不再依赖真实响应,接口被拦截后直接返回你填写的 JSON,HTTP 状态码固定 200; * - 场景:接口报错/无权限/依赖其他服务时,伪造一份完整可用数据; * - 填写内容:完整的出参 JSON。 * - 示例:填 {"code":200,"msg":"success","data":{"name":"张三","score":100}} * → 页面收到 {"code":200,"msg":"success","data":{"name":"张三","score":100}} * (真实接口出参是什么、成功与否都不影响结果) * * 【模式二:合并(merge) —— 在真实出参基础上只改指定字段】 * - 效果:真实请求照常发出,拿到真实出参后,把"你填的字段"深合并覆盖进去,其余字段保留; * - 合并规则:对象递归合并;数组直接整体替换;基础类型(字符串/数字/布尔/null)直接覆盖; * - 场景:只改某几个字段做测试,其余真实字段保持不变; * - 填写内容:只需要填"要改的那部分" JSON。 * - 示例:真实出参 {"code":200,"data":{"name":"李四","age":20,"list":[1,2,3]}} * 填 {"data":{"name":"张三","list":[9]}} * → 页面得到 {"code":200,"data":{"name":"张三","age":20,"list":[9]}} * (age 保留真实值;list 数组被整体替换为 [9]) * * 【注意事项】 * - 修改后必须刷新页面重新触发接口(脚本在 document-start 注入,页面加载前就要生效); * - 规则保存在 Tampermonkey 脚本存储中(key: mock_rules_v1),全局共享、持久化,不随页面丢失; * - XHR 的 responseType 为 blob/arraybuffer 等非文本类型时不支持 merge 模式; * - 面板中可勾选启用/禁用单条规则,"改参"按钮可临时修改出参 JSON。 * * ===================================================================== */ (function () { 'use strict'; // 关键:劫持"页面上下文"的 fetch/XHR,而不是脚本沙箱里的。 // 有 @grant 时 Tampermonkey 运行在沙箱中,改 window.fetch 页面可能感知不到, // 必须改 unsafeWindow.fetch / unsafeWindow.XMLHttpRequest 才能真正生效。 const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // 日志前缀徽章样式:与悬浮球 "M" 一致的蓝底白字 const BADGE_CSS = 'background:#1677ff;color:#fff;padding:1px 5px;border-radius:3px;font-weight:bold;'; const TAG = '%c[出参修改器]%c '; console.log(TAG + '脚本已注入 document-start,开始劫持页面 fetch/XHR', BADGE_CSS, ''); /* ---------------- 规则存储 ---------------- * rule = { * id: string, * url: string, // URL 包含的关键字(支持子串匹配) * method: string, // HTTP 方法,'*' 表示任意 * mode: 'replace' | 'merge', * body: string, // 出参 JSON 字符串;replace 模式为完整出参,merge 模式为要覆盖的字段 * enabled: boolean, * delay: number // 模拟延迟 ms * } */ const RULES_KEY = 'mock_rules_v1'; const LOG_KEY = 'mock_req_log_v1'; let rules = load(RULES_KEY, []); let reqLog = load(LOG_KEY, []); // 最近请求 {url, method, time} function load(key, def) { try { const v = GM_getValue(key); return v ? JSON.parse(v) : def; } catch (e) { return def; } } function saveRules() { GM_setValue(RULES_KEY, JSON.stringify(rules)); } function saveLog() { if (reqLog.length > 50) reqLog = reqLog.slice(-50); GM_setValue(LOG_KEY, JSON.stringify(reqLog)); } function matchRule(url, method) { if (!url) return null; const m = (method || 'GET').toUpperCase(); for (const r of rules) { if (!r.enabled) continue; if (r.method !== '*' && r.method.toUpperCase() !== m) continue; if (!url.includes(r.url)) continue; try { JSON.parse(r.body); } catch (e) { continue; } // JSON 非法则跳过,放行真实请求 return r; } return null; } function deepMerge(target, patch) { if (Array.isArray(patch)) return patch; // 数组直接整体替换 if (patch !== null && typeof patch === 'object') { const out = (target !== null && typeof target === 'object' && !Array.isArray(target)) ? target : {}; for (const k of Object.keys(patch)) { out[k] = deepMerge(out[k], patch[k]); } return out; } return patch; // 基础类型直接覆盖 } const sleep = (ms) => (ms ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve()); /* ---------------- Hook fetch ---------------- */ const rawFetch = pageWindow.fetch; pageWindow.fetch = async function (input, init) { let url = ''; let method = 'GET'; if (typeof input === 'string') { url = input; method = (init && init.method) || 'GET'; } else if (input instanceof Request) { url = input.url; method = (init && init.method) || input.method; } method = (method || 'GET').toUpperCase(); reqLog.push({ url, method, time: Date.now() }); saveLog(); if (panel) renderLog(); const rule = matchRule(url, method); if (!rule) return rawFetch.apply(this, arguments); console.log(TAG + '已拦截 fetch:', BADGE_CSS, '', method, url, '-> 模式:', rule.mode); await sleep(rule.delay); if (rule.mode === 'replace') { return new Response(rule.body, { status: 200, statusText: 'OK', headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); } // merge 模式:先拿真实响应,再在真实数据上合并修改字段 const res = await rawFetch.apply(this, arguments); try { const realData = await res.clone().json(); const merged = deepMerge(realData, JSON.parse(rule.body)); console.log(TAG + 'fetch merge 结果:', BADGE_CSS, '', merged); // 注意:不要透传 res.headers,真实响应可能带 content-encoding 等头,用于构造新 Response 会出问题 return new Response(JSON.stringify(merged), { status: res.status, statusText: res.statusText, headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); } catch (e) { console.warn(TAG + 'merge 模式解析真实响应失败,放行原始响应', BADGE_CSS, '', e); return res; } }; /* ---------------- Hook XMLHttpRequest ---------------- */ const RawXHR = pageWindow.XMLHttpRequest; function PatchedXHR() { const xhr = new RawXHR(); let rule = null; const origOpen = xhr.open; xhr.open = function (method, url) { method = (method || 'GET').toUpperCase(); reqLog.push({ url: String(url), method, time: Date.now() }); saveLog(); if (panel) renderLog(); rule = matchRule(String(url), method); return origOpen.apply(this, arguments); }; // 构造时注册,保证先于页面自己的 handler 执行 xhr.addEventListener('readystatechange', function () { if (xhr.readyState === 4 && rule) { try { patchXhrResponse(xhr, rule); } catch (e) { console.warn(TAG + 'XHR 出参修改失败', BADGE_CSS, '', e); } } }); return xhr; } // 保留常量(DONE 等),页面代码常用 PatchedXHR.UNSENT = RawXHR.UNSENT; PatchedXHR.OPENED = RawXHR.OPENED; PatchedXHR.HEADERS_RECEIVED = RawXHR.HEADERS_RECEIVED; PatchedXHR.LOADING = RawXHR.LOADING; PatchedXHR.DONE = RawXHR.DONE; pageWindow.XMLHttpRequest = PatchedXHR; function patchXhrResponse(xhr, rule) { // merge 模式:基于真实出参合并;真实出参解析失败则不处理 let body = rule.body; if (rule.mode === 'merge') { let raw = null; try { if (xhr.responseType === '' || xhr.responseType === 'text' || xhr.responseType === 'json') raw = xhr.responseText; } catch (e) { /* 跨域等情况可能抛错 */ } if (!raw) return; // responseType 为 blob/arraybuffer 等非文本类型暂不支持合并 let realData; try { realData = JSON.parse(raw); } catch (e) { return; } body = JSON.stringify(deepMerge(realData, JSON.parse(rule.body))); } // responseType 为 'json' 时页面读的是对象,必须返回解析后的对象而不是字符串 const isJsonType = xhr.responseType === 'json'; let jsonVal = null; try { jsonVal = JSON.parse(body); } catch (e) { /* body 不合法时 response 保持字符串 */ } const props = { responseText: { configurable: true, get: () => body }, response: { configurable: true, get: () => (isJsonType && jsonVal !== null ? jsonVal : body) }, }; if (rule.mode === 'replace') { props.status = { configurable: true, get: () => 200 }; props.statusText = { configurable: true, get: () => 'OK' }; } for (const [k, v] of Object.entries(props)) { try { Object.defineProperty(xhr, k, v); } catch (e) { /* 已有同名实例属性则跳过 */ } } console.log(TAG + '已修改 XHR 出参:', BADGE_CSS, '', rule.method, rule.url, '-> 模式:', rule.mode, isJsonType ? jsonVal : body); } /* ---------------- 悬浮面板 UI ---------------- */ let panel = null; let open = false; function whenBodyReady(cb) { if (document.body) return cb(); const t = setInterval(() => { if (document.body) { clearInterval(t); cb(); } }, 200); } whenBodyReady(() => { const ball = document.createElement('div'); ball.textContent = 'M'; Object.assign(ball.style, { position: 'fixed', right: '20px', bottom: '20px', zIndex: 999999, width: '36px', height: '36px', borderRadius: '50%', cursor: 'pointer', background: '#1677ff', color: '#fff', fontSize: '16px', fontWeight: 'bold', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 8px rgba(0,0,0,.3)', userSelect: 'none', }); ball.title = '接口出参修改器'; ball.addEventListener('click', togglePanel); document.body.appendChild(ball); }); function togglePanel() { if (open) { panel.remove(); panel = null; open = false; return; } open = true; panel = document.createElement('div'); Object.assign(panel.style, { position: 'fixed', right: '20px', bottom: '64px', zIndex: 999999, width: '460px', maxHeight: '70vh', overflow: 'auto', background: '#fff', color: '#333', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 4px 16px rgba(0,0,0,.25)', fontSize: '13px', padding: '12px', fontFamily: 'monospace', }); panel.innerHTML = `