Showing Posts From

油猴脚本

给页面注入一个快捷键面板:数字键快速点击标签

做标注 / 高频点击类工作,鼠标点半天累。给页面注入一个悬浮面板 + 快捷键,数字键触发对应元素的 click()——效率能翻几倍。 直接粘贴到浏览器 Console (() => { const items = [ { key: "1", title: "标签A" }, { key: "2", title: "标签B" }, { key: "3", title: "标签C" }, { key: "4", title: "标签D" }, { key: "5", title: "标签E" }, ]; function findEl(title) { return document.querySelector(`span.label[title="${title}"]`); } function clickByTitle(title) { const el = findEl(title); if (!el) return console.warn(`未找到: ${title}`); // 框架页面用 dispatchEvent 更稳 el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window, })); console.log(`点击: ${title}`); } // 面板 const panel = document.createElement("div"); panel.style.cssText = ` position: fixed; top: 80px; right: 20px; width: 220px; background: rgba(0,0,0,.85); color: #fff; padding: 12px; border-radius: 12px; z-index: 999999; font-family: system-ui; font-size: 14px; `; panel.innerHTML = "<b>快捷键面板</b>"; function render() { panel.querySelectorAll(".item").forEach(e => e.remove()); for (const it of items) { const row = document.createElement("div"); row.className = "item"; const ok = !!findEl(it.title); row.style.cssText = ` display:flex; justify-content:space-between; margin:6px 0; padding:4px 6px; border-radius:6px; background:${ok ? "rgba(0,255,0,.12)" : "rgba(255,0,0,.12)"}; cursor:pointer; `; row.innerHTML = `<span>[${it.key}] ${it.title}</span><span>${ok ? "🟢" : "🔴"}</span>`; row.onclick = () => clickByTitle(it.title); panel.appendChild(row); } const tip = document.createElement("div"); tip.className = "item"; tip.style.cssText = "margin-top:10px;color:#aaa;font-size:12px;"; tip.textContent = "F8 显示/隐藏"; panel.appendChild(tip); } render(); document.body.appendChild(panel); // 键盘监听 document.addEventListener("keydown", e => { if (e.key === "F8") { panel.style.display = panel.style.display === "none" ? "block" : "none"; return; } // 输入框里不触发 const t = e.target; if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t?.isContentEditable) return; const item = items.find(x => x.key === e.key); if (item) { e.preventDefault(); clickByTitle(item.title); } }); // 动态渲染的页面定时刷新状态 setInterval(render, 2000); })();功能:右上角浮 220px 宽面板 每项显示 [键] 标题 + 找到/未找到指示 按 1~5 触发对应点击 F8 切换显示/隐藏 面板项本身也能点击 输入框获得焦点时不拦截(不然打字触发 bug)打包成油猴脚本 // ==UserScript== // @name 快捷键面板 // @match https://your-site.com/* // @run-at document-end // @grant none // ==/UserScript==(function () { // ... 上面的代码 })();document-end 保证 DOM 建好之后再挂面板。SPA 页面切路由后可能面板还在但按键失效——加一个 MutationObserver 监听页面变化重新 render 即可。 复杂点击的兼容 某些 Vue / React 页面直接 el.click() 触发不到框架事件(因为它们监听的是自己合成的事件,而不是原生 DOM 事件),改用: el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window, }));冒泡是关键——很多框架的 delegation 是在文档根节点上监听的。 更进一步的想法 如果是标注工具,还能加:Q/W/E/R/T/Y 左手键位(比数字键位好按) 组合标签(Shift+1 = 同时点 1 和 3) 自动跳下一张图(连点两个标签 → 自动 next) 今日已标数统计 本地保存自定义键位(localStorage) CSV 导出统计放在 localStorage 里存 key 配置: const config = JSON.parse(localStorage.getItem("hotkey-config") || "[]"); if (config.length) items.splice(0, items.length, ...config);一句话总结 Console 里几十行 JS 就能注入一个快捷键面板,dispatchEvent 兼容 Vue/React 框架点击、输入框内不拦截。做高频标注 / 批量操作时效率提升明显。

油猴脚本加 IndexedDB 本地备份:掉线也不丢数据

在油猴脚本里做数据备份,通常只往本地服务器 POST 一份。但服务器挂掉或网络问题时,这批数据就丢了。IndexedDB 可以作为本地兜底——不管服务器是否正常,先把数据存本地一份。 IndexedDB 基础封装 const DB_NAME = "localBackupDB"; const STORE_NAME = "backups"; let db;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains(STORE_NAME)) { d.createObjectStore(STORE_NAME, { keyPath: "id" }); } }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e); }); }function saveToIndexedDB(fileName, content) { if (!db) return; const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); store.put({ id: fileName, content, timestamp: Date.now() }); }function getAllBackups() { return new Promise((resolve, reject) => { if (!db) return resolve([]); const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = (e) => reject(e); }); }集成到备份队列 已有服务端备份逻辑时,在 enqueueSave 里同时存 IndexedDB: const savedSet = new Set(); const queue = []; let timer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; savedSet.add(fileName); // 本地 IndexedDB 兜底(不管服务端是否正常) saveToIndexedDB(fileName, content); queue.push({ fileName, content }); if (!timer) timer = setTimeout(flushQueue, 1000); }function flushQueue() { const batch = [...queue]; queue.length = 0; timer = null; batch.forEach(({ fileName, content }) => saveToServer(fileName, content)); }这样即使 saveToServer 失败,IndexedDB 里还有一份。 可视化面板 挂到 window.showPanel,在控制台调用即可弹出历史备份列表: function showPanel() { if (document.getElementById("__backup_panel")) return; const panel = document.createElement("div"); panel.id = "__backup_panel"; Object.assign(panel.style, { position: "fixed", top: "20px", right: "20px", width: "320px", maxHeight: "420px", overflowY: "auto", background: "#fff", border: "1px solid #ccc", boxShadow: "0 0 10px rgba(0,0,0,.3)", padding: "12px", zIndex: 99999, fontSize: "12px", fontFamily: "monospace" }); panel.innerHTML = `<div style="font-weight:bold;margin-bottom:8px">📦 历史备份</div> <div id="__backup_list"></div> <button id="__backup_close" style="margin-top:10px">关闭</button>`; document.body.appendChild(panel); document.getElementById("__backup_close").onclick = () => panel.remove(); getAllBackups().then(backups => { const list = document.getElementById("__backup_list"); backups.sort((a, b) => b.timestamp - a.timestamp); backups.forEach(item => { const row = document.createElement("div"); row.style.cssText = "display:flex;justify-content:space-between;margin-bottom:4px"; const name = document.createElement("span"); name.textContent = item.id; name.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"; const btn = document.createElement("button"); btn.textContent = "恢复"; btn.onclick = () => { try { console.log("🔄 恢复备份:", item.id); console.log(JSON.parse(item.content)); } catch { console.log(item.content); } }; row.appendChild(name); row.appendChild(btn); list.appendChild(row); }); }); }window.showPanel = showPanel;使用时在浏览器控制台输入 showPanel() 即可查看。 初始化调用 initDB() .then(() => console.log("IndexedDB 已就绪")) .catch(console.error);注意事项IndexedDB 存储受同源策略限制,每个域名独立,跨域无法访问 浏览器私有模式下 IndexedDB 数据在关闭标签页后会清除 存储上限一般是磁盘空间的 50% 左右(浏览器决定),备份大量 JSON 不用担心空间 用 store.put()(不是 add()),同 key 会覆盖而不报错 可以在 DevTools → Application → IndexedDB 直接查看和清理数据

