浏览器 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 级别)。

Playwright 接管本地 Chrome:四种方式对比

Playwright 默认下载并使用自带的 Chromium。想用本机装的 Google Chrome(自动化登录时保留 cookie、复用插件),有四种方式,各自适用场景不同。 方式一:channel = "chrome" 最省事——channel: "chrome" 告诉 Playwright 用系统装的稳定版 Chrome: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: browser = p.chromium.launch(channel="chrome", headless=False) page = browser.new_page() page.goto("https://example.com") input("回车退出") browser.close()优点:跨平台一行搞定 局限:不会用你日常那个 profile,不带 cookie、不带插件其它可选 channel:msedge、chrome-beta、chrome-dev、msedge-beta。 方式二:executable_path 指定路径 Chrome 装在非默认路径,或者想用 Chromium 兼容分发(Brave、Edge、Vivaldi): Windows: browser = p.chromium.launch( executable_path=r"C:\Program Files\Google\Chrome\Application\chrome.exe", headless=False, )macOS: browser = p.chromium.launch( executable_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless=False, )Linux: browser = p.chromium.launch( executable_path="/usr/bin/google-chrome", headless=False, )跟方式一一样,不带 cookie / 插件——只是换了浏览器可执行文件。 方式三:launchPersistentContext 复用 profile 想让 Playwright 用日常那个 Chrome 用户目录(复用登录态、书签、扩展): browser = p.chromium.launch_persistent_context( user_data_dir=r"C:\Users\<你>\AppData\Local\Google\Chrome\User Data", channel="chrome", headless=False, ) page = browser.pages[0] if browser.pages else browser.new_page() page.goto("https://example.com")注意:这个方式要求 Chrome 没在运行,否则报: Failed to launch: browser closed. Profile is already in use解法:关掉所有 Chrome 窗口再跑 或者复制一份 profile 出来单独用:xcopy "C:\...\User Data" "D:\pw-profile" /E /I想只用某个 Profile(不是 Default): browser = p.chromium.launch_persistent_context( user_data_dir=r"D:\pw-profile", args=["--profile-directory=Profile 1"], channel="chrome", headless=False, )方式四:CDP 连接已启动的 Chrome(推荐) 方式三最大的痛点是"Chrome 必须关闭"。想在 Chrome 正常开着的情况下让 Playwright 接管它,走 CDP(Chrome DevTools Protocol)。 第一步:以 debug 模式启动 Chrome,指定端口: "C:\Program Files\Google\Chrome\Application\chrome.exe" ^ --remote-debugging-port=9222 ^ --user-data-dir="D:\pw-chrome-profile"--user-data-dir 指向一个专用目录,避免和日常 Chrome 抢占用。 第二步:Playwright 连上去: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222") ctx = browser.contexts[0] page = ctx.pages[0] if ctx.pages else ctx.new_page() page.goto("https://example.com")优势:Chrome 一直开着,做完自动化 Playwright 断开就行 保留登录态、插件、cookie 可以边脚本操作边人工插手(做半自动化)生产爬虫、账号自动登录场景基本用这个。 四种方式对比方式 Chrome 需关闭 保留 profile 使用场景channel="chrome" 否 否 简单自动化,无需登录态executable_path=... 否 否 用其它 Chromium 分发launch_persistent_context 是 是 一次性用现有 profileconnect_over_cdp (CDP) 否 是 推荐:常驻 Chrome + 脚本联动顺带:无痕、隐身、下载路径 无痕模式: browser = p.chromium.launch(channel="chrome", args=["--incognito"])指定下载目录: context = browser.new_context(accept_downloads=True) page = context.new_page() async with page.expect_download() as info: await page.click("a#download") download = await info.value await download.save_as("D:/downloads/file.zip")忽略证书错误(内网自签证书): context = browser.new_context(ignore_https_errors=True)一句话总结 普通自动化用 channel="chrome"、要复用登录态用 CDP 连接(先手动启动 Chrome + --remote-debugging-port=9222)。launch_persistent_context 只在 Chrome 关闭时才能用。

Playwright 使用本地已安装的 Chrome:channel、executablePath 与 CDP 连接

Playwright 默认使用自带的 Chromium 浏览器。以下四种方式可以改用本地安装的 Google Chrome。 方法一:channel='chrome'(最简单) 需要本机已安装 Google Chrome。 Python: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: browser = p.chromium.launch(channel="chrome", headless=False) page = browser.new_page() page.goto("https://example.com") browser.close()JavaScript: const { chromium } = require('playwright');const browser = await chromium.launch({ channel: 'chrome', headless: false }); const page = await browser.newPage(); await page.goto('https://example.com'); await browser.close();方法二:指定 executablePath Python(各平台路径): # Windows executable_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"# macOS executable_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"# Linux executable_path = "/usr/bin/google-chrome"browser = p.chromium.launch(executable_path=executable_path, headless=False)方法三:launchPersistentContext(复用登录状态) 使用已登录账号、Cookie、插件的 Chrome 用户数据目录: Python: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: context = p.chromium.launch_persistent_context( r"C:\Users\用户名\AppData\Local\Google\Chrome\User Data", channel="chrome", headless=False, ) page = context.pages[0] or context.new_page() page.goto("https://example.com")注意:Chrome 运行时会独占用户目录,使用此方法前需要关闭所有 Chrome 窗口,否则报错: Profile is already in use方法四:connectOverCDP(连接已启动的 Chrome) 先启动 Chrome 并开启远程调试端口(不影响正常使用): # Windows "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222# macOS /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222然后 Playwright 连接: Python: browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222") page = browser.contexts[0].pages[0] page.goto("https://example.com")JavaScript: const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); const page = browser.contexts()[0].pages()[0];这种方式完全复用当前 Chrome 的登录状态、Cookie 和扩展,且不独占用户目录。适合需要手动操作与自动化混合使用的场景。 方案对比方法 复用登录 Chrome 需关闭 保留插件channel='chrome' ✗ 否 ✗executablePath ✗ 否 ✗launchPersistentContext ✓ 是 ✓connectOverCDP ✓ 否 ✓

