@evan_zhong/browser-attach 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/README.zh.md +2 -0
- package/docs/ARCHITECTURE.md +4 -4
- package/docs/TEST-REPORT.md +9 -1
- package/docs/USAGE.md +8 -4
- package/extension/controller.js +87 -21
- package/extension/diagnostics.js +29 -3
- package/extension/fetch.js +2 -1
- package/extension/manifest.json +1 -1
- package/extension/navigation.js +18 -9
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +3 -3
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Drive the Chrome / Edge tabs you already have open and already signed in from an
|
|
|
19
19
|
- Native pointer hover, stepped drags and conditional waits; select HTML options without opening a native popup.
|
|
20
20
|
- Navigate and go back/forward, open or close tabs, handle JavaScript dialogs, upload local files, and capture the whole page or a single element.
|
|
21
21
|
- Evaluate JavaScript expressions in the main page; read console, page-error and network lifecycle logs captured after claiming; opt in to bounded, redacted request/response details when XHR/fetch needs verification.
|
|
22
|
+
- Read one HTTP(S) URL in a temporary background tab with the profile's sign-in, returning rendered text, current DOM, or complete JSON with the main-document HTTP status when observed.
|
|
22
23
|
- Standard stdio MCP with 23 tools. The Hub listens on loopback only and pairs with the extension through a random key.
|
|
23
24
|
|
|
24
25
|
## Requirements
|
|
@@ -88,6 +89,7 @@ Claiming and ordinary input never activate a tab or window; call `browser_tab_co
|
|
|
88
89
|
| `browser_discover` | `kind=browsers/tabs`; discover browser profiles, tab identity and control status |
|
|
89
90
|
| `browser_claim_tab` | Exclusively claim a discovered tab |
|
|
90
91
|
| `browser_open_tab` / `browser_tab_control` | Create a tab; explicit `release/activate/close` of a claimed tab |
|
|
92
|
+
| `browser_fetch` | Read one HTTP(S) URL in a temporary background tab using the profile's sign-in; `text`/`html`/`json`, optional HTTP status and cursor continuation |
|
|
91
93
|
| `browser_snapshot` / `browser_read_text` | AX snapshot, element refs and page text |
|
|
92
94
|
| `browser_wait` | Read-only wait for an element, text, URL or network idle |
|
|
93
95
|
| `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | Native/DOM input, pointer hover and drag, option selection, character typing and scrolling |
|
package/README.zh.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- 原生指针悬停、分步拖拽、条件等待;无需打开系统弹层即可选择 HTML 下拉选项。
|
|
20
20
|
- 导航与历史前进/后退、打开或关闭标签页,处理 JavaScript 对话框,上传本机文件并截取整页或单个元素。
|
|
21
21
|
- 在主页面执行 JavaScript 表达式,读取接管后的控制台、页面异常和网络生命周期日志;需要校验 XHR/fetch 时显式开启有界、脱敏的请求/响应详情。
|
|
22
|
+
- 在临时后台标签中复用配置的登录态读取一个 HTTP(S) URL,返回渲染文本、当前 DOM 或完整 JSON,并在可观察时附带主文档 HTTP 状态。
|
|
22
23
|
- 使用标准 stdio MCP,共提供 23 个工具。Hub 仅监听本机 loopback,通过随机密钥配对扩展。
|
|
23
24
|
|
|
24
25
|
## 环境要求
|
|
@@ -88,6 +89,7 @@ Windows 客户端若要求完整命令扩展名,使用 `npx.cmd`。已全局
|
|
|
88
89
|
| `browser_discover` | `kind=browsers/tabs`,发现浏览器配置、标签身份及控制状态 |
|
|
89
90
|
| `browser_claim_tab` | 独占接管已发现的标签 |
|
|
90
91
|
| `browser_open_tab` / `browser_tab_control` | 创建标签;显式 `release/activate/close` 释放控制、激活或关闭已接管的标签 |
|
|
92
|
+
| `browser_fetch` | 在临时后台标签中复用登录态读取一个 HTTP(S) URL;支持 `text`/`html`/`json`、可选 HTTP 状态和游标续读 |
|
|
91
93
|
| `browser_snapshot` / `browser_read_text` | AX 快照、元素引用和正文 |
|
|
92
94
|
| `browser_wait` | 只读等待元素、文本、URL 或网络空闲 |
|
|
93
95
|
| `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | 原生/DOM 输入、指针悬停与拖拽、下拉选择、逐字输入与滚动 |
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.
|
|
3
|
+
当前实现为 **0.15.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
|
|
4
4
|
|
|
5
5
|
## 组件与数据流
|
|
6
6
|
|
|
@@ -57,11 +57,11 @@ claim 与普通输入都不激活标签或窗口,也不移动系统鼠标;
|
|
|
57
57
|
|
|
58
58
|
## 单页读取
|
|
59
59
|
|
|
60
|
-
`browser_fetch` 是一次 URL 读取:Hub 按显式 `browser_id` 把请求交给配对扩展;controller 创建后台 `about:blank` 临时标签,在其队列内复用 attach/session
|
|
60
|
+
`browser_fetch` 是一次 URL 读取:Hub 按显式 `browser_id` 把请求交给配对扩展;controller 创建后台 `about:blank` 临时标签,在其队列内复用 attach/session 初始化后只导航一次,按显式 commit / domcontentloaded / load / network_idle 等待,在隔离世界读取主文档,再于 finally 关闭。总 deadline 预留读取与清理时间;等待未满足时可采集当前文档并标记降级,创建和关闭也有界。创建的迟到回执只能触发清理。它不接管已有页面,不创建浏览器或窗口。临时标签在扩展内记录 owner,并从标签发现列表排除;即使关闭或初始化失败,也不能被其他连接接管。关闭失败保留临时页记录并报告,release_owner/releaseAll 再次尝试关闭;正常已有标签的 detach 边界保持不变。
|
|
61
61
|
|
|
62
|
-
正文按 owner 保留至多 4 份各最多 1000000 字符的不可变采集,cursor 定位采集而非 URL;LRU 淘汰最久未读取的未完成采集,读完、owner 或扩展断连清除。一次读完和失败的请求不淘汰已有缓存。分页不执行脚本、不导航,临时标签关闭不会删除缓存。JSON 在采集上限内整份返回,不按 max_chars
|
|
62
|
+
正文按 owner 保留至多 4 份各最多 1000000 字符的不可变采集,cursor 定位采集而非 URL;LRU 淘汰最久未读取的未完成采集,读完、owner 或扩展断连清除。一次读完和失败的请求不淘汰已有缓存。分页不执行脚本、不导航,临时标签关闭不会删除缓存。JSON 在采集上限内整份返回,不按 max_chars 拆分。导航等待跟随同一标签后续主文档 loader,完成时保留实际满足条件的文档身份;采集期间文档变化最多只读重采 3 次,状态码始终匹配本次采集的 frame/loader。控制权变化立即失败,断连后的迟到结果不能复活缓存。格式、采集上限与分页语义见使用指南。
|
|
63
63
|
|
|
64
|
-
HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档 frame/loader
|
|
64
|
+
HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档 frame/loader 精确匹配;额外保留 requestId、响应 URL、MIME、空体证据和加载错误码,只从匹配状态的 ExtraInfo 补充 Content-Length,防止重定向 hop、跨 session 请求和缓存 304 混淆。独立于可淘汰的日志正文保留至多 500 项文档状态元数据,断连/detach 清理。不新增 webRequest 或 host 权限,不重复请求页面。未匹配时 status/ok 返回 null;ok 仅表示 HTTP 2xx。ERR_HTTP_RESPONSE_CODE_FAILURE 若有已匹配的 HTTP 响应,返回状态及已确认的空体;不能证明正文为空时标记 unavailable,不采集浏览器错误页为目标正文。其他传输错误仍失败。HTML 标记 dom=rendered,保留观察到的 DOM,不声称是服务器原文;空标题省略。
|
|
65
65
|
|
|
66
66
|
## 观察模型
|
|
67
67
|
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.
|
|
3
|
+
当前版本 **0.15.0**(2026-10-22)。验证使用本地 fixture 和临时 profile,链路为扩展 → 本机 Hub → stdio MCP。
|
|
4
4
|
|
|
5
5
|
## 当前版本
|
|
6
6
|
|
|
7
|
+
- `npm test`:172 项通过。新增 commit / DOMContentLoaded、等待超时后的降级采集、主文档替换后跟随 loader、采集期间只读重试、等待完成后换文档的身份核对、HTTP 错误响应关联及空体判断,以及标签创建/关闭超时和迟到清理;保留断连、控制权变化与不重放导航的回归覆盖。
|
|
8
|
+
- Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 238 次调用通过。新增阻塞 load 时的早期等待及超时降级、load 前 location.replace、SSE 阻塞 network_idle,以及空体 418/500/503 的状态码和正文来源;同时验证既有输入、导航、OOPIF、分页与临时页关闭链路。
|
|
9
|
+
- Chromium 临时配置访问真实 URL:`https://httpbin.org/status/500` 与 `/status/503` 分别在约 689 ms、384 ms 返回对应状态、`ok=false`、空正文及 `http_error_response` 标记。`https://chatgpt.com/` 在约 2253 ms 返回 403 验证页的文档结果,未复现客户端跳转,不将该结果视为登录应用验收。
|
|
10
|
+
- `npm run test:package`:仓库外安装运行通过,23 个工具、46 个发布文件,版本为 0.15.0;无新增扩展权限与依赖。
|
|
11
|
+
|
|
12
|
+
## 0.14.0 验证
|
|
13
|
+
|
|
7
14
|
- `npm test`:165 项通过。新增主文档 frame/loader 状态匹配、未知状态、日志淘汰后状态保留、HTTP 错误正文、渲染后 DOM 标记、空标题省略、JSON 整份返回、四份缓存交叉续读与 LRU 淘汰;读完/失败的请求不淘汰已有缓存,同 URL 独立采集,保留断连及迟到回执不续跑的回归覆盖。
|
|
8
15
|
- Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 230 次调用通过。实际验证重定向后的 HTTP 200、404/500 保留正文并返回 ok=false、200 登录墙不误判成 HTTP 错误、子框架 404 不污染主文档状态、动态 DOM 插入与 dom=rendered、900000 字符 JSON 在 max_chars=400 下整份返回、超限 JSON 报错、四份缓存交叉续读与第 5 份 LRU 淘汰,以及临时页关闭和不激活。
|
|
9
16
|
- `npm run test:package`:仓库外安装运行通过,23 个工具、46 个发布文件,版本为 0.14.0;无新增扩展权限与依赖。
|
|
@@ -54,6 +61,7 @@ node scripts/interaction-smoke.mjs --extended --headless --executable /path/to/b
|
|
|
54
61
|
- `select_option` 不制造可信事件;`network_idle` 只覆盖 claim 后观察到的页面/iframe 请求。
|
|
55
62
|
- Monaco / CodeMirror 5 分支通过接口级测试,未针对第三方生产编辑器或特定 React/Vue 版本做站点验收。
|
|
56
63
|
- 计时数据仅代表本机 fixture,不构成对任意网站的耗时承诺。
|
|
64
|
+
- 未针对已登录 Gmail / Overleaf / ChatGPT 生产页面验收;这些页面可受登录状态、验证页、网络与应用初始化影响。降级保证以实际可读文档为准,不保证业务正文在期限内出现。
|
|
57
65
|
|
|
58
66
|
## 历史验证
|
|
59
67
|
|
package/docs/USAGE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BrowserAttach 使用指南
|
|
2
2
|
|
|
3
|
-
适用于 **0.
|
|
3
|
+
适用于 **0.15.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
|
|
4
4
|
|
|
5
5
|
工具发现采用三层说明:初始化 `instructions` 提供跨工具规则;工具 description 说明用途、独有行为与结果语义;参数 schema 及其短说明提供类型、默认值和选择规则。本文保留完整原理、边界和示例。客户端集成时应把 MCP 初始化响应中的 instructions 传入模型上下文;仅读取 tools/list 不会包含完整的通用调用规则。
|
|
6
6
|
|
|
@@ -73,20 +73,24 @@
|
|
|
73
73
|
|
|
74
74
|
```js
|
|
75
75
|
browser_fetch({browser_id, url: "https://example.com/article", wait_until: "network_idle", format: "text", max_chars: 20000})
|
|
76
|
-
// → {url, title?, status, ok,
|
|
76
|
+
// → {url, title?, status, ok, body, ready_state, wait_satisfied, degraded, degraded_reason, document_changed, ...}
|
|
77
77
|
browser_fetch({browser_id, cursor: next_cursor, max_chars: 20000})
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
`browser_fetch` 在选定配置中新建 `active=false` 的临时标签,复用该配置的登录态和导航链路,采集后关闭;不需要对临时页显式 discover/claim。它不操作已有标签,不创建浏览器或窗口,不跟随页面链接。后台标签会短暂出现在标签栏,不能保证浏览器完全不可见。网页脚本、子资源、重定向仍照常执行,打开 URL 也可能产生网站状态变化;“读一页”指工具范围,不代表 HTTP 请求无副作用。
|
|
81
81
|
|
|
82
|
-
- `wait_until
|
|
82
|
+
- `wait_until` 支持 `commit`、`domcontentloaded`、`load`(默认)、`network_idle`。最后一种在 load 后等待被动请求表连续 500 ms 无活动请求;长连接可能使网络无法空闲。`commit` 只表示文档提交,正文可能仍为空,不保证应用完成加载。
|
|
83
|
+
- `timeout_ms` 为 100–30000,默认 15000,涵盖临时标签创建、调试初始化、等待、采集和关闭。总预算内预留采集与清理时间,所以等待阶段可能早于总 deadline 结束。等待超时时,只要仍有可读的 HTTP(S) 文档且控制权未变化,就读取当前内容;不重发导航。浏览器不响应或尚无 HTTP(S) 文档时仍可报错;无法保证任意登录应用在期限内提供有效业务正文。
|
|
84
|
+
- 返回 `ready_state`(`loading` / `interactive` / `complete`)、`wait_until`、`wait_satisfied`、`degraded` 和 `degraded_reason`。`wait_satisfied` 表示本次等待条件已对采集的文档满足,不等于应用内容完整。未满足时 `degraded=true`,原因可能为 `wait_timeout`、`document_changed` 或 `navigation_interrupted`;正常为 `degraded=false`、原因 `null`。即使 `ready_state=complete`,network_idle 仍可能未满足。
|
|
85
|
+
- 客户端 `location.replace` 等换文档会在同一次导航的等待期间跟随当前主文档 loader;采集期间上下文失效或文档变化,最多只读重采 3 次,共用原预算。不会为了重试而重新 navigate。控制权、连接变化始终失败;文档持续变化或重采超时也明确报错,不混用旧状态码和新正文。`document_changed` 表示相对本次首次提交的 HTTP(S) 主文档,是否观察到后续主文档替换;初始 about:blank 和同文档 URL 路由变化不计入。不会预知采集结束之后的未来跳转。
|
|
83
86
|
- 只读当前主文档。`text` 使用 `body.innerText`;`html` 返回当前 DOM `outerHTML`,同时标记 `dom: "rendered"`,包含页面脚本及浏览器扩展对 DOM 的修改,不能用作服务器原始响应或字节存档;不删除或猜测注入来源。`json` 从浏览器呈现的正文解析、校验后序列化。不另发 fetch/XHR,不返回原始响应体或响应头。`contentType` 来自 `document.contentType`,`url` 为最终采集地址;没有文档标题时省略 `title`。非 HTTP(S) 最终文档报错。
|
|
84
87
|
- `status` 来自已有 CDP 网络观察,按采集主文档的 frame 与 loader 匹配最终响应,排除重定向中间响应、子框架和 XHR/fetch。`ok` 仅表示 HTTP 200–299;未观察到匹配状态时两者均为 `null`,不猜成 200。404/500 仍返回正文及 `ok:false`,不因此设置 MCP `isError`。返回 200 的登录墙、验证码页或软错误仍为 `ok:true`,它不代表内容有效、已授权或业务成功。缓存/service worker 响应使用浏览器所报告的文档响应状态,不保证等于服务器本次实际传输的状态行。
|
|
88
|
+
- `body_source=rendered_document` 表示正文来自当前文档。空体 HTTP 错误若使浏览器报告 `ERR_HTTP_RESPONSE_CODE_FAILURE`,会保留已观察到的状态码和原 URL;确认 `Content-Length: 0` 时返回 `body:""`、`body_source=http_response`。如果响应正文无法取得且不能证明为空,返回 `body_source=unavailable` 和 `body_unavailable=true`,不能把它当成空响应。两者均标记 `degraded_reason=http_error_response`、`ready_state=null`,并附带 `navigation_error`;浏览器生成的错误页不冒充服务器正文。DNS/TLS 等未取得 HTTP 响应的错误仍失败。JSON 模式维持有效 JSON 约定,无可解析 JSON 文档时明确报错。
|
|
85
89
|
- `body` 始终为字符串。文本/HTML 按 `max_chars` 分页,范围 1–100000、默认 20000,按 JavaScript 字符串长度计算。JSON 忽略分页预算,在 1000000 字符采集上限内整份返回,可直接 `JSON.parse(body)`;`truncated=false`、`next_cursor=null`,不会返回半截 JSON。JSON 超出采集上限或解析失败时直接报错。
|
|
86
90
|
- 文本/HTML 一次最多采集 1000000 字符。`next_cursor` 非空表示缓存尚有后续片段;`truncated` 表示尚未返回全部内容或采集有损。`capture_truncated=true` 表示超出采集上限,丢失的尾部不能通过 cursor 取回。
|
|
87
91
|
- 每个连接、每个浏览器配置最多保留 4 份尚未读完的不可变采集(每份最多 1000000 字符),可交叉续读;第 5 份需要续读的采集会淘汰最久未读取的一份。一次读完或失败的请求不占缓存槽、不淘汰已有采集。相同 URL 的多次采集各自独立,不按 URL 合并,cursor 自身定位对应采集,无需 fetch_id。读完或断连清除;过期/淘汰的 cursor 明确报错,不重新访问 URL。
|
|
88
92
|
- 续读只接受 `browser_id`、`cursor` 和可选 `max_chars`,只读缓存,不打开页面。缓存不因临时页关闭而失效,也不能跨连接读取。
|
|
89
|
-
-
|
|
93
|
+
- 采集完成、无法降级的错误或断连都会清理临时页,不自动重新导航。标签创建超时后的迟到结果只用于关闭,不能继续导航;关闭等待受原 deadline 限制,底层关闭回执迟到时仍会清理状态。关闭失败会明确报错并保留清理责任,在断连时再次尝试关闭;不会静默移交给其他连接。临时页不出现在 `discover(kind="tabs")` 的结果中。
|
|
90
94
|
|
|
91
95
|
## 观察:快照与引用
|
|
92
96
|
|
package/extension/controller.js
CHANGED
|
@@ -205,6 +205,15 @@ export function createController(api, notify = () => {}) {
|
|
|
205
205
|
const frames = frameStates.get(source.tabId);
|
|
206
206
|
if (method === 'Page.frameAttached') frames?.parents.set(params.frameId, params.parentFrameId);
|
|
207
207
|
const mainNavigation = !source.sessionId && method === 'Page.frameNavigated' && params.frame && !params.frame.parentId;
|
|
208
|
+
const temporary = temporaryTabs.get(source.tabId);
|
|
209
|
+
if (mainNavigation && temporary && params.frame.loaderId) {
|
|
210
|
+
const document = JSON.stringify([params.frame.id, params.frame.loaderId]);
|
|
211
|
+
if (!temporary.firstDocument && /^https?:/.test(params.frame.url || '') && !params.frame.unreachableUrl) temporary.firstDocument = document;
|
|
212
|
+
if (temporary.firstDocument) {
|
|
213
|
+
temporary.document = document;
|
|
214
|
+
temporary.document_changed ||= document !== temporary.firstDocument;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
208
217
|
if (mainNavigation && frames) {
|
|
209
218
|
frames.mainId = params.frame.id;
|
|
210
219
|
frames.parents.clear(); frames.versions.clear(); frames.sessions.clear();
|
|
@@ -464,7 +473,7 @@ export function createController(api, notify = () => {}) {
|
|
|
464
473
|
evaluations.forget(tabId);
|
|
465
474
|
snapshots.delete(tabId);
|
|
466
475
|
fileChoosers.delete(tabId);
|
|
467
|
-
const result = await navigation.navigate(tabId, url, args);
|
|
476
|
+
const { document: navigationDocument, ...result } = await navigation.navigate(tabId, url, args);
|
|
468
477
|
const budget = deadline(NAVIGATION_OBSERVATION_TIMEOUT_MS, 'observation');
|
|
469
478
|
for (let attempt = 0; attempt < 2; attempt++) try {
|
|
470
479
|
if (args.include_snapshot === false) {
|
|
@@ -715,35 +724,92 @@ export function createController(api, notify = () => {}) {
|
|
|
715
724
|
}
|
|
716
725
|
|
|
717
726
|
async function fetchPage(owner, args, cap, checkConnection) {
|
|
718
|
-
const url = httpUrl(args.url);
|
|
719
|
-
const
|
|
720
|
-
const
|
|
721
|
-
const handle = crypto.randomUUID();
|
|
722
|
-
|
|
727
|
+
const url = httpUrl(args.url), timeout = args.timeout_ms ?? 15000, waitUntil = args.wait_until ?? 'load';
|
|
728
|
+
const end = Date.now() + timeout, cleanupReserve = Math.min(250, Math.floor(timeout / 10));
|
|
729
|
+
const budget = deadline(timeout, 'fetch', end - cleanupReserve);
|
|
730
|
+
const handle = crypto.randomUUID(), temporary = { owner, handle };
|
|
731
|
+
const creation = api.tabs.create({ url: 'about:blank', active: false }).then(tab => {
|
|
732
|
+
temporaryTabs.set(tab.id, temporary);
|
|
733
|
+
return tab;
|
|
734
|
+
});
|
|
735
|
+
let tab;
|
|
736
|
+
try { tab = await budget.run(() => creation); }
|
|
737
|
+
catch (error) {
|
|
738
|
+
// A late tabs.create response must not start navigation or leak its tab.
|
|
739
|
+
creation.then(created => queued(created.id, () => closeTemporary(created.id))).catch(() => {});
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
723
742
|
return await queued(tab.id, async () => {
|
|
724
743
|
let failure;
|
|
725
744
|
try {
|
|
726
745
|
checkConnection();
|
|
727
746
|
await attach(tab.id, owner, { tab_handle: handle }, tab, budget);
|
|
728
747
|
const claim = claims.get(tab.id);
|
|
729
|
-
|
|
748
|
+
const checkControl = () => {
|
|
749
|
+
checkConnection();
|
|
750
|
+
if (claims.get(tab.id) !== claim) throw new Error('Tab control ended during fetch; capture discarded');
|
|
751
|
+
};
|
|
752
|
+
const metadata = (satisfied, reason, readyState) => ({ wait_until: waitUntil, wait_satisfied: satisfied,
|
|
753
|
+
degraded: !satisfied, degraded_reason: reason, ready_state: readyState, document_changed: !!temporary.document_changed });
|
|
754
|
+
const httpFailure = response => {
|
|
755
|
+
if ((args.format ?? 'text') === 'json') throw new Error(`HTTP ${response.status}: navigation produced an error page without a readable JSON document`);
|
|
756
|
+
return { url: httpUrl(response.url), contentType: response.contentType, format: args.format ?? 'text', body: '',
|
|
757
|
+
body_source: response.empty_body ? 'http_response' : 'unavailable', ...(!response.empty_body ? { body_unavailable: true } : {}),
|
|
758
|
+
capture_truncated: false, status: response.status, ok: response.status >= 200 && response.status < 300,
|
|
759
|
+
...metadata(false, 'http_error_response', null), navigation_error: response.error };
|
|
760
|
+
};
|
|
761
|
+
checkControl();
|
|
730
762
|
if (budget.remaining() < 100) throw new Error('Fetch preparation timed out; no navigation was dispatched');
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
763
|
+
const readReserve = Math.min(2000, Math.floor(budget.remaining() / 3));
|
|
764
|
+
const waitEnd = Date.now() + budget.remaining() - readReserve;
|
|
765
|
+
const waitBudget = deadline(timeout, 'wait', waitEnd);
|
|
766
|
+
let satisfied = false, reason = null, waitedDocument;
|
|
767
|
+
try {
|
|
768
|
+
const navigationResult = await navigation.navigate(tab.id, url, { wait_until: waitUntil === 'network_idle' ? 'load' : waitUntil,
|
|
769
|
+
timeout_ms: Math.max(100, waitBudget.remaining()) });
|
|
770
|
+
waitedDocument = JSON.stringify([navigationResult.document.frameId, navigationResult.document.loaderId]);
|
|
771
|
+
checkControl();
|
|
772
|
+
if (waitUntil === 'network_idle') await waitFor(cdp, value, sessions, diagnostics, mouse, tab.id, { condition: 'network_idle', budget: waitBudget });
|
|
773
|
+
satisfied = true;
|
|
774
|
+
} catch (error) {
|
|
775
|
+
checkControl();
|
|
776
|
+
if (error.navigation_error === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') {
|
|
777
|
+
const response = diagnostics.failedDocument(tab.id, frameStates.get(tab.id)?.mainId);
|
|
778
|
+
if (response) return httpFailure(response);
|
|
779
|
+
throw error;
|
|
780
|
+
}
|
|
781
|
+
if (error.code === 'NAVIGATION_TIMEOUT' || (waitUntil === 'network_idle' && waitBudget.remaining() === 0)) reason = 'wait_timeout';
|
|
782
|
+
else if (error.navigation_error === 'net::ERR_ABORTED' && temporary.document) reason = 'navigation_interrupted';
|
|
783
|
+
else throw error;
|
|
784
|
+
}
|
|
785
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
786
|
+
checkControl();
|
|
787
|
+
const version = documents.get(tab.id);
|
|
788
|
+
try {
|
|
789
|
+
const { frameTree } = await budget.run(() => cdp(tab.id, 'Page.getFrameTree'));
|
|
790
|
+
const response = frameTree.frame.unreachableUrl && diagnostics.failedDocument(tab.id, frameTree.frame.id);
|
|
791
|
+
if (response && response.url === frameTree.frame.unreachableUrl) return httpFailure(response);
|
|
792
|
+
httpUrl(frameTree.frame.url);
|
|
793
|
+
const { executionContextId } = await budget.run(() => cdp(tab.id, 'Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'browser-attach-fetch' }));
|
|
794
|
+
const result = value(await budget.run(() => cdp(tab.id, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true, userGesture: false,
|
|
795
|
+
expression: `(${capturePage.toString()})(${JSON.stringify(args.format ?? 'text')},${cap})` })));
|
|
796
|
+
checkControl();
|
|
797
|
+
if (documents.get(tab.id) !== version) throw Object.assign(new Error('Document changed during fetch capture'), { code: 'FETCH_DOCUMENT_CHANGED' });
|
|
798
|
+
httpUrl(result.url);
|
|
799
|
+
const status = diagnostics.documentStatus(tab.id, frameTree.frame);
|
|
800
|
+
if (satisfied && waitedDocument !== JSON.stringify([frameTree.frame.id, frameTree.frame.loaderId])) { satisfied = false; reason = 'document_changed'; }
|
|
801
|
+
return { ...result, status, ok: status == null ? null : status >= 200 && status < 300,
|
|
802
|
+
...metadata(satisfied, reason, result.ready_state) };
|
|
803
|
+
} catch (error) {
|
|
804
|
+
checkControl();
|
|
805
|
+
const changing = documents.get(tab.id) !== version || error.code === 'FETCH_DOCUMENT_CHANGED' || /context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated/i.test(error.message);
|
|
806
|
+
if (!changing || attempt === 2) throw error;
|
|
807
|
+
await budget.run(() => new Promise(resolve => setTimeout(resolve, 25)));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
744
810
|
} catch (error) { failure = error; throw error; }
|
|
745
811
|
finally {
|
|
746
|
-
try { await closeTemporary(tab.id); }
|
|
812
|
+
try { await boundedRead(() => closeTemporary(tab.id), Math.max(1, end - Date.now())); }
|
|
747
813
|
catch (error) { throw new Error(`${failure ? failure.message + '; ' : ''}Fetch temporary tab ${tab.id} could not be closed: ${error.message}. Cleanup will be retried on disconnect.`); }
|
|
748
814
|
}
|
|
749
815
|
});
|
package/extension/diagnostics.js
CHANGED
|
@@ -52,7 +52,7 @@ function location(frame = {}) {
|
|
|
52
52
|
|
|
53
53
|
export function createDiagnostics(cdp) {
|
|
54
54
|
const tabs = new Map();
|
|
55
|
-
function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), requests: new Map(), documentResponses: new Map(), networkActivity: Date.now(), requestsOverflow: false, contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
|
|
55
|
+
function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), requests: new Map(), documentResponses: new Map(), pendingDocumentExtraInfo: new Map(), networkActivity: Date.now(), requestsOverflow: false, contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
|
|
56
56
|
function event(source, method, params) {
|
|
57
57
|
const state = tabs.get(source.tabId);
|
|
58
58
|
if (!state) return;
|
|
@@ -108,14 +108,37 @@ export function createDiagnostics(cdp) {
|
|
|
108
108
|
if (params.type === 'Document') {
|
|
109
109
|
const documentKey = JSON.stringify([source.sessionId ?? null, params.frameId]);
|
|
110
110
|
state.documentResponses.delete(documentKey);
|
|
111
|
-
|
|
111
|
+
const header = name => Object.entries(response.headers || {}).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
112
|
+
const pendingKey = JSON.stringify([source.sessionId ?? null, id, response.status]);
|
|
113
|
+
const extra = state.pendingDocumentExtraInfo.get(pendingKey);
|
|
114
|
+
const length = extra?.length ?? header('content-length');
|
|
115
|
+
state.pendingDocumentExtraInfo.delete(pendingKey);
|
|
116
|
+
state.documentResponses.set(documentKey, { requestId: id, loaderId: params.loaderId, status: response.status, url: response.url,
|
|
117
|
+
contentType: response.mimeType || String(header('content-type') || '').split(';')[0] || null, empty_body: String(length ?? '').trim() === '0' });
|
|
112
118
|
if (state.documentResponses.size > MAX_ENTRIES) state.documentResponses.delete(state.documentResponses.keys().next().value);
|
|
113
119
|
}
|
|
114
120
|
add('response', { ...(state.detailed.has(key) ? { headers: redactHeaders(response.headers) } : {}), url: clip(response.url), status: response.status, status_text: clip(response.statusText, 200),
|
|
115
121
|
mime_type: clip(response.mimeType, 200), protocol: clip(response.protocol, 50),
|
|
116
122
|
from_cache: !!(response.fromDiskCache || response.fromPrefetchCache), from_service_worker: !!response.fromServiceWorker });
|
|
123
|
+
} else if (method === 'Network.responseReceivedExtraInfo') {
|
|
124
|
+
// ExtraInfo can arrive on either side of responseReceived. Enrich only
|
|
125
|
+
// its matching status, never a redirect hop or a wire-level cache 304.
|
|
126
|
+
const length = Object.entries(params.headers || {}).find(([key]) => key.toLowerCase() === 'content-length')?.[1];
|
|
127
|
+
if (length != null) {
|
|
128
|
+
let matched = false;
|
|
129
|
+
for (const [documentKey, response] of state.documentResponses) if (JSON.parse(documentKey)[0] === (source.sessionId ?? null) && response.requestId === id && response.status === params.statusCode) {
|
|
130
|
+
response.empty_body = String(length).trim() === '0'; matched = true;
|
|
131
|
+
}
|
|
132
|
+
if (!matched && state.requests.get(key)?.resource_type === 'Document') {
|
|
133
|
+
state.pendingDocumentExtraInfo.set(JSON.stringify([source.sessionId ?? null, id, params.statusCode]), { length });
|
|
134
|
+
if (state.pendingDocumentExtraInfo.size > MAX_ENTRIES) state.pendingDocumentExtraInfo.delete(state.pendingDocumentExtraInfo.keys().next().value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
117
137
|
} else if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
|
|
118
138
|
const failed = method === 'Network.loadingFailed';
|
|
139
|
+
if (failed) for (const [documentKey, response] of state.documentResponses) {
|
|
140
|
+
if (JSON.parse(documentKey)[0] === (source.sessionId ?? null) && response.requestId === id && (!response.error || params.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE')) response.error = params.errorText;
|
|
141
|
+
}
|
|
119
142
|
add(failed ? 'failed' : 'finished', failed ? { error: clip(params.errorText), canceled: !!params.canceled,
|
|
120
143
|
blocked_reason: clip(params.blockedReason) } : { encoded_data_length: params.encodedDataLength });
|
|
121
144
|
if (!failed && state.detailed.has(key) && state.capture.enabled && cdp) {
|
|
@@ -177,6 +200,9 @@ export function createDiagnostics(cdp) {
|
|
|
177
200
|
return { start, event, read, readDetails, networkOptions, documentStatus(tabId, frame) {
|
|
178
201
|
const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frame.id]));
|
|
179
202
|
return frame.loaderId && response?.loaderId === frame.loaderId && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null;
|
|
203
|
+
}, failedDocument(tabId, frameId) {
|
|
204
|
+
const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frameId]));
|
|
205
|
+
return response?.error === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE' && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? { ...response } : null;
|
|
180
206
|
}, networkStatus(tabId) {
|
|
181
207
|
const state = tabs.get(tabId);
|
|
182
208
|
if (!state) throw new Error('Tab control ended while waiting for network idle');
|
|
@@ -185,7 +211,7 @@ export function createDiagnostics(cdp) {
|
|
|
185
211
|
const state = tabs.get(tabId);
|
|
186
212
|
if (!state) return;
|
|
187
213
|
for (const key of state.detailed) if (JSON.parse(key)[0] === sessionId) state.detailed.delete(key);
|
|
188
|
-
for (const map of [state.contexts, state.requests, state.documentResponses]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
|
|
214
|
+
for (const map of [state.contexts, state.requests, state.documentResponses, state.pendingDocumentExtraInfo]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
|
|
189
215
|
}, forget: tabId => tabs.delete(tabId) };
|
|
190
216
|
}
|
|
191
217
|
|
package/extension/fetch.js
CHANGED
|
@@ -9,7 +9,7 @@ export function fetchOptions(args) {
|
|
|
9
9
|
const url = new URL(args.url);
|
|
10
10
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('fetch requires an HTTP(S) URL without credentials');
|
|
11
11
|
if (!['text', 'html', 'json'].includes(args.format ?? 'text')) throw new Error('format must be text, html or json');
|
|
12
|
-
if (!['load', 'network_idle'].includes(args.wait_until ?? 'load')) throw new Error('wait_until must be load or network_idle');
|
|
12
|
+
if (!['commit', 'domcontentloaded', 'load', 'network_idle'].includes(args.wait_until ?? 'load')) throw new Error('wait_until must be commit, domcontentloaded, load or network_idle');
|
|
13
13
|
if (!Number.isInteger(args.timeout_ms ?? 15000) || (args.timeout_ms ?? 15000) < 100 || (args.timeout_ms ?? 15000) > 30000) throw new Error('timeout_ms must be 100..30000');
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -24,6 +24,7 @@ export function capturePage(format, cap) {
|
|
|
24
24
|
if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
|
|
25
25
|
}
|
|
26
26
|
return { url: location.href, ...(document.title ? { title: document.title } : {}), contentType: document.contentType, format,
|
|
27
|
+
ready_state: document.readyState, body_source: 'rendered_document',
|
|
27
28
|
...(format === 'html' ? { dom: 'rendered' } : {}),
|
|
28
29
|
body: body.slice(0, cap), capture_truncated: body.length > cap };
|
|
29
30
|
}
|
package/extension/manifest.json
CHANGED
package/extension/navigation.js
CHANGED
|
@@ -26,7 +26,7 @@ export function createNavigation(cdp) {
|
|
|
26
26
|
if (!['http:', 'https:'].includes(destination.protocol) || destination.username || destination.password) throw new Error('History entry is outside supported HTTP(S) scope; no navigation was dispatched');
|
|
27
27
|
mainId = (await prepare('Page.getFrameTree')).frameTree.frame.id;
|
|
28
28
|
}
|
|
29
|
-
let result, resolveDone, rejectDone, timer;
|
|
29
|
+
let result, currentDocument, completedDocument, resolveDone, rejectDone, timer;
|
|
30
30
|
const events = [];
|
|
31
31
|
const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
|
|
32
32
|
function check() {
|
|
@@ -40,25 +40,34 @@ export function createNavigation(cdp) {
|
|
|
40
40
|
}
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
|
-
// A
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
event.
|
|
43
|
+
// A client-side redirect can replace the loader before the original reply.
|
|
44
|
+
const current = currentDocument || result;
|
|
45
|
+
if (wait_until === 'commit' || !current.loaderId || events.some(event =>
|
|
46
|
+
event.frameId === current.frameId && event.loaderId === current.loaderId &&
|
|
47
|
+
event.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) {
|
|
48
|
+
completedDocument ??= current;
|
|
49
|
+
resolveDone(result);
|
|
50
|
+
}
|
|
47
51
|
}
|
|
48
52
|
pending.set(tabId, {
|
|
49
|
-
event(method, params) {
|
|
53
|
+
event(method, params) {
|
|
54
|
+
if (!direction && method === 'Page.frameNavigated' && !params.frame.parentId && params.frame.loaderId) currentDocument = { frameId: params.frame.id, loaderId: params.frame.loaderId };
|
|
55
|
+
if (['Page.lifecycleEvent', 'Page.frameNavigated', 'Page.navigatedWithinDocument'].includes(method)) { events.push({ method, ...params }); check(); }
|
|
56
|
+
},
|
|
50
57
|
cancel(reason) { rejectDone(reason || new Error('Tab detached during navigation; outcome is unknown.')); },
|
|
51
58
|
});
|
|
52
|
-
timer = setTimeout(() => rejectDone(new Error(`Navigation did not reach ${wait_until} within ${timeout_ms} ms. It may still be loading; observe the tab before another action.`)), Math.max(0, end - Date.now()));
|
|
59
|
+
timer = setTimeout(() => rejectDone(Object.assign(new Error(`Navigation did not reach ${wait_until} within ${timeout_ms} ms. It may still be loading; observe the tab before another action.`), { code: 'NAVIGATION_TIMEOUT' })), Math.max(0, end - Date.now()));
|
|
53
60
|
try {
|
|
54
61
|
// Attach rejection handlers immediately, including when the deadline wins first.
|
|
55
62
|
const command = cdp(tabId, direction ? 'Page.navigateToHistoryEntry' : 'Page.navigate', direction ? { entryId: entry.id } : { url }).then(reply => {
|
|
56
|
-
if (reply.errorText) throw new Error(`Navigation failed: ${reply.errorText}`);
|
|
63
|
+
if (reply.errorText) throw Object.assign(new Error(`Navigation failed: ${reply.errorText}`), { code: 'NAVIGATION_FAILED', navigation_error: reply.errorText });
|
|
57
64
|
if (reply.isDownload) throw new Error('Navigation started a download instead of a document.');
|
|
58
65
|
result = direction ? {} : reply; check(); return done;
|
|
59
66
|
});
|
|
60
67
|
await Promise.race([command, done]);
|
|
61
|
-
|
|
68
|
+
const document = completedDocument || result;
|
|
69
|
+
return { navigated_to: entry?.url || url, ...(direction ? { direction } : {}), wait_until, same_document: !result.loaderId,
|
|
70
|
+
document: { frameId: document.frameId, loaderId: document.loaderId } };
|
|
62
71
|
} finally { clearTimeout(timer); pending.delete(tabId); }
|
|
63
72
|
}
|
|
64
73
|
return { event, cancel, navigate };
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
|
|
|
15
15
|
: process.platform === 'win32' ? join(homedir(), '.browser-attach')
|
|
16
16
|
: join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
|
|
17
17
|
export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
|
|
18
|
-
export const VERSION = '0.
|
|
18
|
+
export const VERSION = '0.15.0';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|
package/src/mcp.mjs
CHANGED
|
@@ -67,13 +67,13 @@ export function createMcp(client) {
|
|
|
67
67
|
tool('open_tab', 'Open an HTTP(S) tab in the selected profile. Returns tab_id; discover tabs after loading before claiming it.', {
|
|
68
68
|
browser_id: z.string(), url: z.string(), active: z.boolean().default(false),
|
|
69
69
|
});
|
|
70
|
-
tool('fetch', 'Read a URL\'s main document in a temporary background tab using the profile\'s sign-in, then close it. status/ok describe HTTP only (unknown=null),
|
|
70
|
+
tool('fetch', 'Read a URL\'s main document in a temporary background tab using the profile\'s sign-in, then close it. status/ok describe HTTP only (unknown=null). ready_state, wait_satisfied, degraded and document_changed report capture readiness. body is a string; JSON returns whole. next_cursor reads immutable cache without revisiting the URL; up to 4 captures per connection/profile, evicted by LRU.', {
|
|
71
71
|
browser_id: z.string().min(1), url: z.string().optional().describe('HTTP(S), no URL credentials; required unless cursor is supplied.'),
|
|
72
72
|
cursor: z.string().optional().describe('Continuation accepts only browser_id, cursor and max_chars.'),
|
|
73
|
-
wait_until: z.enum(['load', 'network_idle']).optional().describe('Default load; network_idle
|
|
73
|
+
wait_until: z.enum(['commit', 'domcontentloaded', 'load', 'network_idle']).optional().describe('Default load; network_idle waits for load then 500ms without observed requests. Unmet waits return a degraded capture if readable.'),
|
|
74
74
|
format: z.enum(['text', 'html', 'json']).optional().describe('Default text (rendered); html=current DOM with dom=rendered; json=complete validated JSON.'),
|
|
75
75
|
max_chars: integerRange('max_chars', 1, 100000).default(20000).describe('Text/HTML page budget; JSON ignores this and returns whole, up to 1M chars. capture_truncated means text/HTML loss; oversized JSON errors.'),
|
|
76
|
-
timeout_ms: integerRange('timeout_ms', 100, 30000).optional().describe('Default 15000;
|
|
76
|
+
timeout_ms: integerRange('timeout_ms', 100, 30000).optional().describe('Default 15000; total deadline including creation/cleanup, with time reserved for capture after an unmet wait.'),
|
|
77
77
|
}, null, args => { fetchOptions(args); return args; });
|
|
78
78
|
tool('claim_tab', 'Exclusively claim a tab_id from this connection\'s latest tab discovery. Optional expected_url/title must match exactly. Returns the current identity.', {
|
|
79
79
|
browser_id: z.string(), tab_id: z.number().int(), expected_url: z.string().optional(), expected_title: z.string().optional(),
|