从页面脚本到 DevTools:Hister qutebrowser 集成方案的技术演进之路 从页面脚本到 DevToolsHister qutebrowser 集成方案的技术演进之路【免费下载链接】histerYour own search engine项目地址: https://gitcode.com/GitHub_Trending/hi/histerHister 通过浏览器扩展把用户浏览过的渲染后页面自动收录进私有的搜索引擎。qutebrowser 不支持 WebExtensions于是把这条路变成了对 CORS、CSP、opaque response、表单提交与 CSRF 保护的一次次试探最终落在 Qt WebEngine DevTools 协议上。本文以该集成历程为主线逐段分析每一条失败路径的技术原因并深入源码讲解最终 companion 的架构、时序参数与安全边界读完你可以完整复现hister companion qutebrowser的搭建过程并理解它为什么选择了浏览器渲染、DevTools 暴露、Go 进程投递这条边界。不可妥协的需求保存用户真正看到的页面浏览器集成的意义不是保存 URL而是让 Hister 拿到用户实际看到的渲染结果。现代站点大量内容由 JavaScript 生成初始 HTTP 响应常常只是一个空壳数据由前端加载、认证状态作用于 DOM、组件随用户交互不断变化。脚本可以直接读取最终 DOMconst page { title: document.title, text: document.body?.innerText ?? , html: document.documentElement?.innerHTML ?? , url: window.location.href, };与之对照curl、Go HTTP 客户端或服务端爬虫都意味着重新下载页面第二次请求可能拿到不同内容、丢失认证态、触发限流或被反爬页拦截更重要的是丢弃了浏览器里已经存在的渲染文档。因此任何可接受的方案都必须保持这条链路且不做第二次下载rendered browser DOM → Hister document这也是贯穿全文的评判标准后续每一次尝试是否失败、为什么失败都可以用这一条需求来解释。尝试一原生 Userscripts——传输没问题触发是问题qutebrowser 的原生 userscript 系统可以运行外部程序并传入当前页面信息。外部程序不受浏览器 CORS 和页面 CSP 限制理论上可以直接向 Hister 提交数据。真正的障碍是触发时机没有可靠的原生 userscript 钩子在页面加载完成或 DOM 变化时自动执行。用户可以通过命令或按键手动调用脚本但 Hister 的浏览器集成要求在浏览过程中自动捕获页面。从另一个进程轮询同样不干净——该进程仍然需要发现标签页、检测导航、观察 DOM 变化、取回当前渲染内容等于在浏览器之外重新实现一遍浏览器事件接口。结论是原生 userscript 可以支撑一个手动保存此页命令却无法支撑自动化的浏览历史捕获。尝试二Greasemonkey 中的 fetch——CORS、preflight 与 CSP 的三重夹击第一次实现从浏览器扩展的页面提取代码生成了一个 Greasemonkey 脚本读取渲染 DOM 后直接fetch到http://127.0.0.1:4433/api/add。问题随之而来qutebrowser 的 Greasemonkey 实现不提供特权跨源请求 API它的fetch仍然是普通页面请求受页面源、浏览器 CORS 实现和页面 CSP 的约束。几个技术细节值得展开不能伪造 Origin。Origin头归浏览器所有页面 JavaScript 无权替换。脚本无法伪装成 Hister 命令行客户端使用的受信hister://源。自定义头触发 preflight。Hister 用X-Access-Token头认证 API 请求见 client/client.go 中newRequest对Origin: hister://与X-Access-Token的设置。跨源请求携带自定义头属于非简单请求浏览器会先发OPTIONS预检服务端必须先批准页面源和自定义头真正的POST才会发出。即使服务端允许携带有效 token 的/api/addtoken 也只认证调用者无法绕过浏览器在服务端验证 token之前就做出的 CORS 决定。CSP 先行拦截。大量页面配置了类似策略Content-Security-Policy: default-src self未定义connect-src时以default-src兜底浏览器在 CORS 介入之前就拦掉了对本地 Hister 服务的请求。也就是说想归档的页面本身决定了归档请求是否被允许——这显然不是可靠的集成。为什么 curl 依然不够原生 userscript 调用curl可以设置期望的 Origin、携带 token、绕过浏览器 CORS 强制。但它依旧没有自动的页面加载/内容变化触发器。反过来Greasemonkey 脚本拥有事件和 DOM 访问能力页面 JavaScript 却无法随意启动外部进程独立运行的 curl 若要生效又必须重新下载页面。独立 curl 解决了传输这容易的一半却丢掉了自动触发 实时渲染 DOM这重要的一半。尝试三no-cors——名字会骗人下一个想法是fetch加mode: no-cors。这个名字容易误读它不等于忽略所有跨源保护而是构造一个受限请求其响应对调用脚本是 opaque不透明的。具体限制包括无法附加任意的X-Access-Token头请求必须停留在简单请求的定义内响应状态与正文对脚本不可见仍受页面 CSP 约束严格的connect-src依旧会拦截。更关键的是安全后果no-cors对普通网站 JavaScript 同样可用并不是 qutebrowser 的特殊能力。任何网站都可以向 localhost 上的服务发起简单请求。显式 bearer token 仍然可以认证动作但这意味着 Hister 永远不能把浏览器发来了这个请求当作请求来自自家集成的证据——no-cors本身不提供任何安全边界。把 token 塞进表单编码的请求体则直接通向下一个尝试把文档塞进 URL 让/api/add通过GET改状态则更糟——它会让端点被更多浏览器原语调用、把文档数据暴露在 URL 和日志里、破坏GET的语义等于为迁就受限传输而放弃重要的 API 边界。尝试四提交表单——走得更远警告也更多经典 HTML 表单可以提交跨源POST而无需 CORS 预检把渲染字段和 token 一起带上form methodpost actionhttp://127.0.0.1:4433/api/add input nameurl / input nametitle / input nametext / input namehtml / input namefavicon / input nameaccess_token / /form服务端可以从请求体取 token 并校验把显式密钥当作 CSRF 防护——与 session cookie 不同浏览器不会自动附带该 token。这个方案推进得最远也积累了最多的警告信号form-actionCSP 一票否决。表单提交受页面form-action策略管辖。常见的Content-Security-Policy: form-action self只允许提交到当前页面源任意网站的表单因此无法提交到本地 Hister 源更严格的form-action none直接禁止一切表单提交。这不是响应处理 bug 或 API 格式不匹配脚本无法绕过浏览器会忠实执行页面的决定。这是放弃表单方案的决定性原因。token 经过页面 DOM 通道。尝试过分离表单、闭合 shadow root 来降低暴露但任何构造都依赖隔离 JS 世界、共享 DOM 对象、页面变更观察、浏览器实现细节等微妙假设对一枚能修改整个 Hister 实例的 token 来说过于脆弱。导航行为需要掩盖。普通表单提交有浏览器导航行为Hister 返回406skip rule 拒绝或500索引失败时qutebrowser 可能导航到该响应。服务端被迫识别 Greasemonkey 提交并把一切结果替换成空204响应页面保住了状态信息也丢了——成功索引、策略拒绝、内部错误对脚本看起来完全一样。服务端改动不断累积为单个端点接受表单字段 token → 让该 token 绕过常规 CSRF 路径 → 从表单数据解码额外文档字段 → 识别特殊 Greasemonkey 客户端标记 → 捕获真实 handler 响应 → 替换为204。过程中甚至出现过冗余WriteHeader调用的告警。每个改动单独看都合理合起来却让浏览器集成把特殊的认证和响应语义深深压进服务端而且页面 CSP 仍可能整体拦截提交。这是集成边界放错了位置的明确信号。值得对照的是当前仓库的状态搜索整个代码库已找不到任何 Greasemonkey 特殊处理webui/website/src/content/posts/the-long-road-to-qutebrowser-support-in-hister.md之外零命中且 server/add_test.go 中的TestAddFormAccessTokenDoesNotAuthenticate明确验证表单里的 access_token 不再认证任何请求带Origin: https://unrelated.example的表单 POST 到/api/add直接返回403。服务端的 CORS 例外只授予受信扩展源——chrome-extension://cciilamhchpmbdnniabclekddabkifhb、moz-extension://*与safari-web-extension://*见 server/extension.go页面脚本不在其列。改变边界Qt WebEngine DevTools 方案突破点在于不再让被检查的页面负责投递。qutebrowser 基于 Qt WebEngine启用远程调试后会暴露 Chrome DevTools 协议可以报告标签页与导航、在页面中执行 JavaScript、并创建隔离的 JavaScript 世界。先关闭所有 qutebrowser 进程qutebrowser 可能复用已在运行的进程而旧进程不会继承后加命令里的调试设置这正是开发初期一次令人困惑的 connection refused 的根源再以回环调试端点启动QTWEBENGINE_REMOTE_DEBUGGING127.0.0.1:9222 qutebrowser然后在另一个终端运行hister companion qutebrowser此后页面不再向 Hister 发送任何东西只通过 DevTools 向 companion 提供内容Go 进程再用常规 Hister 客户端提交文档可以设置 access token 头、拿到真实 HTTP 状态、复用既有命令行与配置选项因为请求根本不源自页面CORS 与页面 CSP 都不适用。最重要的是companion 依然使用现有渲染 DOM不爬取、不重新下载页面。连接与隔离世界源码视角hister companion qutebrowser命令的定义位于 cmd/companion.go运行时通过qutebrowsercompanion.Run启动目标服务器来自全局--server-url、--token、--client-timeout选项。启动后连接流程cmd/companion/qutebrowser/cdp.go向 DevTools 端点的/json/version发起发现请求读取webSocketDebuggerUrl校验其为ws/wss并用用户配置的 host 覆盖 Chromium 通告的地址见下文的normalizeWebSocketURL这是安全设计不是便利性建立 WebSocket 连接readLoop按消息 ID 分发响应、按方法名分发事件事件缓冲容量 512。连接建立后cmd/companion/qutebrowser/companion.goTarget.setDiscoverTargets开启目标发现Target.getTargets枚举现有目标对每个type page的目标执行Target.attachToTargetflatten: true获得独立 session。随后configurePage依次执行Page.enable、Runtime.enable、Runtime.addBinding并在隔离世界hister-companion中安装脚本Page.addScriptToEvaluateOnNewDocument让后续每个新文档都自带观察者Page.createIsolatedWorldRuntime.evaluate为当前文档创建隔离执行上下文并立即安装观察者防止页面脚本篡改。提取时extractAndSubmitcompanion.go#L660-L734通过Runtime.evaluate在隔离世界执行提取表达式表达式与浏览器扩展的提取逻辑同源对照 webui/ext/src/modules/extract.ts 的extractPageState/extractPageData取title、body.innerText、去掉 hash 的window.location.href、documentElement.innerHTML与 favicon URL。隔离世界不共享全局变量页面脚本无法干扰提取。检测页面变化MutationObserver、绑定与调度单页应用可以在没有传统导航的情况下替换大部分内容。companion 在隔离世界安装一个轻量MutationObserver源码位于 companion.go#L596-L631观察attributes、characterData、childList、subtree。观察者不发送页面内容只调用 DevTools binding每次连接随机生成__histerCompanionChanged_hex名称通知 Go 进程有变化由 Go 进程重新提取。此外还监听load、hashchange、popstate并处理Page.frameNavigated、Page.loadEventFired、Page.navigatedWithinDocument、Page.lifecycleEventDOMContentLoaded、load、networkIdle等事件来安排提取companion.go#L200-L294。时序参数默认值见 cmd/companion/qutebrowser/options.go均有测试锁定见 companion_test.go 的TestDefaultPageTiming参数默认值含义InitialDelay1 秒新页面加载后多久提交首次捕获保证初版捕获快速Debounce10 秒DOM 变化后的静默期内容稳定后才提交更新MaxWait30 秒持续变化的页面最迟多久必须提交避免无限推迟RetryDelay30 秒提交失败后的重试间隔置 0 禁用重试ReconnectDelay3 秒DevTools 断线后的重连间隔调度的状态机比透过 opaque 响应猜测结果小得多初始计划拥有优先级不会被后续 DOM 变化推迟scheduleUpdate遇到高优先级初始定时器直接返回每次提取前用SHA-256(URL NUL 字节 HTML)计算指纹pageFingerprintcompanion.go#L736-L742内容未变则不再投递同一页面同一时刻只运行一次提取提取期间新到的更新标记为 pending提取结束后立即执行companion_test.go 的TestPendingUpdateRunsImmediatelyAfterExtraction验证了该行为。认证与失败处理token 永不进入页面最终方案中 access token 完全留在 Hister 进程内从不注入被检查页面或其 DOM。提交通过DocumentSubmitter接口调用AddDocumentJSON真正实现是 client/client.go 构造的Client会自动附加Origin: hister://、X-Access-Token与 User-Agent。companion 能拿到 API 的真实错误companion.go#L753-L778临时失败按RetryDelay重试406skip rule 拒绝与422敏感内容拒绝被视为已完成的决定而非瞬时故障不重试companion_test.go 的TestSubmitTreatsPolicyRejectionsAsCompleted与TestSubmitPropagatesUnexpectedHTTPFailure分别验证了两类路径qutebrowser 重启或调试连接消失时run循环以ReconnectDelay自动重连companion.go#L105-L123。这同时允许从服务端删除全部 Greasemonkey 特殊行为表单 token 不再绕过认证或 CSRF/api/add也不再需要把错误伪装成空成功响应。最终集成与 Hister 其他命令行客户端使用同一套安全模型。新的安全权衡DevTools 方案更干净但并非没有安全代价。调试端点对打开的标签页拥有广泛访问能力能连上它的人可能查看页面内容并控制浏览器因此只接受回环地址normalizeOptions强制 DevTools 主机必须是localhost或回环 IPoptions.go#L80-L85companion_test.go 的TestNormalizeOptionsRejectsRemoteDevTools验证了远程地址被拒重写通告的 WebSocket 地址normalizeWebSocketURL把 Chromium 通告的0.0.0.0等通配地址替换为用户选定的精确回环主机cdp.go#L135-L154companion_test.go 的TestBrowserWebSocketURLKeepsConfiguredHost覆盖端口永不暴露到网络favicon 同源限制页面控制 favicon URL若让 Go 进程任意抓取 URL等于在浏览器外制造了一个不必要的请求原语。companion 只下载与页面同源的 favicon重定向必须停留在同源CheckRedirect校验第一跳与当前 URL 同源并把响应大小限制在MaxFaviconBytes默认 1 MiBdata:URI 也有等价的编码长度限制companion.go#L780-L832。部分外部托管的 favicon 会被跳过这是刻意的取舍排除 Hister 自身页面与配置的 Hister base URL 同源且路径匹配的页面不采集isHisterPagecompanion.go#L744-L751避免把搜索引擎自身界面回灌进索引。把代码移出浏览器页面解决了 CORS 与 CSP 问题但没有消除定义谨慎信任边界的必要性。完整命令与配置速查最终架构一句话概括浏览器渲染页面 → DevTools 向本地 companion 暴露渲染 DOM → companion 通过常规认证的 Hister API 投递文档。每个组件只做自己擅长的事谁也不假装浏览器的安全控件不存在。常用启动组合# 终端一先关闭所有 qutebrowser 进程再以回环调试端点启动 QTWEBENGINE_REMOTE_DEBUGGING127.0.0.1:9222 qutebrowser # 终端二运行 companion读取配置中的 server.base_url 与 app.access_token hister companion qutebrowser远程服务器或显式配置时用全局选项覆盖目标hister --server-url http://127.0.0.1:4433 \ --token replace-with-app-access-token \ companion qutebrowsercompanion qutebrowser子命令的全部参数定义于 cmd/companion.go默认值来自DefaultOptions参数默认值说明--devtools-urlhttp://127.0.0.1:9222qutebrowser DevTools HTTP 端点仅接受 localhost/回环 IP--label空附加到提交文档的标签--initial-delay1s新加载页面的提交延迟--debounce10s页面更新内容提交前的静默期--max-wait30s连续更新允许推迟提交的最长时间0关闭上限--retry-delay30s失败提交的重试间隔0关闭重试--reconnect-delay3s重连 qutebrowser 的间隔--command-timeout10s单条 DevTools 命令超时--request-timeout30sDevTools 发现与 favicon 请求超时--max-favicon-bytes1048576接受的 favicon 响应最大字节数运行hister companion qutebrowser --help可查看完整帮助。所有时间参数与大小限制在 cmd/companion/qutebrowser/options.go 中有严格的取值范围校验延迟类非负、重连/命令/请求超时为正、favicon 上限为正。延伸阅读官方安装与配置说明webui/website/src/content/docs/browser-extension.mdqutebrowser 一节与该文一致默认端点、时序参数、回环限制companion 核心实现cmd/companion/qutebrowser/companion.goDevTools WebSocket 客户端cmd/companion/qutebrowser/cdp.go参数默认值与校验cmd/companion/qutebrowser/options.go行为测试用例cmd/companion/qutebrowser/companion_test.goCLI 入口与全部 flagcmd/companion.go提交文档的认证客户端client/client.go服务端扩展源 CORS 白名单与表单 token 拒绝测试server/extension.go、server/add_test.go【免费下载链接】histerYour own search engine项目地址: https://gitcode.com/GitHub_Trending/hi/hister创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考