油猴脚本 XHR Hook 数据落地:IndexedDB 本地备份 + 本地服务双写

场景 油猴脚本 Hook XHR/fetch 后捕获接口数据,需要持久化保存。常见方案是 POST 到本机 http://127.0.0.1:PORT,但本地服务可能未启动或崩溃,导致丢失数据。 双写方案:先写 IndexedDB(浏览器本地,无外部依赖),再异步 POST 到本地服务,两者互为备份。 IndexedDB 封装 const DB_NAME = 'XhrBackup'; const STORE_NAME = 'records';let db = null;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const store = e.target.result.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); store.createIndex('fileName', 'fileName', { unique: false }); }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e.target.error); }); }function saveToIDB(fileName, content) { return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.add({ fileName, content, savedAt: Date.now() }); tx.oncomplete = resolve; tx.onerror = (e) => reject(e.target.error); }); }队列去重与批量 flush 频繁触发(如分页列表接口每页都调用)时需要去重,避免重复保存同一数据: const savedSet = new Set(); const queue = []; let flushTimer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; // 去重 savedSet.add(fileName); queue.push({ fileName, content }); // 1 秒防抖,批量 flush if (!flushTimer) { flushTimer = setTimeout(flushQueue, 1000); } }function flushQueue() { const batch = [...queue]; queue.length = 0; flushTimer = null; batch.forEach(item => saveItem(item.fileName, item.content)); }双写实现 async function saveItem(fileName, content) { // 1. 先写 IndexedDB(不依赖本地服务) try { await saveToIDB(fileName, content); console.log('✅ IDB 保存成功:', fileName); } catch (e) { console.error('❌ IDB 保存失败:', fileName, e); } // 2. 再 POST 到本地服务(可选) const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); fetch('http://127.0.0.1:8099/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, content }), signal: controller.signal }) .then(res => { clearTimeout(timeout); if (!res.ok) throw new Error(`HTTP ${res.status}`); console.log('✅ 服务保存成功:', fileName); }) .catch(err => { clearTimeout(timeout); if (err.name === 'AbortError') { console.warn('⚠️ 服务超时,已保留 IDB 备份:', fileName); } else { console.error('❌ 服务保存失败(IDB 已备份):', fileName, err.message); } }); }XHR Hook 集成 const originOpen = XMLHttpRequest.prototype.open; const originSend = XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._method = method; this._url = url; return originOpen.apply(this, [method, url, ...rest]); };XMLHttpRequest.prototype.send = function (body) { this.addEventListener('readystatechange', function () { if (this.readyState !== 4) return; // 匹配目标接口 URL,按业务修改正则 if (/\/api\/data\/\d+/.test(this._url)) { const url = new URL(this._url, location.origin); const id = url.searchParams.get('id') || Date.now(); enqueueSave(`data/${id}_response.json`, this.responseText); } }); return originSend.apply(this, arguments); };读取 IndexedDB 中已保存的数据 function getAllRecords() { return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const req = store.getAll(); req.onsuccess = () => resolve(req.result); req.onerror = (e) => reject(e.target.error); }); }// 控制台查看所有备份 getAllRecords().then(records => { console.log(`共 ${records.length} 条备份`); console.table(records.map(r => ({ id: r.id, fileName: r.fileName, savedAt: new Date(r.savedAt).toLocaleString() }))); });初始化入口 // 油猴脚本主入口 (async function () { await initDB(); // 在这里挂 XHR Hook console.log('备份系统就绪'); })();IndexedDB 在同源页面(同协议+域名+端口)下持久存储,浏览器关闭后数据不丢失,只有手动清除浏览器数据时才会清空。

