@glittering0717/ai-browser 1.2.2 → 1.3.1
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 +136 -19
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +7 -5
- package/scripts/build_dmg.cjs +220 -0
- package/scripts/marketing/data_snapshot.sh +69 -0
- package/scripts/render_icon.cjs +118 -0
- package/src/main/axExtractor.js +103 -36
- package/src/main/error_log.js +245 -0
- package/src/main/index.js +276 -7
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/page_manager.js +936 -128
- package/src/main/ws_server.js +73 -12
- package/src/preload/actions.cjs +61 -2
- package/src/preload/bridge.cjs +31 -1
- package/src/preload/extractor.cjs +520 -25
- package/src/renderer/error_banner.html +90 -0
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/deadline.js +6 -0
- package/src/shared/deep_active.cjs +31 -0
- package/src/shared/error_banner.cjs +43 -0
- package/src/shared/find_ref.cjs +44 -0
- package/src/shared/frame_probe.cjs +112 -0
- package/src/shared/hit_point.cjs +80 -0
- package/src/shared/input_verify.js +47 -0
- package/src/shared/key_dispatch.cjs +37 -0
- package/src/shared/nav_wait.cjs +47 -0
- package/src/shared/protocol.js +8 -0
- package/src/shared/ref_guard.cjs +133 -0
- package/src/shared/screenshot_opts.cjs +130 -0
- package/src/shared/tree_diff.cjs +105 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +239 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>主进程异常</title>
|
|
6
|
+
<style>
|
|
7
|
+
/* 横幅只有 34px 高:所有尺寸都按"一行文字"设计,任何换行都会被裁掉,
|
|
8
|
+
所以主进程那边已经把消息压成一行(shared/error_banner.cjs)。 */
|
|
9
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
+
html, body { height: 100%; overflow: hidden; }
|
|
11
|
+
body {
|
|
12
|
+
display: flex; align-items: center; gap: 10px;
|
|
13
|
+
padding: 0 8px 0 12px;
|
|
14
|
+
background: #7f1d1d;
|
|
15
|
+
color: #fff;
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
|
17
|
+
font-size: 12px;
|
|
18
|
+
user-select: none;
|
|
19
|
+
cursor: default;
|
|
20
|
+
border-top: 1px solid #b91c1c;
|
|
21
|
+
}
|
|
22
|
+
#icon { font-size: 13px; flex: none; opacity: 0.95; }
|
|
23
|
+
#title { font-weight: 600; flex: none; letter-spacing: 0.2px; }
|
|
24
|
+
#detail {
|
|
25
|
+
flex: 1 1 auto;
|
|
26
|
+
min-width: 0;
|
|
27
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
28
|
+
color: #fee2e2;
|
|
29
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
30
|
+
font-size: 11.5px;
|
|
31
|
+
}
|
|
32
|
+
#count {
|
|
33
|
+
display: none;
|
|
34
|
+
flex: none;
|
|
35
|
+
background: #fff; color: #7f1d1d;
|
|
36
|
+
border-radius: 8px; padding: 0 6px;
|
|
37
|
+
font-size: 10.5px; font-weight: 700; line-height: 16px;
|
|
38
|
+
}
|
|
39
|
+
#log {
|
|
40
|
+
flex: 0 1 auto; max-width: 34%;
|
|
41
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
42
|
+
color: #fca5a5; font-size: 11px;
|
|
43
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
44
|
+
}
|
|
45
|
+
#close {
|
|
46
|
+
flex: none;
|
|
47
|
+
background: transparent; border: 1px solid #fca5a5; color: #fee2e2;
|
|
48
|
+
border-radius: 4px; width: 20px; height: 20px; line-height: 1;
|
|
49
|
+
cursor: pointer; font-size: 13px; font-family: inherit;
|
|
50
|
+
}
|
|
51
|
+
#close:hover { background: #b91c1c; color: #fff; }
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<span id="icon">⚠</span>
|
|
56
|
+
<span id="title">主进程异常</span>
|
|
57
|
+
<span id="detail"></span>
|
|
58
|
+
<span id="count"></span>
|
|
59
|
+
<span id="log"></span>
|
|
60
|
+
<button id="close" title="关闭(日志已保存,不受影响)">×</button>
|
|
61
|
+
|
|
62
|
+
<script>
|
|
63
|
+
const { ipcRenderer } = require('electron');
|
|
64
|
+
|
|
65
|
+
function apply(p) {
|
|
66
|
+
if (!p) return;
|
|
67
|
+
const detail = document.getElementById('detail');
|
|
68
|
+
// title 只用一次:横幅里"主进程异常"当标签,异常消息当正文,不重复两遍
|
|
69
|
+
document.getElementById('title').textContent = p.title || '主进程异常';
|
|
70
|
+
detail.textContent = p.detail || '';
|
|
71
|
+
detail.title = p.detail || ''; // 悬停看全文(截断时可读)
|
|
72
|
+
const logEl = document.getElementById('log');
|
|
73
|
+
// 路径可能很长,展示时只留尾部(文件名一定在末尾),完整路径放 title
|
|
74
|
+
logEl.textContent = p.log_path ? '日志 ' + p.log_path : '';
|
|
75
|
+
logEl.title = p.log_path || '';
|
|
76
|
+
const countEl = document.getElementById('count');
|
|
77
|
+
if (p.count > 1) {
|
|
78
|
+
countEl.style.display = 'inline-block';
|
|
79
|
+
countEl.textContent = '×' + p.count;
|
|
80
|
+
countEl.title = '连续异常次数(日志里每一条都在)';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ipcRenderer.on('error-banner:show', (_e, payload) => apply(payload));
|
|
85
|
+
document.getElementById('close').addEventListener('click', () => {
|
|
86
|
+
ipcRenderer.send('error-banner:close');
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/shared/act_params.cjs — ui.act 的参数归一(纯函数)
|
|
2
|
+
//
|
|
3
|
+
// 坑:WS 层的 `ui.act` 只认 `params:{...}` 嵌套形状。把 `text` / `html` /
|
|
4
|
+
// `from_x` 放在顶层时它们**被静默丢掉**,动作照常执行但输入为空,回执是
|
|
5
|
+
// "type called with empty text" —— 调用方会以为自己传了文字,只是没生效。
|
|
6
|
+
// 这不是猜测:本轮实测里,同一个 act 调用写成 `{action, target, text}` 就失败,
|
|
7
|
+
// 写成 `{action, target, params:{text}}` 就成功。
|
|
8
|
+
//
|
|
9
|
+
// 现在两种形状都接受:嵌套优先,顶层未嵌套的已知动作参数被提升,并在回执里
|
|
10
|
+
// 用 params_hoisted 说明"这些是从顶层挪进来的",调用方不必靠猜。
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// 动作级参数(与 mcp_tools.browse_act 的 properties 同源)。tab/action/target
|
|
15
|
+
// 是 RPC 层自己的字段,不参与提升。
|
|
16
|
+
const ACTION_KEYS = [
|
|
17
|
+
'text', 'html', 'value', 'offset', 'limit', 'url', 'keep_tab', 'file', 'files',
|
|
18
|
+
'timeout_ms', 'to_target', 'from_anchor', 'to_anchor', 'hold', 'button', 'key', 'keys',
|
|
19
|
+
'dx', 'dy', 'to_x', 'to_y', 'from_x', 'from_y', 'delta_x', 'delta_y', 'steps',
|
|
20
|
+
'from', 'to', 'path',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} top WS 收到的完整 params
|
|
27
|
+
* @returns {{params: object, hoisted: string[]}}
|
|
28
|
+
*/
|
|
29
|
+
function hoistActParams(top) {
|
|
30
|
+
const src = top && typeof top === 'object' ? top : {};
|
|
31
|
+
const nested = src.params && typeof src.params === 'object' ? src.params : {};
|
|
32
|
+
const params = { ...nested };
|
|
33
|
+
const hoisted = [];
|
|
34
|
+
for (const k of ACTION_KEYS) {
|
|
35
|
+
if (src[k] !== undefined && params[k] === undefined) {
|
|
36
|
+
params[k] = src[k];
|
|
37
|
+
hoisted.push(k);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// 坐标快捷键:drag 的 from_x/from_y 与 to_x/to_y 在内部用 {from:{x,y}} 表达。
|
|
41
|
+
// 只在一个都没给、且两个分量都齐时才合成,避免半截坐标变成 (x, 0)。
|
|
42
|
+
if (params.from == null && num(params.from_x) && num(params.from_y)) {
|
|
43
|
+
params.from = { x: params.from_x, y: params.from_y };
|
|
44
|
+
if (!hoisted.includes('from')) hoisted.push('from(x,y)');
|
|
45
|
+
}
|
|
46
|
+
if (params.to == null && num(params.to_x) && num(params.to_y)) {
|
|
47
|
+
params.to = { x: params.to_x, y: params.to_y };
|
|
48
|
+
if (!hoisted.includes('to')) hoisted.push('to(x,y)');
|
|
49
|
+
}
|
|
50
|
+
return { params, hoisted };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { hoistActParams, ACTION_KEYS };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// 登录墙 / 登录态探测。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它(来自一次真实事故,不是假想):
|
|
4
|
+
// 2026-10-17 往 CSDN 发文章,会话早已失效,editor.csdn.net/md/ 被 302 到
|
|
5
|
+
// passport.csdn.net/login?code=applets。此时 ui.get_tree / ui.evaluate / ui.act
|
|
6
|
+
// 全部照常返回、毫无异常,操作在这个状态下继续了很久,最后失败被归因到
|
|
7
|
+
// bizapi 的 401 上 —— 而那只是会话失效的下游表现。排查顺序从最基础的
|
|
8
|
+
// "我还登着吗"就已经偏了。
|
|
9
|
+
//
|
|
10
|
+
// 更糟的是 context.session.logged_in 当时**主动说了谎**。它的 cookie 判定是:
|
|
11
|
+
// document.cookie.indexOf("token") >= 0
|
|
12
|
+
// CSDN 未登录时 cookie 串里照样有 csrfToken —— 子串命中 "token",于是判定
|
|
13
|
+
// logged_in = true。一个本该报警的字段,变成了掩护。
|
|
14
|
+
//
|
|
15
|
+
// 所以这里做三件事:
|
|
16
|
+
// 1. cookie 判定按**名字**匹配,并显式排除 csrf/xsrf 类(它们恰恰在未登录时也存在);
|
|
17
|
+
// 2. 提供显式的 auth_wall 信号 —— URL 命中登录域 / 页面上有密码输入框。它必须是
|
|
18
|
+
// 一眼可见的前置信号,而不是埋在 context 里的某个小字段;
|
|
19
|
+
// 3. logged_in 收紧:去掉"正文超过 1000 字就算登录"这种没有信息量的兜底
|
|
20
|
+
// (CSDN 登录页正文远超 1000 字,而误判方向是"说有登录"——代价最高的方向)。
|
|
21
|
+
//
|
|
22
|
+
// 诚实边界:这些都是启发式,权威信号只有 auth_wall.detected(URL / 密码框是硬事实)。
|
|
23
|
+
// logged_in 的语义是"看着像登录态",宁紧勿松 —— 误报未登录只会让调用方多验一次,
|
|
24
|
+
// 误报已登录会让它带着错误假设走完整条流程。
|
|
25
|
+
|
|
26
|
+
/** 主机名里的登录/鉴权服务标识。 */
|
|
27
|
+
const AUTH_HOST_RE = /(^|\.)(passport|login|signin|sso|auth|accounts|id)\./i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 路径段里的登录墙标识。按**段边界**匹配,不是全文 indexOf ——
|
|
31
|
+
* `/blog/login-tips` 这种讲登录的文章页不该被判成登录墙。
|
|
32
|
+
*/
|
|
33
|
+
const LOGIN_PATH_RE =
|
|
34
|
+
/(^|\/)(login|log-in|signin|sign-in|passport|sso|oauth|authorize|cas)(\/|$|[?#.])/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* cookie 名表示"已登录"的白名单(按完整名字/段匹配,不是任意子串)。
|
|
38
|
+
*
|
|
39
|
+
* 踩过的坑(都是真实数据,不是假想):
|
|
40
|
+
* - `uid` / `userid` **不能**进白名单:CSDN 未登录时的 `p_uid` 是埋点 partner id,
|
|
41
|
+
* 旧口径放它进去会让未登录态被判成已登录 —— 正是要防的那类误判;
|
|
42
|
+
* - `UserName` / `UserInfo` / `UserToken` 必须进:它们是 CSDN 的真鉴权项,
|
|
43
|
+
* 而 "token" 前后没有分隔符,靠 (^|_)token(_|$) 这种段匹配抓不到。
|
|
44
|
+
*/
|
|
45
|
+
const AUTH_COOKIE_NAME_RE =
|
|
46
|
+
/(^|_|-)(token|auth|session|sessionid|sid|jwt|login|loggedin|logged_in|access_token|refresh_token|username|userinfo|usertoken|nickname|uname)(_|$|-)/i;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* cookie 名排除项。csrf/xsrf token 是防跨站用的,未登录时照样存在 ——
|
|
50
|
+
* 把它算进登录证据,正是 CSDN 那次误判的直接原因。
|
|
51
|
+
*/
|
|
52
|
+
const CSRF_COOKIE_NAME_RE = /csrf|xsrf|_stm|stm_/i;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 这个 cookie 名能不能作为"已登录"的证据。
|
|
56
|
+
* @param {string} name cookie 名(不含值)
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isAuthCookieName(name) {
|
|
60
|
+
const n = String(name || '').trim().toLowerCase();
|
|
61
|
+
if (!n) return false;
|
|
62
|
+
if (CSRF_COOKIE_NAME_RE.test(n)) return false;
|
|
63
|
+
if (AUTH_COOKIE_NAME_RE.test(n)) return true;
|
|
64
|
+
// 一些站点用裸名,如 `TOKEN` / `SESSION` / `Auth` / `UserName`。整体匹配才算,
|
|
65
|
+
// 避免 `csrfToken` 这类复合名靠子串蒙混过关。
|
|
66
|
+
return /^(token|auth|session|sessionid|sid|jwt|username|userinfo|usertoken|nickname|uname|login|loggedin)$/.test(n);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 把 `document.cookie` 串拆成名字列表。只需要名字,不解析值
|
|
71
|
+
* (值可能含 base64 的 '=',按第一个 '=' 切就够了)。
|
|
72
|
+
* @param {string} cookieString
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
function parseCookieNames(cookieString) {
|
|
76
|
+
return String(cookieString || '')
|
|
77
|
+
.split(';')
|
|
78
|
+
.map((kv) => kv.split('=')[0].trim())
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 登录墙探测。命中任一硬事实即 detected:
|
|
84
|
+
* - URL 的 host 命中登录/鉴权服务域名(passport.csdn.net、sso.example.com…)
|
|
85
|
+
* - URL 的 path 命中登录墙路径段(/login、/signin、/passport、/oauth/authorize…)
|
|
86
|
+
* - 页面上存在可见的密码输入框
|
|
87
|
+
*
|
|
88
|
+
* 纯文本"登录"字样**不**作为独立触发条件:大量正常页面都含这两个字
|
|
89
|
+
* (登录引导按钮、文档标题…),拿它当证据会把 auth_wall 变成噪音,
|
|
90
|
+
* 而噪音信号比没有信号更糟 —— 调用方会学会忽略它。
|
|
91
|
+
*
|
|
92
|
+
* @param {{url?: string, hasPasswordInput?: boolean}} input
|
|
93
|
+
* @returns {{detected: boolean, reason: string|null, matched: string|null}}
|
|
94
|
+
*/
|
|
95
|
+
function detectAuthWall(input) {
|
|
96
|
+
const url = String((input && input.url) || '');
|
|
97
|
+
const hasPasswordInput = !!(input && input.hasPasswordInput);
|
|
98
|
+
|
|
99
|
+
let host = '';
|
|
100
|
+
let path = '';
|
|
101
|
+
try {
|
|
102
|
+
const u = new URL(url, 'http://relative.invalid');
|
|
103
|
+
host = u.hostname || '';
|
|
104
|
+
path = u.pathname || '';
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// URL 解析失败时退化成字符串匹配,总比静默不判好。
|
|
107
|
+
host = url;
|
|
108
|
+
path = url;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const hostMatch = host.match(AUTH_HOST_RE);
|
|
112
|
+
if (hostMatch) {
|
|
113
|
+
// 去掉首尾的点:matched 要能直接读("passport" 而不是 "passport.")。
|
|
114
|
+
return { detected: true, reason: 'auth_host', matched: hostMatch[0].replace(/^\.+|\.+$/g, '') };
|
|
115
|
+
}
|
|
116
|
+
const pathMatch = path.match(LOGIN_PATH_RE);
|
|
117
|
+
if (pathMatch) {
|
|
118
|
+
return { detected: true, reason: 'login_path', matched: pathMatch[0] };
|
|
119
|
+
}
|
|
120
|
+
if (hasPasswordInput) {
|
|
121
|
+
return { detected: true, reason: 'password_field', matched: null };
|
|
122
|
+
}
|
|
123
|
+
return { detected: false, reason: null, matched: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* "看着像登录态吗"——收紧过的启发式。
|
|
128
|
+
*
|
|
129
|
+
* 与旧实现的差别:
|
|
130
|
+
* - 删掉 `bodyText.length > 1000`(没有信息量,且误判方向是"说有登录");
|
|
131
|
+
* - cookie 按名字判,csrf 类显式排除;
|
|
132
|
+
* - auth_wall.detected 是硬否决项:已经在登录墙上,不可能同时是登录态。
|
|
133
|
+
*
|
|
134
|
+
* @param {{url?: string, hasPasswordInput?: boolean, hasAvatar?: boolean,
|
|
135
|
+
* cookieString?: string, cookieNames?: string[]}} input
|
|
136
|
+
* @returns {{logged_in: boolean, evidence: string[]}}
|
|
137
|
+
*/
|
|
138
|
+
function looksLoggedIn(input) {
|
|
139
|
+
const url = String((input && input.url) || '');
|
|
140
|
+
const wall = detectAuthWall({ url, hasPasswordInput: input && input.hasPasswordInput });
|
|
141
|
+
const evidence = [];
|
|
142
|
+
|
|
143
|
+
if (wall.detected) {
|
|
144
|
+
return { logged_in: false, evidence: ['auth_wall:' + wall.reason] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let cookieNames = (input && input.cookieNames) || parseCookieNames(input && input.cookieString);
|
|
148
|
+
const hasAuthCookie = cookieNames.some(isAuthCookieName);
|
|
149
|
+
if (hasAuthCookie) evidence.push('auth_cookie');
|
|
150
|
+
if (input && input.hasAvatar) evidence.push('avatar');
|
|
151
|
+
|
|
152
|
+
return { logged_in: hasAuthCookie || !!(input && input.hasAvatar), evidence };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
AUTH_HOST_RE,
|
|
157
|
+
LOGIN_PATH_RE,
|
|
158
|
+
isAuthCookieName,
|
|
159
|
+
parseCookieNames,
|
|
160
|
+
detectAuthWall,
|
|
161
|
+
looksLoggedIn,
|
|
162
|
+
};
|
package/src/shared/deadline.js
CHANGED
|
@@ -78,6 +78,12 @@ export function actionDeadlineMs(action, params = {}) {
|
|
|
78
78
|
} else if (action === 'clear') {
|
|
79
79
|
// clear 要走编辑器状态提交与回读校验(多处 80~150ms 等待)。
|
|
80
80
|
need = Math.max(need, 20000);
|
|
81
|
+
} else if (action === 'paste') {
|
|
82
|
+
// paste 本身不逐字符输入,但大型 HTML 经编辑器管线转换(Draft.js 的
|
|
83
|
+
// DraftPasteProcessor / ProseMirror 的 transformPastedHTML)可能很慢,
|
|
84
|
+
// 之后还有 350ms 等待 + 回读校验。按 payload 大小给余量。
|
|
85
|
+
const payload = Math.max(String(p.html || '').length, String(p.text || '').length);
|
|
86
|
+
need = Math.max(need, 20000 + payload / 4);
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
const env = Number(process.env.AI_BROWSER_ACTION_TIMEOUT_MS);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/shared/deep_active.cjs —— 「真正的焦点元素」表达式。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要它:`document.activeElement` 在 Web Component 里只到**宿主**为止。
|
|
5
|
+
// 焦点实际在 `<bili-rich-text>` 的 shadow root 里那个 contenteditable 上,但
|
|
6
|
+
// `document.activeElement` 返回的是 `bili-rich-text` 宿主本身,宿主上没有内容
|
|
7
|
+
// (innerText 为空),于是所有"读回输入内容再和目标比对"的校验全部读到空串。
|
|
8
|
+
//
|
|
9
|
+
// 后果不是"慢"或"看不清",而是**假阴性**:内容明明打进去了,回执却报
|
|
10
|
+
// `Input not verified / actual_chars: 0`。调用方(尤其 agent)看到这个结论会
|
|
11
|
+
// 再打一次,结果是**重复输入**——比直接报错更糟,因为它看起来像"上次没成功"。
|
|
12
|
+
// 实测(2026-10-17,B站 opus 详情页评论框):type 返回失败、actual_chars=0,
|
|
13
|
+
// 而回读 value 是完整的一句话。
|
|
14
|
+
//
|
|
15
|
+
// 修法:沿 `shadowRoot.activeElement` 一路下钻,取最里层的那个。
|
|
16
|
+
// 这条链是浏览器自己维护的焦点路径,不是猜 —— 每一层都是**该树内**的 activeElement。
|
|
17
|
+
//
|
|
18
|
+
// 返回值是一个**页面内可执行的 JS 表达式字符串**(不是函数),因为这些调用点
|
|
19
|
+
// 全部是拼进 `executeJavaScript` 的一行脚本里,统一用表达式才不用改它们的形状。
|
|
20
|
+
function deepActiveExpr() {
|
|
21
|
+
return '(function(){' +
|
|
22
|
+
'var e=null;try{e=document.activeElement}catch(x){return null}' +
|
|
23
|
+
// 防御死循环:shadow 嵌套深度有限,但页面可以自己构造循环引用。
|
|
24
|
+
'var g=0;while(e&&e.shadowRoot&&e.shadowRoot.activeElement&&g++<10){e=e.shadowRoot.activeElement}' +
|
|
25
|
+
'return e})()';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 同源 iframe 里的焦点:顶层 document.activeElement 是 <iframe> 元素本身,
|
|
29
|
+
// 这条链进不去(跨进程更进不去)。调用方要区分"在帧里"时自己判 tagName 即可,
|
|
30
|
+
// 这里不多导出一个口径 —— 两处判断同一件事迟早会不一致。
|
|
31
|
+
module.exports = { deepActiveExpr };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// shared/error_banner.cjs — 主进程异常横幅的文案装配(纯函数,可在 vitest 里直接测)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一个文件:横幅内容有两条硬要求,都值得被钉住 ——
|
|
4
|
+
// ① **一行**。横幅只有 34px 高,异常消息里带换行(多行栈、带 \n 的 Error.message)
|
|
5
|
+
// 会把文字挤没,用户看到一片红却读不到原因。
|
|
6
|
+
// ② **必须带日志路径**。横幅是提醒,不是全部信息:真正的栈在 error.jsonl 里。
|
|
7
|
+
// 只写"出错了"等于把人重新推回"点掉之后无从查证"的老问题。
|
|
8
|
+
//
|
|
9
|
+
// 这些逻辑放在 main/index.js 里就没法测了(那个文件 import electron)。
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const MAX_DETAIL = 220;
|
|
14
|
+
const MAX_LOG_PATH = 120;
|
|
15
|
+
|
|
16
|
+
// 压成一行 + 截断。用省略号而不是硬切,读的人才知道后面还有内容。
|
|
17
|
+
function oneLine(input, max) {
|
|
18
|
+
const s = String(input === undefined || input === null ? '' : input)
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
if (!s) return '';
|
|
22
|
+
if (s.length <= max) return s;
|
|
23
|
+
return s.slice(0, Math.max(0, max - 1)) + '…';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{detail?: string, logPath?: string, at?: string, count?: number}} input
|
|
28
|
+
* @returns {{title: string, detail: string, log_path: string, at: string, count: number}}
|
|
29
|
+
*/
|
|
30
|
+
function buildBannerPayload(input) {
|
|
31
|
+
const src = input || {};
|
|
32
|
+
return {
|
|
33
|
+
title: 'AI Browser 主进程异常',
|
|
34
|
+
detail: oneLine(src.detail, MAX_DETAIL) || '(没有消息)',
|
|
35
|
+
// 路径也压一行:userData 可能被 AI_BROWSER_USER_DATA 指到很深的目录
|
|
36
|
+
log_path: oneLine(src.logPath, MAX_LOG_PATH),
|
|
37
|
+
at: src.at || new Date().toISOString(),
|
|
38
|
+
// 连续报错时横幅不叠加,只把次数写进角标 —— 一屏红块比一行红更烦人
|
|
39
|
+
count: Number(src.count) > 0 ? Math.floor(Number(src.count)) : 1,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { buildBannerPayload, MAX_DETAIL };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// shared/find_ref.cjs — 按 data-ai-id 找元素,**穿透影子根**。
|
|
3
|
+
//
|
|
4
|
+
// 为什么不能只用 document.querySelector:shadow boundary 是硬边界,顶层查询进不去。
|
|
5
|
+
// Web Component 里的元素(B站 opus 的 <bili-comments>、YouTube 的 <ytd-*>、
|
|
6
|
+
// 各设计系统的 <ui-*>)即使抽取层已经写了 data-ai-id,顶层也查不到 —— 报出来只是
|
|
7
|
+
// "no element with data-ai-id=...",读起来像"元素不存在",实际是"查询路径够不着"。
|
|
8
|
+
// 实测 2026-10-17:B站帖子详情页的评论输入框整个在 bili-comments 的 shadow root 里,
|
|
9
|
+
// 抽取层补了下探(extractor.cjs 的 childrenOf)之后,解析这一侧也必须跟着穿透,
|
|
10
|
+
// 否则就是"树里有、点不动"。
|
|
11
|
+
//
|
|
12
|
+
// 两半各自有存在的理由:
|
|
13
|
+
// findRef —— 运行时函数,给能在页面里直接跑的代码用(preload/actions.cjs 自己
|
|
14
|
+
// 那份等价实现就是它,故意不跨界 require,避免一处路径写错连带
|
|
15
|
+
// 整个 preload 起不来)。
|
|
16
|
+
// findRefExpr —— 字符串表达式,给主进程"拼注入脚本"的地方用。返回自包含表达式,
|
|
17
|
+
// 所以可以原地替换 `document.querySelector(...)`,不需要在每个
|
|
18
|
+
// IIFE 里补前置声明(这样的地方有十几处,漏一处就是一处静默失效)。
|
|
19
|
+
|
|
20
|
+
function findRefExpr(idJsExpr) {
|
|
21
|
+
return "(function(id){var f=function(r){var e=null;"
|
|
22
|
+
+ "try{e=r.querySelector('[data-ai-id=\"'+id+'\"]')}catch(x){e=null}"
|
|
23
|
+
+ "if(e)return e;"
|
|
24
|
+
+ "var a;try{a=r.querySelectorAll('*')}catch(x){return null}"
|
|
25
|
+
+ "for(var i=0;i<a.length;i++){var s=a[i].shadowRoot;if(!s)continue;var h=f(s);if(h)return h}"
|
|
26
|
+
+ "return null};return f(document)})(" + idJsExpr + ")";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 在**某个子树内**穿透影子根找一个后代(按选择器)。
|
|
30
|
+
//
|
|
31
|
+
// 为什么还需要这个:找到编辑器外壳(例如 B站 的 <bili-rich-text> 宿主)之后,
|
|
32
|
+
// 真正的 contenteditable 往往在它自己的 shadow root 里 —— `el.querySelector(
|
|
33
|
+
// '[contenteditable=true]')` 同样不跨边界,于是"清空校验"读到空、把已经清干净
|
|
34
|
+
// 的编辑器判成"没清掉",接着又删一遍。读回口径必须和定位口径一样深。
|
|
35
|
+
function findDescExpr(rootJsExpr, selectorJsExpr) {
|
|
36
|
+
return "(function(root,sel){var f=function(r){var e=null;"
|
|
37
|
+
+ "try{e=r.querySelector(sel)}catch(x){e=null}"
|
|
38
|
+
+ "if(e)return e;"
|
|
39
|
+
+ "var a;try{a=r.querySelectorAll('*')}catch(x){return null}"
|
|
40
|
+
+ "for(var i=0;i<a.length;i++){var s=a[i].shadowRoot;if(!s)continue;var h=f(s);if(h)return h}"
|
|
41
|
+
+ "return null};return f(root)})(" + rootJsExpr + "," + selectorJsExpr + ")";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { findRefExpr, findDescExpr };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/shared/frame_probe.cjs — 跨进程子帧(OOPIF)探测的纯函数部分
|
|
2
|
+
//
|
|
3
|
+
// 背景(B站图文编辑器隐形两小时的事故):站点把富文本编辑器放进跨进程
|
|
4
|
+
// iframe。同源 DOM 通路(preload extractor 的 contentDocument 递归)对
|
|
5
|
+
// OOPIF 恒返回 null,于是那部分内容在默认语义树里**完全不存在** —— 不是
|
|
6
|
+
// "读出来是空的",是连占位都没有。能救场的只有 AX 路径(getFullAXTree
|
|
7
|
+
// 走 CDP,天生跨帧),但调用方无从知道该切过去。
|
|
8
|
+
//
|
|
9
|
+
// 本模块只放纯函数:探测本身在 extractor 里(要 DOM),判定与摘要放这里
|
|
10
|
+
// (要可单测,且 page_manager 的自动路由要用同一把尺)。
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 汇总 extractor 收集到的不可读子帧。
|
|
16
|
+
* @param {Array<{id:string,src:string,width:number,height:number}>} frames
|
|
17
|
+
* @returns {{count:number, visible_count:number, suggestion:string|null}}
|
|
18
|
+
*/
|
|
19
|
+
function summarizeOpaqueFrames(frames) {
|
|
20
|
+
const list = Array.isArray(frames) ? frames.filter((f) => f && typeof f === 'object') : [];
|
|
21
|
+
const visible = list.filter((f) => (f.width || 0) > 0 && (f.height || 0) > 0);
|
|
22
|
+
if (!list.length) {
|
|
23
|
+
return { count: 0, visible_count: 0, suggestion: null };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
count: list.length,
|
|
27
|
+
visible_count: visible.length,
|
|
28
|
+
// 建议文案里必须出现 ax:true —— 那是唯一的补救通路,且此前无任何地方
|
|
29
|
+
// 告诉调用方它存在。
|
|
30
|
+
suggestion: 'page has cross-process iframe(s) whose content is invisible to the default DOM read path — retry with get_tree {ax:true} (CDP Accessibility reads across frames)',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 是否值得为这批帧自动补一次 AX 读取。
|
|
36
|
+
* 判定故意保守:只有"确有可见的不可读帧"才自动路由。不可见的帧(width/height
|
|
37
|
+
* 为 0,通常是埋点/广告帧)不值得让每次 get_tree 都多付一次 CDP 往返。
|
|
38
|
+
*/
|
|
39
|
+
function shouldAutoAx(summary) {
|
|
40
|
+
return !!(summary && typeof summary.visible_count === 'number' && summary.visible_count > 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 自动 AX 读取的结果如何写进 context.frame_hints。
|
|
45
|
+
* 三种结局分开说:合并成功 / 合并没有新内容 / AX 本身失败。调用方需要能
|
|
46
|
+
* 区分"已经尽力了"和"还有一条没走过的路"。
|
|
47
|
+
*/
|
|
48
|
+
function describeAutoAx(outcome) {
|
|
49
|
+
const o = outcome && typeof outcome === 'object' ? outcome : {};
|
|
50
|
+
if (o.merged) {
|
|
51
|
+
return { auto_ax: 'merged', frames_read: Number(o.framesRead) || 0 };
|
|
52
|
+
}
|
|
53
|
+
if (o.error) {
|
|
54
|
+
return { auto_ax: 'failed', reason: String(o.error) };
|
|
55
|
+
}
|
|
56
|
+
return { auto_ax: 'no-new-content' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 把子帧读到的"该填什么 / 错在哪"合进顶层 context。
|
|
61
|
+
*
|
|
62
|
+
* 为什么需要:extractPageContext 只在顶层文档里扫描,而真正要填的字段常常在
|
|
63
|
+
* 跨进程子帧里(B站图文的标题框、掘金编辑器)。于是 required_hints 和
|
|
64
|
+
* validation_errors 会在**最需要它们的那一类页面上**恰好为空 —— 这比没有这个
|
|
65
|
+
* 字段更糟,因为调用方会读成"这页没有必填项"。
|
|
66
|
+
*
|
|
67
|
+
* 纯函数:不读 DOM、不改入参。子帧来源的条目带 frame 字段,调用方能看出它
|
|
68
|
+
* 不是顶层文档说的。
|
|
69
|
+
*/
|
|
70
|
+
function mergeFrameContext(ctx, frames) {
|
|
71
|
+
const base = ctx && typeof ctx === 'object' ? ctx : {};
|
|
72
|
+
const hints = Array.isArray(base.required_hints) ? base.required_hints.slice() : [];
|
|
73
|
+
const ves = Array.isArray(base.validation_errors) ? base.validation_errors.slice() : [];
|
|
74
|
+
const seenHint = new Set(hints);
|
|
75
|
+
const seenVE = new Set(ves.map((e) => (e && e.near ? e.near : '') + '\u0000' + ((e && e.text) || '')));
|
|
76
|
+
let added = 0;
|
|
77
|
+
for (const f of frames || []) {
|
|
78
|
+
const fc = f && f.context;
|
|
79
|
+
if (!fc) continue;
|
|
80
|
+
const where = f.key || f.label || 'subframe';
|
|
81
|
+
for (const h of fc.required_hints || []) {
|
|
82
|
+
const t = String(h || '').trim();
|
|
83
|
+
if (!t || seenHint.has(t)) continue;
|
|
84
|
+
seenHint.add(t);
|
|
85
|
+
hints.push(t);
|
|
86
|
+
added += 1;
|
|
87
|
+
}
|
|
88
|
+
for (const e of fc.validation_errors || []) {
|
|
89
|
+
const text = String((e && e.text) || '').trim();
|
|
90
|
+
if (!text) continue;
|
|
91
|
+
const near = String((e && e.near) || '');
|
|
92
|
+
const key = near + '\u0000' + text;
|
|
93
|
+
if (seenVE.has(key)) continue;
|
|
94
|
+
seenVE.add(key);
|
|
95
|
+
ves.push({ text, near, frame: where });
|
|
96
|
+
added += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!added) return null;
|
|
100
|
+
const out = {};
|
|
101
|
+
if (hints.length) out.required_hints = hints.slice(0, 40);
|
|
102
|
+
if (ves.length) out.validation_errors = ves.slice(0, 30);
|
|
103
|
+
out.frame_context_merged = added;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
summarizeOpaqueFrames,
|
|
109
|
+
shouldAutoAx,
|
|
110
|
+
describeAutoAx,
|
|
111
|
+
mergeFrameContext,
|
|
112
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// findRefExpr 是这里的地基:定位本身也得能穿透 shadow,否则连 el 都拿不到,
|
|
3
|
+
// 命中判定再对也没用("树里有、点不动")。
|
|
4
|
+
const { findRefExpr } = require('./find_ref.cjs');
|
|
5
|
+
|
|
6
|
+
// shared/hit_point.cjs — "指针落在这个坐标上,到底会命中谁",**穿透影子根**。
|
|
7
|
+
//
|
|
8
|
+
// 为什么不能只用 document.elementFromPoint:它和 querySelector 一样,被 shadow
|
|
9
|
+
// boundary 挡住 —— 对影子内部的元素,它返回的是**宿主**(离目标最近的那个
|
|
10
|
+
// 自定义元素),不是元素本身。
|
|
11
|
+
//
|
|
12
|
+
// 实测证据(2026-10-18,B站 视频页评论区,整块装在 <bili-comments> 的 shadow 里):
|
|
13
|
+
// 树里报的 bounds => {x:733,y:377,w:24,h:24} ← 坐标本身是准的
|
|
14
|
+
// document.elementFromPoint(745,389) => bili-comments ← 宿主
|
|
15
|
+
// containsHit => false
|
|
16
|
+
// 于是"点击前先证明坐标会落在目标上"这道校验对影子内部元素**恒为假**,
|
|
17
|
+
// 点击只能回退到 preload 的 el.click()(非受信合成事件)。这在 CKEditor /
|
|
18
|
+
// ProseMirror / Draft.js 之类只认真实指针序列的编辑器上会直接失效,
|
|
19
|
+
// 而且"回退"这个事实不会出现在返回里 —— 看着成功,机制已经降级了。
|
|
20
|
+
//
|
|
21
|
+
// 修法:命中后逐级下钻。`ShadowRoot` 实现了 `DocumentOrShadowRoot`,所以
|
|
22
|
+
// `shadowRoot.elementFromPoint(x,y)` 是标准 API(不是 hack),坐标与
|
|
23
|
+
// document 版本同为视口坐标。一直钻到没有 shadowRoot 或钻不动为止。
|
|
24
|
+
//
|
|
25
|
+
// 配套的第二处修正:判定"命中即目标"必须用**含 shadow 的包含关系**。
|
|
26
|
+
// `Node.contains()` 不是 shadow-including 的 —— 隔着一层 shadow 它照样返回
|
|
27
|
+
// false(影子树是独立的 node tree)。上溯要写 `parentNode || ShadowRoot.host`。
|
|
28
|
+
//
|
|
29
|
+
// 隔离实验(tools/_oneoff/shadow_hittest.html + shadow_hittest_probe.cjs):
|
|
30
|
+
// A 裸元素 flat=A-plain deep=A-plain 旧判定 true 新判定 true
|
|
31
|
+
// B 一层 shadow flat=DIV(宿主) deep=B-inner 旧判定 false 新判定 true
|
|
32
|
+
// C 两层 shadow flat=DIV(宿主) deep=C-deep 旧判定 false 新判定 true
|
|
33
|
+
// D shadow 内被盖 flat=DIV deep=DIV 旧判定 false 新判定 false ← 阴性仍守住
|
|
34
|
+
// 新判定是旧判定的严格超集:任何原本为真的情形仍为真(命中链是目标的祖先链),
|
|
35
|
+
// 只是把"隔着 shadow 其实命中了"这一整类假阴性补回来。
|
|
36
|
+
|
|
37
|
+
// 下钻层数上限:防御性。真实页面不会嵌套 32 层 shadow,但万一某页自己套娃,
|
|
38
|
+
// 宁可停下返回宿主的命中结果,也不要在页面里转不出来(这个 JS 是注入到页面
|
|
39
|
+
// 上下文里同步跑的,死循环等于页面卡死)。
|
|
40
|
+
const MAX_DEPTH = 32;
|
|
41
|
+
|
|
42
|
+
// 最深命中元素。传进来的 x/y 是**已求值**的 JS 表达式字符串(例如 "745" 或 "c.x")。
|
|
43
|
+
function deepHitExpr(xJs, yJs) {
|
|
44
|
+
return '(function(x,y){var h=null;try{h=document.elementFromPoint(x,y);}catch(e){h=null}' +
|
|
45
|
+
'var g=0;' +
|
|
46
|
+
'while(h&&h.shadowRoot&&g++<' + MAX_DEPTH + '){' +
|
|
47
|
+
'var n=null;try{n=h.shadowRoot.elementFromPoint(x,y);}catch(e){n=null}' +
|
|
48
|
+
'if(!n||n===h)break;h=n;}' +
|
|
49
|
+
'return h;})(' + xJs + ',' + yJs + ')';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 含 shadow 的"anc 是不是 n 的(含自身)祖先"。上溯时遇到 ShadowRoot 就跳到 host。
|
|
53
|
+
function shadowAncestorOfExpr(ancJs, nodeJs) {
|
|
54
|
+
return '(function(anc,n){var c=n,g=0;while(c&&g++<128){if(c===anc)return true;' +
|
|
55
|
+
'c=c.parentNode||c.host||null;}return false;})(' + ancJs + ',' + nodeJs + ')';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 坐标会不会落在 el 上。el 用 data-ai-id 定位(本身已穿透 shadow,见 find_ref.cjs)。
|
|
59
|
+
// 判定:最深命中元素 === el,或 el 是它的含 shadow 祖先。
|
|
60
|
+
// **不能**反过来接受 hit.contains(el):那等于"父元素当然包含子元素",会把
|
|
61
|
+
// "按在了外层容器上"这种失败情形原样放行(拖拽连接点那处踩过,见 page_manager)。
|
|
62
|
+
function hitTestExpr(idJs, xJs, yJs) {
|
|
63
|
+
return '(function(){var el=' + findRefExpr(idJs) + ';if(!el)return false;' +
|
|
64
|
+
'var h=' + deepHitExpr(xJs, yJs) + ';if(!h)return false;' +
|
|
65
|
+
'return ' + shadowAncestorOfExpr('el', 'h') + ';})()';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "这个点上实际是哪个元素" —— 用于回执/错抓检测。
|
|
69
|
+
// 返回 {el:"TAG cls", id:最近一个带 data-ai-id 的含 shadow 祖先的 id} 或 null。
|
|
70
|
+
// 原来用 e.closest('[data-ai-id]'),同样跨不过 shadow:影子内部的元素 closest
|
|
71
|
+
// 走到影子树顶就停,永远返回 null,于是"抓错节点"的检测被静默跳过。
|
|
72
|
+
function hitInfoExpr(xJs, yJs) {
|
|
73
|
+
return '(function(){var e=' + deepHitExpr(xJs, yJs) + ';if(!e)return null;' +
|
|
74
|
+
'var c=e,id=null,g=0;' +
|
|
75
|
+
'while(c&&g++<128){if(c.getAttribute){var v=c.getAttribute("data-ai-id");if(v){id=v;break;}}' +
|
|
76
|
+
'c=c.parentNode||c.host||null;}' +
|
|
77
|
+
'return {el:(e.tagName||"")+" "+(typeof e.className==="string"?e.className:"").slice(0,60),id:id};})()';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { deepHitExpr, hitTestExpr, hitInfoExpr, shadowAncestorOfExpr, MAX_DEPTH };
|