@glittering0717/ai-browser 1.3.0 → 2.0.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.
@@ -0,0 +1,260 @@
1
+ // src/shared/frame_read_source.js — 跨进程子帧(OOPIF)内容读取器
2
+ //
3
+ // 这段代码**在子帧自己的 JS 上下文里执行**(frame.executeJavaScript),所以它
4
+ // 不能引用模块作用域的任何东西;也因此本文件用「真实函数 + toString」的形式
5
+ // 产出源码,而不是拼字符串 —— 拼字符串时正则里的 \s 会被模板串吃掉一层,
6
+ // 这类错误只有在页面里跑起来才暴露。
7
+ //
8
+ // ================== 为什么要有这个文件 ==================
9
+ // 原来 _readOopifFrames 里内联的选择器是:
10
+ // [contenteditable],[role=textbox],[role=combobox],textarea,
11
+ // input[type=text],input[type=title],input[type=search],button,
12
+ // [role=button],a[href]
13
+ // 它是一份**表单控件读取器**,为「B站图文编辑器住在 OOPIF 里」那一次事故写的。
14
+ // 于是:
15
+ // 1. `a[href]` 漏掉一切 JS 驱动的链接。超星泛雅的章节条目就是
16
+ // `<a onclick=...>` 且 href 为 null —— 实测该帧里 a 有 8 个、a[href] 只有 2 个,
17
+ // 章节列表整整 6 条一个都进不了树。
18
+ // 2. 纯文本/列表/表格结构完全没有采集通路。就算把锚点补齐,一个「没有可点
19
+ // 元素」的 frame(正文、成绩表)依然是空树 —— 而 frame_hints 还会说
20
+ // "merged",因为合并确实发生了(合并进去一个搜索框)。
21
+ //
22
+ // 现在改成一次合并查询、按文档序产出两类节点:
23
+ // - 可交互(链接/按钮/输入/富文本/ARIA 控件)→ 带 actions,可被 ui.act
24
+ // - 内容块(标题/列表项/段落/表格单元)→ 只读,用来让 agent **读懂**内容
25
+ // 并额外回报覆盖率(帧里可见文本多少字、其中多少字进了树),让"读到了"
26
+ // 变成可测量的结论而不是一句自我声明。
27
+
28
+ /**
29
+ * 子帧内容读取器(页面内执行)。
30
+ * @param {string} prefix 本帧的 ref 前缀,形如 "axf-2-"
31
+ * @returns {{url:string,title:string,nodes:Array,stats:object}|null}
32
+ */
33
+ function frameRead(prefix) {
34
+ if (typeof document === 'undefined' || !document || !document.body) return null;
35
+
36
+ // 上限是**给调用方省 token**,不是给采集省事:一帧正文能匹配上千个元素,
37
+ // 全塞进树里既贵又没用。覆盖率字段会如实告诉调用方"剩下的没进来"。
38
+ var MAX_SCAN = 1200; // 参与判定的候选元素上限(文档序)
39
+ var MAX_NODES = 200; // 最终产出节点上限
40
+ var LABEL_CAP = 120; // 单个 label 字符上限
41
+
42
+ var SEL = [
43
+ // 可编辑 / 表单
44
+ '[contenteditable]', 'textarea', 'select', 'input:not([type=hidden])',
45
+ '[role=textbox]', '[role=combobox]', '[role=listbox]',
46
+ // 可点
47
+ 'button', 'a', '[role=button]', '[role=link]', '[role=tab]',
48
+ '[role=menuitem]', '[role=option]', '[role=treeitem]',
49
+ '[role=checkbox]', '[role=switch]',
50
+ // 内容块
51
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role=heading]',
52
+ 'li', '[role=listitem]', 'p', 'dt', 'dd', 'blockquote', 'pre',
53
+ 'td', 'th', '[role=cell]', '[role=article]'
54
+ ].join(',');
55
+
56
+ function norm(s) {
57
+ return String(s == null ? '' : s).replace(/[\s\u00a0]+/g, ' ').trim();
58
+ }
59
+ function clip(s) {
60
+ return s.length > LABEL_CAP ? s.slice(0, LABEL_CAP - 1) + '\u2026' : s;
61
+ }
62
+ // 字数只数**非空白字符**。分母(innerText)和分子(label 累计)必须用同一把
63
+ // 尺子,否则中文页面的覆盖率会因为空格/换行/缩进横向失真:
64
+ // 一个 40 行、每行 3 个字的表格,按字符数算分母会虚高 2~3 倍。
65
+ // 主文档侧的 doc_coverage.js 用的是同一条规则,改一处必须改另一处。
66
+ function countChars(s) {
67
+ if (!s) return 0;
68
+ var m = String(s).match(/[^\s\u00a0]/g);
69
+ return m ? m.length : 0;
70
+ }
71
+
72
+ var els;
73
+ try { els = Array.prototype.slice.call(document.querySelectorAll(SEL)); }
74
+ catch (e) { return null; }
75
+ if (els.length > MAX_SCAN) els = els.slice(0, MAX_SCAN);
76
+
77
+ // 这一帧**看得见的文字**总量(覆盖率的分母)。提前算,因为"一个候选都没有
78
+ // 但正文有字"这种帧要单独对待:它往往意味着内容在更深一层的 iframe 里。
79
+ var bodyText = norm(document.body.innerText || '');
80
+ // 完全空白的帧返回 null(上层据此跳过,不往树里塞空壳)。
81
+ if (!els.length && !bodyText) return null;
82
+
83
+ // 第一趟:分类 + 过滤不可见。
84
+ var picked = [];
85
+ for (var k = 0; k < els.length; k++) {
86
+ var e = els[k];
87
+ var tag = String(e.tagName || '').toUpperCase();
88
+ var aria = (e.getAttribute && e.getAttribute('role')) || '';
89
+ var r;
90
+ try { r = e.getBoundingClientRect(); } catch (e2) { continue; }
91
+ // 不可见(display:none / 折叠 / 零尺寸)不进树 —— 这是唯一一条"存在但不算"的规则。
92
+ if (!r || r.width < 4 || r.height < 4) continue;
93
+
94
+ var editable = e.isContentEditable === true || tag === 'TEXTAREA' || tag === 'INPUT'
95
+ || tag === 'SELECT' || aria === 'textbox' || aria === 'combobox' || aria === 'listbox';
96
+ var clickable = tag === 'BUTTON' || tag === 'A'
97
+ || aria === 'button' || aria === 'link' || aria === 'tab' || aria === 'menuitem'
98
+ || aria === 'option' || aria === 'treeitem' || aria === 'checkbox' || aria === 'switch';
99
+
100
+ if (editable || clickable) {
101
+ var label = norm(e.getAttribute && (
102
+ e.getAttribute('aria-label') || e.getAttribute('placeholder') ||
103
+ e.getAttribute('title') || e.getAttribute('data-placeholder')));
104
+ if (!label && (tag === 'BUTTON' || tag === 'A' || clickable)) {
105
+ label = norm(e.innerText || e.textContent || '');
106
+ }
107
+ if (!label && tag === 'INPUT') label = norm(e.value || '');
108
+ var role;
109
+ if (tag === 'A' || aria === 'link') role = 'link';
110
+ else if (tag === 'BUTTON' || aria === 'button') role = 'button';
111
+ else if (tag === 'SELECT' || aria === 'combobox' || aria === 'listbox') role = 'select';
112
+ else if (aria === 'checkbox' || aria === 'switch') role = 'checkbox';
113
+ else if (aria === 'tab' || aria === 'menuitem' || aria === 'option' || aria === 'treeitem') role = 'button';
114
+ else role = 'textbox';
115
+ var editor_type = null;
116
+ if (e.isContentEditable) editor_type = 'richtext';
117
+ else if (tag === 'TEXTAREA') editor_type = 'textarea';
118
+ else if (tag === 'INPUT') editor_type = 'textbox';
119
+ var url = '';
120
+ // `href="javascript:void(0)"` 不是地址,是"点我"的另一种写法。照搬进树里
121
+ // 会让调用方以为可以拿它去 navigate(然后失败),也会让它以为这个链接有
122
+ // 真实目标。丢掉它,节点仍然是 link + click。
123
+ if (tag === 'A' && e.href && !/^\s*javascript:/i.test(String(e.href))) url = String(e.href);
124
+ picked.push({
125
+ el: e, interactive: true, role: role, label: clip(label), editor_type: editor_type, url: url,
126
+ bounds: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) }
127
+ });
128
+ continue;
129
+ }
130
+
131
+ // 内容块:用 innerText(含后代文字)。**不能用"只取直接文本"**:超星的
132
+ // "已完成任务点: <span>0</span>/4" 是 <h3> 里套 span,只取直接文本会得到
133
+ // "已完成任务点: /4" —— 数字丢了,而这正是这一行唯一有用的信息。
134
+ // 祖先重复的问题不靠这里解决,靠下面第二趟"祖先让位给后代"。
135
+ var t = norm(e.innerText || e.textContent || '');
136
+ if (!t) continue;
137
+ var crole = 'text';
138
+ if (tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6' || aria === 'heading') crole = 'heading';
139
+ else if (tag === 'LI' || aria === 'listitem') crole = 'listitem';
140
+ else if (tag === 'TD' || tag === 'TH' || aria === 'cell') crole = 'cell';
141
+ picked.push({
142
+ el: e, interactive: false, role: crole, label: clip(t),
143
+ bounds: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) }
144
+ });
145
+ }
146
+
147
+ // 第二趟:祖先让位给后代 —— 谁更具体谁留下。
148
+ //
149
+ // 实测(超星泛雅章节行):一行是
150
+ // <div role="link" tabindex="0" class="catalog_title">1.1 标题 <b>1</b></div>
151
+ // └─ <a class="clicktitle">1.1 标题</a>
152
+ // 两个都能点、文字几乎一样。全留下 = 6 个章节变成 12 个节点,全是噪声。
153
+ //
154
+ // 规则按"谁更可能是真正的动作目标"来分:
155
+ // 可交互 ← 可交互:留**里面那个**(里面的是 a/button,是最精确的目标;
156
+ // 外面那层通常是整行包装,点里面那层照样冒泡上去)
157
+ // 内容块 ← 任何后代:留后代(后代的文字更具体,祖先只是把它们拼在一起)
158
+ // 可交互 ← 内容块:留可交互,内容块让位(否则"链接"和它里面的"标题"各来一次)
159
+ var kept = [];
160
+ for (var i = 0; i < picked.length; i++) {
161
+ var it = picked[i];
162
+ var shadowed = false;
163
+ for (var j = 0; j < picked.length; j++) {
164
+ if (j === i) continue;
165
+ var o = picked[j];
166
+ if (o.el === it.el) continue;
167
+ try {
168
+ if (!it.el.contains(o.el)) continue; // o 不是 it 的后代
169
+ } catch (e3) { continue; /* 跨文档不会发生 */ }
170
+ if (o.interactive || !it.interactive) { shadowed = true; break; }
171
+ }
172
+ if (!shadowed) kept.push(it);
173
+ }
174
+
175
+ // 第二趟半:内容块如果被某个**活着的**可交互祖先包着,也让位。
176
+ var final = [];
177
+ for (var f = 0; f < kept.length; f++) {
178
+ var node0 = kept[f];
179
+ if (!node0.interactive) {
180
+ var underInteractive = false;
181
+ for (var g = 0; g < kept.length; g++) {
182
+ var o2 = kept[g];
183
+ if (g === f || !o2.interactive) continue;
184
+ try { if (o2.el.contains(node0.el)) { underInteractive = true; break; } } catch (e5) { /* ignore */ }
185
+ }
186
+ if (underInteractive) continue;
187
+ }
188
+ final.push(node0);
189
+ }
190
+ kept = final;
191
+
192
+ // 第三趟:产出节点。**两类都写 data-ai-id**:树里出现的 ref 必须能用
193
+ // ui.act / ui.evaluate 路由回这个帧(见 page_manager._owningFrame)。
194
+ // 出现"树里有、点了说找不到"的 ref,比不出现更伤人。
195
+ var out = [];
196
+ for (var m = 0; m < kept.length && out.length < MAX_NODES; m++) {
197
+ var node = kept[m];
198
+ var idx = out.length;
199
+ var id = prefix + idx;
200
+ try { node.el.setAttribute('data-ai-id', id); } catch (e4) { continue; }
201
+ var rec = {
202
+ id: id,
203
+ role: node.role,
204
+ label: node.label || '',
205
+ bounds: node.bounds
206
+ };
207
+ if (node.url) rec.url = node.url;
208
+ if (node.editor_type) {
209
+ rec.editor_type = node.editor_type;
210
+ rec.states = ['editable=' + node.editor_type, 'focusable'];
211
+ } else if (node.interactive) {
212
+ rec.states = ['focusable'];
213
+ } else {
214
+ // 只读内容:显式标出来,调用方不会去点一个标题再奇怪为什么没反应。
215
+ rec.states = ['readonly'];
216
+ }
217
+ out.push(rec);
218
+ }
219
+
220
+ var keptChars = 0;
221
+ var texts = 0;
222
+ for (var n = 0; n < out.length; n++) {
223
+ // 可编辑节点的 label 是占位符("请输入标题"),不是页面正文 —— 不计入
224
+ // 覆盖率分子,否则一个带 placeholder 的搜索框就能把覆盖率虚抬上去。
225
+ if (out[n].editor_type) continue;
226
+ keptChars += countChars(out[n].label);
227
+ if ((out[n].states || [])[0] === 'readonly') texts += 1;
228
+ }
229
+ var bodyChars = countChars(bodyText);
230
+
231
+ return {
232
+ url: (typeof location !== 'undefined' && location.href) || '',
233
+ title: (typeof document.title === 'string') ? document.title : '',
234
+ nodes: out,
235
+ stats: {
236
+ candidates: els.length,
237
+ interactive: out.length - texts,
238
+ text_blocks: texts,
239
+ text_chars: bodyChars,
240
+ kept_chars: keptChars,
241
+ // 覆盖率:这一帧**看得见的文字**里,有多少进了树。1 = 全进;
242
+ // 0.01 = 树里只有一个搜索框,正文一个字都没读到(泛雅事故现场)。
243
+ coverage: bodyChars
244
+ ? Math.round(Math.min(1, keptChars / bodyChars) * 100) / 100
245
+ : (out.length ? 1 : 0),
246
+ }
247
+ };
248
+ }
249
+
250
+ /**
251
+ * 产出可直接喂给 `frame.executeJavaScript()` 的源码。
252
+ * 用 toString() 而不是拼字符串:正则、模板、引号全部保持原样,不会在拼装层
253
+ * 被转义吃掉一层(拼字符串的版本踩过 `\s` 变成 `s`)。
254
+ */
255
+ export function buildFrameReadSource(prefix) {
256
+ return '(' + frameRead.toString() + ')(' + JSON.stringify(String(prefix || '')) + ')';
257
+ }
258
+
259
+ export { frameRead };
260
+ export default { buildFrameReadSource, frameRead };
@@ -0,0 +1,71 @@
1
+ // shared/opened_tab.js — 判断"这条新标签记录是不是本次动作造成的"
2
+ //
3
+ // 背景(2026-10-19 实测):_openedTabs 记录"某个 tab 打开了新标签",供
4
+ // executeAction 回报 opened_tab 并自动切换过去。但记录的产生方不止 act:
5
+ // • window.open 垫片 / setWindowOpenHandler 也会写这条记录
6
+ // • 如果那次 window.open 是 ui.evaluate 里 el.click() 触发的,就**没有人消费它**
7
+ // 于是陈旧记录会挂在 map 里,被下一次点击或悬停误认为"我刚开了个标签"。
8
+ // 实测症状:在课程列表页点过「进入学习」之后,随后的 hover 回执里
9
+ // opened_tab 指向那个 about:blank —— 一个与本次操作毫无关系的标签。
10
+ //
11
+ // 规则:只有**在本次动作开始之后**记录的条目才算数。时间戳由 act 入口打,
12
+ // 这里只做纯判定,便于单测。
13
+
14
+ /**
15
+ * @param {{id:number,url:string,at?:number}|null|undefined} entry 已取出的记录
16
+ * @param {number|undefined} startedAt 本次动作的开始时刻(ms)
17
+ * @returns {object|null} 属于本次动作的记录,否则 null
18
+ */
19
+ export function pickOpenedTab(entry, startedAt) {
20
+ if (!entry) return null;
21
+ // startedAt 缺省时保持旧行为(调用方没提供时间基准,不做时间过滤)
22
+ if (startedAt === undefined) return entry;
23
+ // 没有时间戳的旧记录一律视为陈旧:宁可漏报一次,也不要把别的动作的标签
24
+ // 算到这次头上 —— 后者会让 agent 去操作一个它没打开过的页面。
25
+ if (typeof entry.at !== 'number') return null;
26
+ return entry.at >= startedAt ? entry : null;
27
+ }
28
+
29
+ // ── 「等一下再收」的宽限窗口 ──────────────────────────────────────────────
30
+ //
31
+ // 2026-10-19 实测(西电专技课程页点「进入学习」,站点代码是
32
+ // `var w = window.open("","_blank"); w.location = res.url;`):
33
+ //
34
+ // act 返回 10~15 ms
35
+ // 新标签出现 105 / 214 / 236 ms ← 三轮,**不稳定**
36
+ //
37
+ // 第一轮测到的 105~119ms(三轮)是热缓存下的假稳定,加了等待之后复测就散到
38
+ // 200ms 以上了 —— 站点是先 fetch 拿到地址再 window.open,所以延迟跟着网络走。
39
+ // 这条推翻了我最初"固定 100ms 就够"的判断,记在这里免得下次又按那个数设计。
40
+ //
41
+ // 结论:**单靠一个窗口不可能既覆盖又不拖慢**,所以配两个提前退出条件:
42
+ // 1. 已经有记录 → 立刻返回(零等待);
43
+ // 2. 本 tab 开始原地导航(isLoading)→ 立刻返回。站内跳转不会同时开新标签,
44
+ // 而这一类点击恰恰是最高频的,于是最常见的情况根本不会付这笔时间。
45
+ // 剩下的(点了按钮、页面既没导航也没开标签)才等满窗口。
46
+ //
47
+ // 为什么是固定窗口而不是"等到页面安静":没有可靠的"安静"信号 —— 页面自己的
48
+ // 定时器、动画、轮询会让它永远不安静(教训见 hover 的收菜单那段注释)。
49
+ //
50
+ // 代价要说明白:窗口大小直接乘以"无副作用的点击次数"。所以它必须可关:
51
+ // · `opened_tab_wait_ms: 0` 单次调用关掉
52
+ // · `AI_BROWSER_OPENED_TAB_WAIT_MS=0` 全局关掉(跑基准测试时用)
53
+ export const DEFAULT_OPENED_TAB_GRACE_MS = 250;
54
+ const MAX_GRACE_MS = 2000;
55
+
56
+ /**
57
+ * 本次点击应该为"新标签记录"等多久。
58
+ *
59
+ * @param {object} [params] act 的参数体
60
+ * @param {NodeJS.ProcessEnv|object} [env] 便于测试注入
61
+ * @returns {number} 毫秒,0 表示不等
62
+ */
63
+ export function openedTabGraceMs(params, env) {
64
+ const explicit = params && Number(params.opened_tab_wait_ms);
65
+ if (Number.isFinite(explicit) && explicit >= 0) return Math.min(explicit, MAX_GRACE_MS);
66
+ const e = env || (typeof process !== 'undefined' ? process.env : null);
67
+ const fromEnv = e && Number(e.AI_BROWSER_OPENED_TAB_WAIT_MS);
68
+ if (Number.isFinite(fromEnv) && fromEnv >= 0) return Math.min(fromEnv, MAX_GRACE_MS);
69
+ return DEFAULT_OPENED_TAB_GRACE_MS;
70
+ }
71
+
@@ -0,0 +1,99 @@
1
+ // shared/ref_epoch.js — 给「目标找不到」补上一个关键区分:**ref 过期了,还是从来没对过**。
2
+ //
3
+ // 问题(真实排查记录):agent 从 get_tree 拿到 `e:button-12`,点下去回
4
+ // `target_not_found`。它面对两种完全不同的处境,却收到同一句话:
5
+ // a) 页面在这两次调用之间变了(SPA 重渲染、弹窗重开、列表翻页)→ 老 ref 作废,
6
+ // 正确做法是**重新 get_tree 拿新 ref**;
7
+ // b) ref 从头就没对过(传了可见文字、抄错、来自另一个标签页)→ 重新 get_tree
8
+ // 也没用,要改的是传参方式。
9
+ // 分不清这两者时,agent 的典型反应是"重试同一个 ref"(因为错误信息里没说要换),
10
+ // 把一次本来 1 步的恢复拖成 3~5 步。
11
+ //
12
+ // 做法:每次 get_tree 成功就推进一个 per-tab 的 epoch,并记下这一版树里出现过的所有
13
+ // ref 属于哪一版。之后 ref 找不到时:
14
+ // · ref 在某版树里出现过,且那版 < 当前版 → **stale**(过期),让它重读树;
15
+ // · ref 一次都没出现过 → **never_seen**(形状/来源错),让它改传参;
16
+ // · ref 就在当前版里,DOM 却查不到 → **present_in_tree**(树里有、点不到),
17
+ // 通常是在 iframe / shadow root 里,或节点已脱离文档 —— 换读法,不是换 ref。
18
+ //
19
+ // 边界诚实说明:这个追踪只知道**本进程内 get_tree 见过什么**。如果 agent 从别处
20
+ // 拿到 ref(自己拼的、上一次会话的),这里只会判成 never_seen —— 那也正是我们想让
21
+ // 它知道的事实。
22
+
23
+ /** 一个 tracker 管一个标签页。 */
24
+ export function newTracker() {
25
+ return { epoch: 0, seen: new Map() }; // ref -> 见过它的最后一次 epoch
26
+ }
27
+
28
+ /**
29
+ * 从语义树里收集所有 ref。节点的 id 就是 data-ai-id。
30
+ * 只收字符串型 id;缺 id 的容器节点跳过。
31
+ */
32
+ export function collectRefs(tree) {
33
+ const refs = [];
34
+ (function walk(n) {
35
+ if (!n || typeof n !== 'object') return;
36
+ if (typeof n.id === 'string' && n.id) refs.push(n.id);
37
+ const kids = n.children;
38
+ if (Array.isArray(kids)) for (const k of kids) walk(k);
39
+ })(tree);
40
+ return refs;
41
+ }
42
+
43
+ /**
44
+ * 推进 epoch 并登记这一版树里出现过的 ref。
45
+ * @returns {number} 新的 epoch(从 1 开始,0 表示"还没有任何一版树")
46
+ */
47
+ export function bumpEpoch(tracker, refs) {
48
+ tracker.epoch += 1;
49
+ for (const r of refs) if (typeof r === 'string' && r) tracker.seen.set(r, tracker.epoch);
50
+ return tracker.epoch;
51
+ }
52
+
53
+ /** 便捷入口:直接吃一棵树。 */
54
+ export function noteTree(tracker, tree) {
55
+ return bumpEpoch(tracker, collectRefs(tree));
56
+ }
57
+
58
+ export const REF_STALE = 'stale';
59
+ export const REF_NEVER_SEEN = 'never_seen';
60
+ export const REF_PRESENT_IN_TREE = 'present_in_tree';
61
+
62
+ const HINTS = {
63
+ [REF_STALE]: (o) =>
64
+ `stale_ref: this ref was in semantic-tree v${o.ref_epoch}, the page has since been re-read (now v${o.tree_epoch}). ` +
65
+ 'Re-run get_tree and use a fresh ref — retrying the same one will keep failing.',
66
+ [REF_NEVER_SEEN]: (o) =>
67
+ `ref_never_seen: this ref is not in any of the ${o.tree_epoch} semantic tree(s) read so far. ` +
68
+ 'Most likely you passed visible text instead of a data-ai-id, mistyped it, or took it from another tab. ' +
69
+ 'Reading the tree again will not help by itself — fix the ref.',
70
+ [REF_PRESENT_IN_TREE]: (o) =>
71
+ `ref_present_in_tree: the ref is in the current tree (v${o.tree_epoch}) but the DOM query found nothing. ` +
72
+ 'It is probably inside an iframe / shadow root, or has been detached. Try the frame-aware read, or pick a nearby stable element.',
73
+ };
74
+
75
+ /**
76
+ * 诊断一个找不到的 ref。
77
+ *
78
+ * @param {string} ref
79
+ * @param {{epoch:number, seen:Map<string,number>}} tracker
80
+ * @returns {{stale_ref:boolean, ref_epoch:number|null, tree_epoch:number, reason:string, hint:string}}
81
+ */
82
+ export function diagnoseMissingRef(ref, tracker) {
83
+ const treeEpoch = (tracker && tracker.epoch) || 0;
84
+ const seenEpoch = tracker && tracker.seen ? tracker.seen.get(String(ref)) : undefined;
85
+
86
+ let reason;
87
+ if (seenEpoch === undefined || treeEpoch === 0) reason = REF_NEVER_SEEN;
88
+ else if (seenEpoch < treeEpoch) reason = REF_STALE;
89
+ else reason = REF_PRESENT_IN_TREE;
90
+
91
+ const info = { ref_epoch: seenEpoch === undefined ? null : seenEpoch, tree_epoch: treeEpoch };
92
+ return {
93
+ stale_ref: reason === REF_STALE,
94
+ ref_epoch: info.ref_epoch,
95
+ tree_epoch: info.tree_epoch,
96
+ reason,
97
+ hint: HINTS[reason](info),
98
+ };
99
+ }
@@ -0,0 +1,33 @@
1
+ // shared/tab_hooks.js — 给 pageManager.setActive 挂副作用钩子,且**不吞返回值**。
2
+ //
3
+ // 为什么要单独抽出来(2026-10-19 实测事故):
4
+ // index.js 为了「地址栏状态跟随激活 tab」在启动时给 setActive 套了一层包装:
5
+ // const orig = pageManager.setActive.bind(pageManager);
6
+ // pageManager.setActive = function (tabId) { orig(tabId); wireNavState(tabId); };
7
+ // 包装里忘了 return。setActive 原本 return true/false,是**协议的一部分**:
8
+ // ws_server.js 里 `const ok = pageManager.setActive(params.tab)` 再回
9
+ // `{ ok }` 给客户端。丢掉返回值 → ok 变 undefined → JSON.stringify 直接把这个
10
+ // 键删掉 → agent 收到 `{}`,拿不到"切成功了没"的任何确认。
11
+ //
12
+ // 这个 bug 极难在端到端里看出来:tab 实际上**切成功了**(list_tabs 显示 active
13
+ // 已变),只有回执是空的。所以修法不只是补一个 return,而是把"套钩子"这件事
14
+ // 变成一个被测过的函数,以后再有人往上挂副作用,返回值天然被保住。
15
+ //
16
+ // 契约(tests/test_tab_hooks.js 钉住):
17
+ // 1. 返回值原样透传(true / false / 任何值);
18
+ // 2. 钩子在**原方法之后**执行 —— 它要读 activeTab 这类被原方法改过的状态;
19
+ // 3. 原方法抛异常时钩子不执行,异常照常向上抛(不能掩盖真错误);
20
+ // 4. this 绑定到原 pageManager(原方法可能是 class 方法,用到 this)。
21
+ export function wrapSetActive(pageManager, onAfter) {
22
+ if (!pageManager || typeof pageManager.setActive !== 'function') {
23
+ throw new TypeError('wrapSetActive: pageManager.setActive is not a function');
24
+ }
25
+ const orig = pageManager.setActive.bind(pageManager);
26
+ const wrapped = function (tabId) {
27
+ const result = orig(tabId);
28
+ if (typeof onAfter === 'function') onAfter(tabId, result);
29
+ return result;
30
+ };
31
+ pageManager.setActive = wrapped;
32
+ return wrapped;
33
+ }
@@ -2,4 +2,4 @@
2
2
  // Kept in JS (not imported from package.json) to avoid Node import-attribute