浏览器 XHR Hook + IndexedDB:油猴脚本实现网络请求本地备份

在油猴脚本或浏览器控制台中,可以通过覆写 XMLHttpRequest.prototype 方法拦截页面发出的所有 XHR 请求,配合本地服务实现数据自动备份。 XHR Hook 核心实现 const originOpen = XMLHttpRequest.prototype.open; const originSend = XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._method = method; this._url = url; return originOpen.apply(this, [method, url, ...rest]); };XMLHttpRequest.prototype.send = function (body) { this.addEventListener('readystatechange', function () { if (this.readyState === 4) { // 匹配目标接口 if (/\/api\/data/.test(this._url)) { enqueueSave(`data_${Date.now()}.json`, this.responseText); } } }); return originSend.apply(this, arguments); };readyState === 4 表示请求完成,this.responseText 是响应体。 Set 去重 + 队列节流 高频请求下需要防止重复保存和并发过多: const savedSet = new Set(); const queue = []; let timer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; // 去重 savedSet.add(fileName); queue.push({ fileName, content }); // 1秒后批量发送 if (!timer) { timer = setTimeout(flushQueue, 1000); } }function flushQueue() { const batch = [...queue]; queue.length = 0; timer = null; batch.forEach(item => saveToServer(item.fileName, item.content)); }保存到本地 HTTP 服务(带超时) function saveToServer(fileName, content) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 5000); return fetch('http://127.0.0.1:8099/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, content }), signal: controller.signal }) .then(res => { clearTimeout(id); return res; }) .catch(err => console.error('保存失败:', fileName, err)); }IndexedDB 兜底备份 当本地服务不可用时,IndexedDB 可以作为浏览器内持久化存储的备份: const DB_NAME = 'localBackupDB'; const STORE_NAME = 'backups'; let db;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains(STORE_NAME)) { d.createObjectStore(STORE_NAME, { keyPath: 'id' }); } }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e); }); }function saveToIndexedDB(fileName, content) { if (!db) return; const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).put({ id: fileName, content, timestamp: Date.now() }); }在 enqueueSave 中同时写入: function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; savedSet.add(fileName); saveToIndexedDB(fileName, content); // 本地先存一份 queue.push({ fileName, content }); if (!timer) { timer = setTimeout(flushQueue, 1000); } }读取 IndexedDB 备份 function getAllBackups() { return new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readonly'); const req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = () => resolve(req.result); }); }getAllBackups().then(records => { records.forEach(r => console.log(r.id, r.timestamp)); });IndexedDB 在同源页面刷新后仍然保留数据,是浏览器端容量最大的持久化存储方案(通常限额数百 MB 到 GB 级别)。

油猴脚本获取 __webpack_require__:webpackChunk.push 注入比 Hook call 更稳

为什么 Hook Function.prototype.call 不好用 一种常见思路是拦截 webpack 模块执行时的 call: const oldCall = Function.prototype.call;Function.prototype.call = function (...args) { if (this.name == '84686') { webpackRequire = args[3]; } return oldCall.apply(this, args); };这个方法有两个核心问题:webpack 模块函数通常没有名字,this.name 是 '' 或 anonymous,而不是模块 ID,所以 this.name == '84686' 几乎永远不成立。性能灾难:现代前端框架(React、Vue、Webpack Runtime)每秒调用 call 数以万次,全部经过你的 hook,页面直接卡死。另外,如果 webpack 已经执行完才开始 hook,Function.prototype.call 根本拿不到任何东西——模块早就加载完了。 正确方案:向 webpackChunk 注入入口模块 webpack 5 会在 window 上挂一个全局数组,名字通常是 webpackChunk 加项目名前缀。向这个数组 push 一个特殊的 chunk,可以在 runtime 函数里拿到 __webpack_require__: // 找到 webpackChunk 数组 const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));if (chunkKey) { let webpackRequire; window[chunkKey].push([ [Symbol()], // chunk ID(用 Symbol 避免冲突) {}, // 模块定义(空) function (require) { webpackRequire = require; // 在这里拿到 __webpack_require__ } ]); console.log(webpackRequire); // 现在可以用 webpackRequire(模块ID) 访问任意模块 }这个方法有效的原因:即使 webpack 已经执行完,也可以通过这种方式从现有的 chunk 数组里拿到 __webpack_require__,因为 webpack 会立即处理新 push 进来的 chunk。 在 webpack 执行前 Hook(如果需要监听所有模块) 如果你的目标是在每个模块加载时做点什么(比如修改特定模块的导出),需要在 webpack 执行之前 hook push 方法: const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));const oldPush = window[chunkKey].push;window[chunkKey].push = function (...args) { const runtime = args[0][2]; // 拦截 runtime 函数 if (typeof runtime === 'function') { args[0][2] = function (__webpack_require__) { // 保存引用 window._webpackRequire = __webpack_require__; return runtime.apply(this, arguments); }; } return oldPush.apply(this, args); };用 webpack_require 访问模块 拿到 __webpack_require__ 后,可以直接访问任意 webpack 模块: // 按模块 ID 获取模块导出 const lodash = webpackRequire(96486); const mtopModule = webpackRequire(82122);console.log(lodash); console.log(mtopModule.default);模块 ID 可以在浏览器 Sources 面板里搜索特征字符串定位,也可以遍历 webpackRequire.m(模块注册表)查找: Object.keys(webpackRequire.m).forEach(id => { const src = webpackRequire.m[id].toString(); if (src.includes('targetFunction')) { console.log('found at module id:', id); } });油猴脚本注意:@run-at 时机 在 Tampermonkey 里使用时,需要根据目的选择合适的 @run-at:时机 说明document-start 最早,适合在 webpack 执行前 hook pushdocument-end 适合页面加载完后注入 chunk 拿引用如果 webpack 在 DOMContentLoaded 之前执行完,用 document-start + push hook;如果只是要访问模块,用 document-end + push 注入即可。

JavaScript 函数 Hook:拦截 fetch、XHR、Function.prototype.call 与 setInterval

Hook 的核心模式 JavaScript 函数 Hook 的通用模式: const original = window.targetFunction;window.targetFunction = function (...args) { // 前置处理 console.log('before call:', args); const result = original.apply(this, args); // 后置处理 console.log('after call:', result); return result; };apply(this, args) 保持原始的 this 上下文,...args 透传所有参数。 Hook fetch const rawFetch = window.fetch;window.fetch = async function (...args) { console.log('[fetch]', args[0]); const response = await rawFetch.apply(this, args); // clone() 避免 body 被消费后无法再读 const clone = response.clone(); clone.text().then(body => { console.log('[fetch response]', body.slice(0, 200)); }); return response; };Hook XMLHttpRequest const RawXHR = window.XMLHttpRequest;window.XMLHttpRequest = function () { const xhr = new RawXHR(); const rawOpen = xhr.open.bind(xhr); xhr.open = function (method, url, ...args) { console.log('[XHR]', method, url); return rawOpen(method, url, ...args); }; const rawSend = xhr.send.bind(xhr); xhr.send = function (body) { xhr.addEventListener('load', function () { console.log('[XHR response]', xhr.responseText.slice(0, 200)); }); return rawSend(body); }; return xhr; };Hook Function.prototype.call(监听所有函数调用) (function () { const rawCall = Function.prototype.call; Function.prototype.call = function (...args) { // this 就是被调用的函数 if (this.name === 'targetFunction') { console.log('[call hook]', this.name, args); } return rawCall.apply(this, args); }; })();影响范围极大,生产环境谨慎使用,建议只在调试时临时注入。 Hook 某个对象的方法 const original = window.api.submit;window.api.submit = function (...args) { console.log('[hook api.submit]', args); const result = original.apply(this, args); console.log('[result]', result); return result; };Hook setInterval(拦截 debugger) 部分网页在 setInterval 里运行 debugger 语句来阻止调试: (() => { const rawSetInterval = window.setInterval; const rawClearInterval = window.clearInterval; const fakeTimers = new Set(); let fakeId = 1_000_000; function shouldBlock(fn) { try { let code = typeof fn === 'function' ? Function.prototype.toString.call(fn) : String(fn); if (/\bdebugger\b/.test(code)) { console.warn('[blocked debugger interval]'); return true; } } catch (e) {} return false; } window.setInterval = function (fn, delay, ...args) { if (shouldBlock(fn)) { fakeTimers.add(++fakeId); return fakeId; } return rawSetInterval(fn, delay, ...args); }; window.clearInterval = function (id) { if (fakeTimers.has(id)) { fakeTimers.delete(id); return; } return rawClearInterval(id); }; })();fakeId 返回一个虚假的 timer ID,避免调用 clearInterval 时报错。 Hook WebAssembly.instantiate const rawInstantiate = WebAssembly.instantiate;WebAssembly.instantiate = async function (...args) { const result = await rawInstantiate.apply(this, args); // 拦截导出函数 const exports = result.instance.exports; for (const key of Object.keys(exports)) { if (typeof exports[key] === 'function') { const original = exports[key]; exports[key] = function (...callArgs) { console.log(`[wasm] ${key}(`, callArgs, ')'); return original.apply(this, callArgs); }; } } return result; };油猴中的注入时机 // ==UserScript== // @run-at document-start // ==/UserScript==document-start 是最早的时机,在页面任何脚本执行之前运行,适合 hook 全局 API。如果用 document-end 或 document-idle,页面原始代码可能已经运行了。

用 Proxy hook setInterval 拦截反调试 debugger

初版的问题 常见的 setInterval debugger 拦截写法有几个缺陷: (function(){ window._setInterval = window.setInterval; // 污染全局 window.setInterval = function (fn, delay, ...args) { let code = typeof fn === "function" ? fn.toString() : fn; if (code.includes("debugger")) { return null; // ❌ 返回 null 会让部分站点报错 } return _setInterval(fn, delay, ...args); // ❌ _setInterval 在闭包外查找可能丢失 }; })()问题汇总:fn.toString() 可被重写 getter 绕过 返回 null 时,站点调用 clearInterval(null) 或存 timer ID 会报错 _setInterval 挂到 window 上,污染全局命名空间 没有 hook clearInterval,假 ID 泄漏改进版:Proxy + 假 timer ID (() => { const rawSetInterval = window.setInterval; const rawClearInterval = window.clearInterval; const fakeTimers = new Set(); let fakeId = 1_000_000; function shouldBlock(fn) { try { let code = ""; if (typeof fn === "function") { code = Function.prototype.toString.call(fn); // 绕过 toString 重写 } else if (typeof fn === "string") { code = fn; } if (!code) return false; if (/\bdebugger\b/.test(code)) { console.warn("拦截到 debugger interval:", code); return true; } if (/while\s*\(\s*true\s*\)/.test(code)) { console.warn("拦截到死循环 interval:", code); return true; } } catch (e) { console.error("检测 interval 出错:", e); } return false; } window.setInterval = new Proxy(rawSetInterval, { apply(target, thisArg, args) { const [fn] = args; if (shouldBlock(fn)) { const id = fakeId++; fakeTimers.add(id); return id; // 返回真实格式的假 ID,不返回 null } return Reflect.apply(target, thisArg, args); } }); window.clearInterval = new Proxy(rawClearInterval, { apply(target, thisArg, args) { const [id] = args; if (fakeTimers.has(id)) { fakeTimers.delete(id); // 吞掉对假 ID 的 clearInterval 调用 return; } return Reflect.apply(target, thisArg, args); } }); console.log("setInterval hook 已启动"); })();关键设计点 为什么用 Function.prototype.toString.call(fn) 而不是 fn.toString() 站点可以重写 fn.toString 或者在函数对象上挂 getter: const fn = () => {}; fn.toString = () => "() => {}"; // 伪造源码而 Function.prototype.toString 直接从函数底层获取源码,无法被实例属性覆盖。 为什么返回假 timer ID 而不是 null 很多站点会存 timer ID 用于后续 clearInterval: const tid = setInterval(check, 100); // 之后 clearInterval(tid);如果 tid === null,clearInterval(null) 在部分引擎下会抛异常,或者日志里产生噪声。返回 1_000_000 以上的数字既像真实 ID,又不会和浏览器分配的 ID 冲突。 为什么同步 hook clearInterval 假 ID 进入 fakeTimers Set 之后,站点持有这个 ID 并在后续调用 clearInterval(fakeId)。如果不 hook,这个调用会传给原生 clearInterval,可能误清真实定时器(原生 ID 碰巧相同)或产生错误。 为什么用 Proxy 而不是直接赋值 Proxy 保留了 length、name 等函数元信息,instanceof 检测也更接近原生。部分站点会用 typeof setInterval === "function" 或特征检测来判断是否被 hook,Proxy 比直接赋值的闭包函数更难被识别。 可扩展检测项 如果需要更宽泛的反调试拦截,可以在 shouldBlock 里补充: // 检测 setTimeout debugger(同理 hook setTimeout) // 检测 constructor/eval 执行 debugger 字符串 if (/\bFunction\s*\(/.test(code)) return true; if (/\beval\s*\(/.test(code)) return true;现代站点已经较少用 setInterval(debugger) 这种低级方式,更多见的是混淆后的字节码或 WebAssembly 版本的反调试,这类情况 setInterval hook 就无法覆盖了。

JS 模板字符串里包含反引号:三种不转义方案

模板字符串内部出现反引号时,必须转义成 \`。内容量大或来自外部时,转义很烦。 方案一:数组 join(最稳) 完全不需要考虑转义,任意字符都行: const sql = [ "SELECT * FROM `users`", "WHERE `status` = 'active'", "AND `name` LIKE `%test%`", ].join('\n');大段 HTML、SQL、Prompt 推荐这种写法——可读性好,又不怕特殊字符。 方案二:String.raw(不处理转义序列) const pattern = String.raw`\d+\.\d+`; // 等价于 "\\d+\\.\\d+" // 不需要手动双反斜杠在 String.raw 里,反斜杠不被解释为转义符,但反引号仍然需要转义,所以它主要解决的是 \n、\t 这类转义序列问题,而不是反引号嵌套问题。 const py = String.raw` print("hello") # 反引号仍需 \` `;方案三:外部文件(大段文本首选) // Vite / webpack 环境 import template from './prompt.txt?raw'; import html from './template.html?raw';// Node.js 环境 const template = fs.readFileSync('./prompt.txt', 'utf8');AI Prompt、HTML 模板、大段代码存外部文件,完全不需要处理引号或转义问题,也方便单独编辑和版本追踪。 油猴脚本里 hook window 属性 油猴脚本里经常需要拦截页面的某个全局变量或 API,Object.defineProperty 比整体 Proxy 更可靠: // hook 单个属性 const rawFetch = window.fetch;Object.defineProperty(window, 'fetch', { configurable: true, get() { return function(...args) { console.log('fetch called:', args[0]); return rawFetch.apply(this, args); }; } });整体 new Proxy(window, ...) 只是创建了一个代理对象,不会改变其他代码里访问的真实 window,所以通常没有实际效果。Object.defineProperty 是直接修改全局对象,才能真正拦截到。 hook document.cookie const cookieSetter = Document.prototype.__lookupSetter__('cookie'); const cookieGetter = Document.prototype.__lookupGetter__('cookie');Object.defineProperty(Document.prototype, 'cookie', { configurable: true, get() { return cookieGetter.call(this); }, set(val) { console.log('cookie set:', val); cookieSetter.call(this, val); } });安全读取 cookie 值 正则匹配 cookie 时,结尾分号可能没有(最后一项),用 [^;]* 比 .*?; 更稳: function getCookie(name) { const match = document.cookie.match( new RegExp(`${name}=([^;]*)`) ); return match ? match[1] : undefined; }[^;]* 含义:匹配零个或多个不是分号的字符,不管 cookie 是不是最后一项都能正确提取。

油猴脚本阻止页面脚本加载:hook appendChild 与 createElement

执行时机:document-start 油猴脚本的执行时机由 @run-at 控制,document-start 是最早的时机,在浏览器开始解析 HTML 前执行: // ==UserScript== // @name Block Scripts // @match *://*/* // @run-at document-start // ==/UserScript==此时 document.body 还不存在,但 DOM API 已可用,可以提前 hook 原型方法。 方案1:hook appendChild 拦截动态 script // ==UserScript== // @name Block Dynamic Scripts // @match *://example.com/* // @run-at document-start // ==/UserScript==(function () { const rawAppend = Element.prototype.appendChild; Element.prototype.appendChild = function (el) { if (el.tagName === 'SCRIPT') { const src = el.src || '(inline)'; console.log('[blocked]', src); // 返回 el 但不真正追加,阻止加载 return el; } return rawAppend.call(this, el); }; })();rawAppend 保存原始方法,避免无限递归。只拦截 SCRIPT 标签,其他元素正常追加。 方案2:hook createElement,在 src 赋值时拦截 部分页面通过 createElement('script') 创建后再设置 src: const rawCreate = document.createElement.bind(document);document.createElement = function (tag) { const el = rawCreate(tag); if (tag === 'script') { Object.defineProperty(el, 'src', { configurable: true, set(v) { if (v.includes('ads.example.com')) { console.log('[blocked src]', v); return; } el.setAttribute('src', v); }, get() { return el.getAttribute('src') || ''; } }); } return el; };Object.defineProperty 劫持 src 的 setter,在 src 被赋值时决定是否放行。 方案3:MutationObserver 监控已插入的 script 上面两种方案针对动态插入。对于 HTML 中静态的 <script> 标签,可以用 MutationObserver 在插入前禁用: const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.tagName === 'SCRIPT' && node.src.includes('tracker.js')) { node.type = 'text/blocked'; // 改掉 type,浏览器不执行 console.log('[neutralized]', node.src); } } } });observer.observe(document.documentElement, { childList: true, subtree: true });将 type 改为非 JavaScript MIME 类型,浏览器不会执行该脚本但 DOM 中仍有该节点。 三种方案对比方案 拦截时机 适用场景hook appendChild 追加时 动态 document.body.appendChild(script)hook createElement src src 赋值时 el.src = '...' 模式MutationObserver 节点插入 DOM 后 任意方式插入,包括 innerHTML实际页面可能混合使用多种方式,建议组合 hook。 注意事项document-start 时 document.body 为 null,不要访问 DOM 元素 hook 原型方法影响所有调用,测试时先在控制台验证 浏览器扩展的 Content Script 比油猴更底层,如需更强的拦截能力可考虑开发扩展

油猴脚本虚拟文件输入 + FileReader 封装

在油猴脚本里读取本地文件,思路是:动态创建隐藏的 <input type="file">,触发点击,再用 FileReader 读取内容。 封装函数 /** * 选择文件并读取内容 * @param {Object} options * @param {string} [options.accept] - 文件类型限制,如 ".json,.txt" * @param {string} [options.readAs="text"] - text | dataURL | arrayBuffer * @param {string} [options.encoding="utf-8"] - 文本编码(仅 text 模式有效) * @returns {Promise<{file: File, content: any}>} */ function selectFileAndRead(options = {}) { const { accept = "", readAs = "text", encoding = "utf-8" } = options; return new Promise((resolve, reject) => { let input = document.getElementById("__file_input__"); if (!input) { input = document.createElement("input"); input.type = "file"; input.id = "__file_input__"; input.style.display = "none"; document.body.appendChild(input); } input.accept = accept; input.onchange = () => { const file = input.files[0]; if (!file) { reject(new Error("未选择文件")); return; } const reader = new FileReader(); reader.onload = (e) => resolve({ file, content: e.target.result }); reader.onerror = () => reject(new Error("读取失败")); switch (readAs) { case "dataURL": reader.readAsDataURL(file); break; case "arrayBuffer": reader.readAsArrayBuffer(file); break; default: reader.readAsText(file, encoding); } }; // 必须重置 value,否则选同一个文件不会触发 change 事件 input.value = ""; input.click(); }); }使用示例 读取文本文件 selectFileAndRead({ accept: ".txt,.json" }).then(({ file, content }) => { console.log(file.name, content); });读取 JSON 并解析 selectFileAndRead({ accept: ".json" }) .then(({ content }) => JSON.parse(content)) .then((data) => console.log("JSON 数据:", data)) .catch((err) => console.error(err));读取图片为 base64 selectFileAndRead({ accept: "image/*", readAs: "dataURL" }).then( ({ content }) => { const img = document.createElement("img"); img.src = content; document.body.appendChild(img); } );async/await 版 document.addEventListener("keydown", async (e) => { if (e.ctrlKey && e.key === "u") { try { const { file, content } = await selectFileAndRead({ accept: ".json" }); const config = JSON.parse(content); applyConfig(config); } catch (err) { console.error(err); } } });关键坑 浏览器安全限制:input.click() 必须在用户事件(点击、键盘)的同步调用链里触发,不能在 setTimeout 或 Promise 回调里调用,否则会被拦截。 同一文件无法重复选择:input.value = "" 在 click() 前必须执行,否则选同一个文件第二次不会触发 change 事件。 中文乱码:Windows 下生成的文件有时是 GBK 编码: selectFileAndRead({ encoding: "gbk" });DOM 污染:函数复用同一个 __file_input__ 元素,不会重复创建,避免 DOM 堆积导致的性能问题。 在油猴脚本中的完整示例 // ==UserScript== // @name 配置文件导入 // @namespace http://tampermonkey.net/ // @version 1.0 // @match https://example.com/* // @grant none // ==/UserScript==(function () { "use strict"; // 插入导入按钮 const btn = document.createElement("button"); btn.textContent = "导入配置"; btn.style.cssText = "position:fixed;top:10px;right:10px;z-index:9999"; document.body.appendChild(btn); btn.addEventListener("click", async () => { const { content } = await selectFileAndRead({ accept: ".json" }); const config = JSON.parse(content); console.log("已加载配置:", config); }); // ... selectFileAndRead 函数定义 ... })();

JS 动态注入 CSS 样式:setStyle 函数

在油猴脚本或 Chrome 扩展里需要动态修改页面样式时,直接写 element.style.xxx 只能改行内样式,无法用 CSS 选择器批量设置。更好的方案是动态创建 <style> 标签。 setStyle 函数 function setStyle(id, css) { let style = document.getElementById(id); if (!style) { style = document.createElement("style"); style.id = id; document.head.appendChild(style); } style.textContent = css; }用 id 做唯一标识——如果该 id 的 <style> 已存在就直接更新内容,不会重复插入。 使用示例 // 隐藏广告 setStyle("ad-blocker", ` .ad-banner, .sidebar-ad, [class*="advertisement"] { display: none !important; } `);// 强制暗色模式 setStyle("dark-mode", ` body { background: #1a1a1a !important; color: #e0e0e0 !important; } a { color: #7eb8f7 !important; } `);// 动态更新(复用同一个 style 元素) setStyle("custom-theme", `body { font-size: ${fontSize}px; }`);为什么不用 innerHTML // ❌ 安全风险:可能被 XSS 利用 style.innerHTML = css;// ✅ 推荐:textContent 不会解析 HTML style.textContent = css;textContent 只设置文本内容,不会解析 HTML 标签,比 innerHTML 更安全。 移除样式 function removeStyle(id) { const el = document.getElementById(id); if (el) el.remove(); }removeStyle("dark-mode");油猴脚本中的注意事项 油猴脚本默认在 document-end 注入,此时 document.head 已存在。如果设置了 @run-at document-start,需要等待 head 可用: // @run-at document-start function setStyle(id, css) { const target = document.head || document.documentElement; let style = document.getElementById(id); if (!style) { style = document.createElement("style"); style.id = id; target.appendChild(style); } style.textContent = css; }document.documentElement(即 <html>)在 document-start 阶段就已存在,可以作为 fallback。 GM_addStyle(油猴内置 API) Tampermonkey 提供了 GM_addStyle 作为快捷方式: // @grant GM_addStyle GM_addStyle(` .target-element { color: red; } `);不需要去重处理,但无法动态更新——每次调用都会新增一个 <style> 标签。频繁更新时还是用自定义的 setStyle。

在浏览器里 hook setInterval:原理和五种写法

在浏览器里"hook" 某个内置函数,本质就是——把原函数偷出来,用你自己的函数替换它。setInterval 是最常见的 hook 目标:调试疯狂轮询、监控页面定时器、干掉反调试轮询。 一句话原理 const _origin = window.setInterval;window.setInterval = function (fn, delay, ...args) { // 你的自定义逻辑 return _origin(fn, delay, ...args); // 回到原函数 };_origin 是原函数引用,window.setInterval 是你替换后的新函数。之后所有 setInterval(...) 调用都会先经过你。 场景一:监控所有调用 (function () { const _origin = window.setInterval; window.setInterval = function (fn, delay, ...args) { console.log("setInterval:", { delay, fn: fn.toString().slice(0, 80) }); return _origin(fn, delay, ...args); }; })();用途:找出页面里到底谁在挂定时器。 场景二:按条件拦截 只关心 1 秒轮询: window.setInterval = function (fn, delay, ...args) { if (delay === 1000) { console.trace("1s interval:", fn); } return _origin(fn, delay, ...args); };console.trace 直接打出调用栈,能追到是哪段代码挂的。 场景三:阻止高频轮询 页面每 50ms 疯狂检查某个东西(典型反调试或广告轮询),CPU 直接吃满: window.setInterval = function (fn, delay, ...args) { if (delay < 500) { console.log("阻止高频 interval:", delay); return null; // 直接返回 null,不真的开定时器 } return _origin(fn, delay, ...args); };场景四:记录所有 ID 方便统一清 (function () { const _origin = window.setInterval; const ids = new Set(); window.setInterval = function (fn, delay, ...args) { const id = _origin(fn, delay, ...args); ids.add(id); return id; }; window.__clearAll = () => { ids.forEach(clearInterval); ids.clear(); console.log("全部清除"); }; })();在 devtools 里手动 __clearAll() 就能一键把当前页面所有定时器停了。 场景五:油猴脚本里 hook 必须 @run-at document-start——否则页面脚本先跑,等你 hook 上去已经晚了: // ==UserScript== // @name hook setInterval // @match https://example.com/* // @run-at document-start // @grant none // ==/UserScript==(function () { const _origin = window.setInterval; window.setInterval = function (fn, delay, ...args) { console.log("intercepted:", delay); return _origin.call(window, fn, delay, ...args); }; })();注意用 _origin.call(window, ...)——某些浏览器的 setInterval 严格要求 this === window,不 bind 会抛 Illegal invocation。 常见反 hook 手法 页面可能这样绕过你: // 提前保存原始引用 const _real = window.setInterval.bind(window); _real(fn, 100);页面代码保存的是你的 hook 前的原函数,你就拦不到 或者 iframe 里跑,window 隔离 或者用 postMessage + Worker,压根不走主线程 setInterval对策:@run-at document-start 越早越好 同时 hook setTimeout / requestAnimationFrame——很多站点会混着用 iframe 里的定时器要另外注入用 Proxy 更隐蔽 上面的写法暴露痕迹(window.setInterval.toString() 会变),要更隐蔽: window.setInterval = new Proxy(window.setInterval, { apply(target, thisArg, args) { const [fn, delay] = args; console.log("intercepted:", delay); return Reflect.apply(target, thisArg, args); }, });Proxy 版本的 .toString() 还是原始的,页面用 toString().includes("[native code]") 检测更难发现。 一句话总结 hook = 存原函数引用 + 替换 window 属性。油猴里必须 run-at document-start,进阶用 Proxy 更隐蔽。同一套模板套 setTimeout / fetch / XMLHttpRequest 也照样能用。