JS 逻辑赋值运算符:||= / &&= / ??= 用法和区别

ES2021 引入了三个逻辑赋值运算符:||=、&&=、??=。这三个符号都是"条件赋值"的简写,但触发条件各不相同,混淆了容易出 bug。 ||=(逻辑或赋值) 只有左侧为 falsy 时才赋值: a ||= b // 等价于 a = a || b // 即:if (!a) a = blet x = null; x ||= "default"; console.log(x); // "default"let y = 0; y ||= 100; console.log(y); // 100 ← 0 是 falsy,会被覆盖会触发赋值的值:null、undefined、false、0、''、NaN 典型用途:缓存计算结果 function checkIsRowVisible(data) { viewportRect ||= scrollContainer.getBoundingClientRect(); // 第一次为 undefined,执行右侧并缓存 // 之后 viewportRect 有值,跳过 const rowEl = data._rowEl.getBoundingClientRect(); return rowEl.bottom >= viewportRect.top && rowEl.top <= viewportRect.bottom; }??=(空值合并赋值) 只有左侧为 null 或 undefined 时才赋值: a ??= b // 等价于 a = a ?? b // 即:if (a === null || a === undefined) a = blet x = null; x ??= "default"; console.log(x); // "default"let y = 0; y ??= 100; console.log(y); // 0 ← 0 不是 null/undefined,不会被覆盖??= 比 ||= 更"精准":0、false、'' 都不会触发赋值。 典型用途:对象属性的惰性初始化 user.preferences ??= {}; user.preferences.theme ??= "dark";&&=(逻辑与赋值) 只有左侧为 truthy 时才赋值: a &&= b // 等价于 a = a && b // 即:if (a) a = blet arr = [1, 2, 3]; arr &&= arr.map(x => x * 2); console.log(arr); // [2, 4, 6]let empty = null; empty &&= [1, 2, 3]; console.log(empty); // null ← null 是 falsy,不赋值典型用途:只在值存在时更新 element &&= element.querySelector(".item"); // element 为 null 时不执行,避免报错三者对比运算符 触发赋值条件 不触发的值||= 左侧为 falsy truthy 值??= 左侧为 null/undefined 0, false, '', NaN 等&&= 左侧为 truthy falsy 值let a = 0; a ||= 1; // a = 1 (0 是 falsy) a = 0; a ??= 1; // a = 0 (0 不是 null/undefined) a = 0; a &&= 1; // a = 0 (0 是 falsy,不赋值)let b = 5; b ||= 1; // b = 5 (5 是 truthy,不赋值) b ??= 1; // b = 5 (5 不是 null/undefined,不赋值) b &&= 10; // b = 10 (5 是 truthy,赋值)选哪个想要"没有值就用默认值",且 0 / false / '' 也算有效值 → 用 ??= 想要"空/假就用默认值" → 用 ||= 想要"有值才更新" → 用 &&=对于对象初始化类的场景,??= 更安全,不会意外覆盖 false 或 0 这类合法值。

JavaScript 逻辑赋值运算符:||=、&&=、??= 和可选链动态属性访问

逻辑或赋值 ||= ES2021 引入的 ||= 只在左侧是 falsy 时才赋值: viewportRect ||= scrollContainer.getBoundingClientRect();等价于: if (!viewportRect) { viewportRect = scrollContainer.getBoundingClientRect(); }常用于"懒初始化缓存":第一次访问时计算并保存,后续复用已有值,避免重复执行开销大的操作。 触发赋值的 falsy 值:null、undefined、false、0、''、NaN。 空值合并赋值 ??= ??= 只在左侧是 null 或 undefined 时才赋值: config.timeout ??= 5000;等价于: if (config.timeout === null || config.timeout === undefined) { config.timeout = 5000; }||= 与 ??= 的区别 let x = 0; x ||= 10; console.log(x); // 10 ← 0 是 falsy,触发了赋值let y = 0; y ??= 10; console.log(y); // 0 ← 0 不是 nullish,不触发赋值场景 推荐变量为空对象/数组/字符串时初始化 ??=变量为任何假值时初始化 `变量已赋值则不覆盖 ??=(更安全)逻辑与赋值 &&= &&= 只在左侧是 truthy 时才赋值: user &&= { ...user, lastLogin: Date.now() }; // 仅当 user 存在时才更新可选链结合动态属性访问 ?.[] 是可选链加计算属性的组合: const currentValue = projection.user_property?.[attrDef.value];等价于: const currentValue = projection.user_property == null ? undefined : projection.user_property[attrDef.value];attrDef.value 是运行时计算的属性名,不能用 ?. 点语法(点语法只能接静态标识符),必须用方括号形式 ?.[]。 // 深层链式安全访问 const val = obj?.a?.b?.[dynamicKey]?.value; // 任何一级为 null/undefined 都返回 undefined,不会抛错