3
3
  // (JSON module) compatibility issues inside Electron. package.json "version"
4
4
  // is manually synced to this value; tests assert they match.
5
- export const VERSION = '1.3.0';
5
+ export const VERSION = '2.0.0';
@@ -81,12 +81,38 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
81
81
  // noIdChain 自己和整条祖先链都没有 id → **真缺口**
82
82
  // 真缺口 = ambiguous + noIdChain。
83
83
  const COUNT_JS = `(function(){
84
- var INTER = 'a[href],button,input,select,textarea,summary,details,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[contenteditable=true]';
84
+ // a[href] 换成 a:无 href 的锚点(onclick 驱动)也是可点目标。超星泛雅的章节
85
+ // 条目就是 <a onclick>,href 为 null —— 只认 a[href] 会把它们从**分母**里删掉,
86
+ // 于是覆盖率虚高。帧读取器(shared/frame_read_source.js)早先踩过同一个坑。
87
+ var INTER = 'a,button,input,select,textarea,summary,details,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[contenteditable=true]';
85
88
  function coveredByDesc(el, r){
86
89
  var hits;
87
90
  try { hits = el.querySelectorAll(INTER); } catch(e) { return false; }
88
- if (!hits || hits.length !== 1) return false;
89
- var inner = hits[0];
91
+ if (!hits || hits.length === 0) return false;
92
+ // 只算**可见的、最内层的**可交互元素:
93
+ // - 不可见的(隐藏 input、0×0 的埋点控件)不是人能看到的目标;
94
+ // - 最内层的(不再包含别的可交互元素)才是真正的动作目标,外面那层是包装。
95
+ // 不这么筛,"这一行里到底有几个可点的东西"永远数不清 —— 实测超星章节行
96
+ // 里同时有 2 个隐藏 input + 行容器 div[role=link] + 标题 a,共 4 个命中,
97
+ // 于是"唯一可交互后代"这个前提永远不成立,6 个章节被永久误报成真缺口。
98
+ var live = [];
99
+ for (var i = 0; i < hits.length; i++) {
100
+ var h = hits[i];
101
+ var hr = h.getBoundingClientRect();
102
+ if (!(hr.width > 0) || !(hr.height > 0)) continue;
103
+ live.push(h);
104
+ }
105
+ var leaves = [];
106
+ for (var k = 0; k < live.length; k++) {
107
+ var nest = false;
108
+ for (var m = 0; m < live.length; m++) {
109
+ if (m === k || live[m] === live[k]) continue;
110
+ if (live[k].contains(live[m])) { nest = true; break; }
111
+ }
112
+ if (!nest) leaves.push(live[k]);
113
+ }
114
+ if (leaves.length !== 1) return false;
115
+ var inner = leaves[0];
90
116
  if (!inner.hasAttribute('data-ai-id')) return false;
91
117
  var b = inner.getBoundingClientRect();
92
118
  if (!(b.width > 0) || !(b.height > 0)) return false;
@@ -94,10 +120,26 @@ const COUNT_JS = `(function(){
94
120
  var h = Math.min(r.bottom, b.bottom) - Math.max(r.top, b.top);
95
121
  if (!(w > 0) || !(h > 0)) return false;
96
122
  var inter = w * h;
97
- // 与 src/preload/extractor.cjs 的 wrapsSingleInteractive 同一判据:
98
- // 内层要「盖满」外层,两个方向都 >= 90%。只看交集/内层面积会把
99
- // "大卡片里放个小链接"误判成壳子(点卡片 ≠ 点那个链接)。
100
- return inter / (b.width * b.height) >= 0.9 && inter / (r.width * r.height) >= 0.9;
123
+ // 判据一(几何):内层要「盖满」外层,两个方向都 >= 90%。与
124
+ // src/preload/extractor.cjs 的 wrapsSingleInteractive 同一口径。
125
+ // 只看交集/内层面积会把"大卡片里放个小链接"误判成壳子(点卡片 ≠ 点那个链接)。
126
+ if (inter / (b.width * b.height) >= 0.9 && inter / (r.width * r.height) >= 0.9) return true;
127
+ // 判据二(文本):外层的文字**就是**这一个链接的文字(可能多个角标)。
128
+ // 2026-10-19 实测(超星泛雅章节行):外层是整行
129
+ // <div class="chapter_item" role="link">1.1 标题 <b>1</b></div>
130
+ // 内层是 <a>1.1 标题</a>,宽 996 / 行宽 1100 —— 几何上差 5% 不过线,于是
131
+ // 6 个章节被报成"真缺口"。但点内层 a 会冒泡到整行,实测
132
+ // act{axf-1-4} → clicked_via:"oopif-click", effect_state:"committed"。
133
+ // 这里比抽取层**故意松一点**,因为两边错的方向不同:抽取层判错会**丢掉**
134
+ // 一个目标(不可逆),这里判松只是让百分比略微乐观(可复查)。
135
+ // 松的条件是有界的:唯一可交互后代 + 外层文字以内层文字开头 + 长度接近。
136
+ // "大卡片里放个小链接"过不了这三条(文字开头就不是它)。
137
+ try {
138
+ var ot = (el.innerText || '').replace(/\s+/g, ' ').trim();
139
+ var it = (inner.innerText || '').replace(/\s+/g, ' ').trim();
140
+ if (ot && it && ot.indexOf(it) === 0 && it.length >= ot.length * 0.5) return true;
141
+ } catch (e2) { /* 拿不到文字就按几何判据走 */ }
142
+ return false;
101
143
  }
102
144
  function scan(onlyBoundary){
103
145
  var s = {pointer: 0, inTree: 0, byDesc: 0, ancOnly: 0, ambiguous: 0, noIdChain: 0, missing: []};
@@ -179,8 +221,51 @@ const COUNT_JS = `(function(){
179
221
 
180
222
  const r = await c.call('ui.evaluate', { tab: TAB, js: COUNT_JS }, 60000);
181
223
  const raw2 = JSON.parse(String(r.value));
182
- const d = raw2.boundary; // 人的视角:手型边界
183
- const e = raw2.el; // 元素视角:和此前数据可比,但分母被继承放大了
224
+
225
+ // ---- 跨进程帧:分母必须包含它们,否则这个数字会骗人 ----
226
+ // 2026-10-19 实测(超星泛雅学习页):本工具报「可点目标 4,可用率 100%、
227
+ // 真缺口 0」,而**整门课程的章节列表一个都没进树** —— 因为那 6 个条目住在
228
+ // 跨进程 iframe 里,而 COUNT_JS 只在顶层 document 里数。
229
+ // 100% 覆盖了 4 个按钮,同时 100% 看不见正文。
230
+ // 现在的口径:每个 `frame-N` 门节点都单独数一遍再合并,数不到的明确报出来,
231
+ // 不许静默排除 —— 分母把"我没测的地方"藏起来,比测错更危险。
232
+ const gates = [];
233
+ (function walkGates(n) {
234
+ if (!n) return;
235
+ if (n.role === 'Iframe' && /^frame-\d+$/.test(String(n.id || ''))) gates.push(n.id);
236
+ (n.children || []).forEach(walkGates);
237
+ })(root);
238
+ const perFrame = [];
239
+ for (const g of gates) {
240
+ try {
241
+ const fr = await c.call('ui.evaluate', { tab: TAB, js: COUNT_JS, frame_ref: g }, 60000);
242
+ const pj = JSON.parse(String(fr.value));
243
+ perFrame.push({ ref: g, boundary: pj.boundary });
244
+ } catch (err) {
245
+ perFrame.push({ ref: g, error: String((err && err.message) || err) });
246
+ }
247
+ }
248
+ const emptyScan = () => ({ pointer: 0, inTree: 0, byDesc: 0, ancOnly: 0, ambiguous: 0, noIdChain: 0, missing: [] });
249
+ const num = (o, k) => (o && Number.isFinite(Number(o[k])) ? Number(o[k]) : 0);
250
+ function addInto(acc, one, where) {
251
+ for (const k of ['pointer', 'inTree', 'byDesc', 'ancOnly', 'ambiguous', 'noIdChain']) acc[k] += num(one, k);
252
+ for (const m of (one && one.missing) || []) acc.missing.push(where ? { ...m, frame: where } : m);
253
+ }
254
+ // 顶层 + 每个可读帧 = 完整分母(人的视角 / 元素视角各算一份)
255
+ const d = emptyScan();
256
+ const e = emptyScan();
257
+ addInto(d, raw2.boundary, null);
258
+ addInto(e, raw2.el, null);
259
+ for (const pf of perFrame) {
260
+ if (pf.error || !pf.boundary) continue;
261
+ addInto(d, pf.boundary, pf.ref);
262
+ // 帧内没有单独的"元素口径"(继承放大在帧内影响很小),与主口径同源
263
+ addInto(e, pf.boundary, pf.ref);
264
+ }
265
+ const framesRead = perFrame.filter((x) => !x.error).length;
266
+ const framesFailed = perFrame.filter((x) => x.error);
267
+ const opaque = Number((raw.context && raw.context.cross_process_frames && raw.context.cross_process_frames.visible_count) || 0);
268
+ const unmeasured = Math.max(0, opaque - gates.length);
184
269
  const pct = (n, tot) => (tot ? Math.round((n / tot) * 100) : null);
185
270
 
186
271
  const report = {
@@ -197,6 +282,14 @@ const COUNT_JS = `(function(){
197
282
  coverage_pct: pct(d.inTree, d.pointer),
198
283
  usable_pct: pct(d.inTree + d.byDesc + d.ancOnly, d.pointer),
199
284
  blind: d.ambiguous + d.noIdChain,
285
+ // 跨进程帧的计量状态:读了几个、失败几个、还有几个根本没被读到。
286
+ // unmeasured > 0 时,上面的百分比只覆盖了顶层文档 + 已读的帧。
287
+ frames: {
288
+ measured: framesRead,
289
+ failed: framesFailed,
290
+ unmeasured,
291
+ details: perFrame.map((x) => (x.error ? { ref: x.ref, error: x.error } : { ref: x.ref, pointer: num(x.boundary, 'pointer') })),
292
+ },
200
293
  // 次要口径:所有 cursor:pointer 元素(含继承),仅供与历史数据对比
201
294
  legacy: {
202
295
  pointer_elements: e.pointer,
@@ -218,6 +311,15 @@ const COUNT_JS = `(function(){
218
311
  console.log(' ├ 祖先歧义(同一祖先下多个手型后代,点祖先指不准):', d.ambiguous);
219
312
  console.log(' └ 整条祖先链无 id(真看不见):', d.noIdChain);
220
313
  console.log('可用率(in_tree + by_desc + anc_only):', report.usable_pct ?? 'n/a', '%', '| 真缺口:', report.blind);
314
+ console.log('跨进程帧(已计入分母):', framesRead, '个'
315
+ + (framesFailed.length ? ',读取失败 ' + framesFailed.length + ' 个' : '')
316
+ + (unmeasured ? ',**未测量 ' + unmeasured + ' 个**' : ''));
317
+ if (framesFailed.length) {
318
+ for (const f of framesFailed) console.log(' ! ' + f.ref + ' 读不到: ' + f.error);
319
+ }
320
+ if (unmeasured) {
321
+ console.log(' ! 上面这批百分比不含未测量的帧 —— 分母把没测的地方藏起来,比测错更危险');
322
+ }
221
323
  console.log('(历史口径 cursor:pointer 元素数 ' + e.pointer + ',覆盖 ' + (report.legacy.coverage_pct ?? 'n/a') + '%)');
222
324
  if (d.missing.length) {
223
325
  console.log('\n没进树的可点目标(ancOnly 已排除,前 40 个):');