油猴脚本 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 在同源页面(同协议+域名+端口)下持久存储,浏览器关闭后数据不丢失,只有手动清除浏览器数据时才会清空。