@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.
@@ -5,6 +5,10 @@
5
5
  var valueContract = require("../shared/value_contract.cjs");
6
6
  var truncateNodeValue = valueContract.truncateNodeValue;
7
7
  var sensitiveInputKind = valueContract.sensitiveInputKind;
8
+ var authWallLib = require("../shared/auth_wall.cjs");
9
+ // 跨进程子帧(OOPIF)探测的纯函数部分。extractor 负责收集(要 DOM),
10
+ // 这里负责汇总与"该不该自动切 AX"的判定(要可单测)。
11
+ var frameProbe = require("../shared/frame_probe.cjs");
8
12
 
9
13
  // ============================================================
10
14
  // 1. EXTRACT TREE — DOM → semantic tree with bounds + states
@@ -14,10 +18,48 @@ var sensitiveInputKind = valueContract.sensitiveInputKind;
14
18
  // (旧元素沿用自身 data-ai-id,新元素却可能生成出同名的号),所以它只增不减。
15
19
  var AI_ID_SEQ = 0;
16
20
 
21
+ // ---- 必填 / 已判错(P2-2)----------------------------------------------
22
+ // 这两条判定同时服务两处输出:语义树的 node.states 与 context.fields[].required。
23
+ // 两个地方必须给同一个答案 —— 树里说"可选"而 context 说"必填",比只给一处更糟。
24
+ //
25
+ // required 只认承诺级别的信号:原生 required 属性、aria-required="true"。
26
+ // 不用红星/文案猜(那是 context.required_hints 的启发式,语义不同)。
27
+ function isRequiredField(el) {
28
+ if (!el || typeof el.getAttribute !== "function") return false;
29
+ if (el.required === true) return true;
30
+ return el.getAttribute("aria-required") === "true";
31
+ }
32
+
33
+ // invalid 同样只认"页面自己说了算"的信号:
34
+ // 1) aria-invalid(true / grammar / spelling;只有字面 "false" 才算有效)
35
+ // 2) 控件自身或近祖先的常见错误态 class(bootstrap 的 has-error、antd 的
36
+ // ant-form-item-has-error、is-invalid 等)
37
+ // 刻意**不**用 el.matches(":invalid"):必填项一旦为空,浏览器在用户还没动手之前
38
+ // 就把它算作 :invalid —— 那样每个空必填框都会顶着 invalid,信号变成噪声。
39
+ function isInvalidField(el) {
40
+ if (!el || typeof el.getAttribute !== "function") return false;
41
+ var ai = el.getAttribute("aria-invalid");
42
+ if (ai && ai !== "false") return true;
43
+ var ERROR_STATE_RE = /(^|[\s_-])(is-invalid|invalid|has-error|error-state|field-error)($|[\s_-])/i;
44
+ var n = el;
45
+ for (var d = 0; n && d < 3; d++) {
46
+ var cls = n.className;
47
+ if (typeof cls === "string" && ERROR_STATE_RE.test(cls)) return true;
48
+ n = n.parentElement;
49
+ }
50
+ return false;
51
+ }
52
+
17
53
  function extractTree() {
18
54
  var seen = new WeakSet();
19
55
  var counter = 0;
20
56
  var VERSION_TAG = "v6.9";
57
+ // 本次抽取中遇到的、内容读不到的 iframe(跨进程子帧)。汇总后由
58
+ // extractPageContext 写进 context.cross_process_frames。
59
+ var opaqueFrames = [];
60
+ // process() 与文末 fallback 显式扫描会各遇到一次同一个 OOPIF,用这个集合
61
+ // 去重(不依赖 DOM 顺序,也不往页面里写任何标记)。
62
+ var seenOpaque = new WeakSet();
21
63
 
22
64
  // 元素自己所在的 window。跨文档时必须用它:
23
65
  // `el instanceof HTMLElement` 里 HTMLElement 是**顶层 window**的构造函数,
@@ -40,8 +82,30 @@ function extractTree() {
40
82
  }
41
83
  }
42
84
 
43
- function isHidden(el) {
44
- if (!isHtmlElement(el)) return true;
85
+ // 真正的焦点元素 —— 穿透 shadow root 取最里层。
86
+ //
87
+ // `document.activeElement` 在 Web Component 里只到**宿主**为止:焦点实际在
88
+ // 宿主 shadow root 里那个 contenteditable 上,但顶层读到的是宿主本身。于是
89
+ // 树里没有任何节点能被标成 "focused"(宿主 !== 里层输入框),agent 看不到
90
+ // "光标现在在哪",而这是判断"接下来 type 会落到哪"的唯一依据。
91
+ // 一次抽取内只算一次(缓存),因为每个节点都要问。
92
+ var _deepActive,
93
+ _deepActiveDone = false;
94
+ function deepActive() {
95
+ if (_deepActiveDone) return _deepActive;
96
+ _deepActiveDone = true;
97
+ var e = null;
98
+ try { e = document.activeElement; } catch (x) { return null; }
99
+ // 防御页面构造的循环引用;shadow 嵌套深度实际远小于 10。
100
+ var g = 0;
101
+ while (e && e.shadowRoot && e.shadowRoot.activeElement && g++ < 10) {
102
+ e = e.shadowRoot.activeElement;
103
+ }
104
+ _deepActive = e;
105
+ return e;
106
+ }
107
+
108
+ function isHidden(el) { if (!isHtmlElement(el)) return true;
45
109
  var tag = el.tagName.toLowerCase();
46
110
  if (tag === "dialog") return false;
47
111
  var style = winOf(el).getComputedStyle(el);
@@ -88,6 +152,212 @@ function extractTree() {
88
152
  "nav","main","header","footer","section","article","aside","iframe"
89
153
  ];
90
154
 
155
+ // ───────────────────────────────────────────────────────────────────────
156
+ // 「人看着能点」—— 用渲染结果判定可点性,而不是猜类名、也不是看 onclick 属性。
157
+ //
158
+ // 为什么不能用 el.onclick:Vue / React 一律走 addEventListener,元素上的
159
+ // onclick **属性**恒为 null。实测 CSDN 首页顶部 10 个 .layout-menu-item
160
+ // (首页/博客/下载/学习/…)全是裸 div + addEventListener,用属性判据一个都看不见,
161
+ // 而它们是页面上最常被点的东西。老校验里 `typeof el.onclick === "function"`
162
+ // 这条在框架站上等于永远为假。
163
+ //
164
+ // 为什么用 cursor:pointer:它是**浏览器自己算出来的**可点性 —— 人手看到手型光标
165
+ // 就知道这儿能点,浏览器也是照这个画的。读它等于读"浏览器怎么理解这个元素",
166
+ // 比猜 `.btn` / `[class*=item]` 这类各框架私有类名通用得多。
167
+ //
168
+ // 为什么必须限定「自己是手型边界」:cursor 是**可继承**的 CSS 属性。一张卡片设了
169
+ // cursor:pointer,卡片里每个 div/span/svg/path 计算出来全是 pointer。若逐个收下,
170
+ // CSDN 首页这一条会从 254 个可点目标涨到 840 个节点,而它们点起来效果完全相同
171
+ // (都落在卡片上)—— 语义树白白膨胀 3 倍,agent 还得从一堆同义节点里挑。
172
+ // 只收"父元素不是 pointer"的最外层那个,数量才等于**人看到几个可点区块**。
173
+ // 实测(tools/coverage_probe.cjs,2026-10-17):CSDN 254 个可点目标 / 840 个
174
+ // pointer 元素;B站 88 / 400;百度 38 / 88。
175
+ //
176
+ // 还要能读出名字:只判"可点"不够,agent 拿到一个无名节点照样没法选。aria-label /
177
+ // title / 自身文本 / 内含图标,四者任一即可 —— 图标按钮靠标签和图标,文字按钮靠文本。
178
+ // 全都没有就不收(这类通常真的是装饰性占位),宁可少报。
179
+ var CLICK_MIN_BOX = 8;
180
+ // 「一个可点目标」的候选交互后代。只作判重用途,不用来产出节点。
181
+ var INTERACTIVE_DESC = "a[href],button,input,select,textarea,summary,details," +
182
+ "[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[contenteditable=true]";
183
+ function ownCursor(el) {
184
+ try { return String(winOf(el).getComputedStyle(el).cursor || "").toLowerCase(); } catch (e) { return ""; }
185
+ }
186
+ // 外层只是**单个**交互元素的外壳时,不要再收一个节点。
187
+ //
188
+ // 实测(CSDN 首页):每个导航项都是
189
+ // <div class="layout-menu-item"><a class="layout-menu-item_inner">首页</a></div>
190
+ // div 是手型边界、<a> 本来就在树里,两者指向同一个目标。不加这条,agent 会在
191
+ // 树里看到两个同名「首页」,还得自己猜哪个是对的 —— 这不是能力,是噪声。
192
+ //
193
+ // 判据是"内层几乎盖满外层"(>=90%),而不是"外层只有一个交互后代":
194
+ // 一张大的可点卡片里只放了一个小链接时,两者是不同的目标(点卡片 ≠ 点那个链接),
195
+ // 那条外层通路要保留。
196
+ function wrapsSingleInteractive(el) {
197
+ var hits;
198
+ try { hits = el.querySelectorAll(INTERACTIVE_DESC); } catch (e) { return false; }
199
+ if (!hits || hits.length !== 1) return false;
200
+ var inner = hits[0], a, b;
201
+ try { a = el.getBoundingClientRect(); b = inner.getBoundingClientRect(); } catch (e) { return false; }
202
+ if (!a || !b || !(b.width > 0) || !(b.height > 0)) return false;
203
+ var w = Math.min(a.right, b.right) - Math.max(a.left, b.left);
204
+ var h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
205
+ if (!(w > 0) || !(h > 0)) return false;
206
+ var inter = w * h;
207
+ // 两个方向都要「盖满」:
208
+ // 内层全在外层里 ≠ 内层盖满外层。大卡片里放一个 80×20 的链接,
209
+ // 交集/内层面积 = 100%,但交集/外层面积只有 1% —— 点卡片空白处根本
210
+ // 不等于点那个链接,两条通路必须都留。
211
+ // 实测踩过:只看前者,CB-5 的大卡片会被误判成壳子删掉。
212
+ return inter / (b.width * b.height) >= 0.9 && inter / (a.width * a.height) >= 0.9;
213
+ }
214
+ // ── 浮层(下拉菜单 / 弹出面板)────────────────────────────────────────────
215
+ //
216
+ // 脱流浮层里的每一项都是**独立目标**,但 cursor:pointer 是从触发按钮一路继承
217
+ // 下来的,于是"父元素 pointer 就抑制"的边界规则会把整张菜单并成触发按钮的
218
+ // 一个节点。实测(2026-10-17,B站 动态卡片的「更多」菜单):
219
+ //
220
+ // div.bili-dyn-more cursor:auto 24×24 ← 真正的边界在这里
221
+ // div.tp absolute 24×24 ← 菜单被并到这个节点上
222
+ // div.bili-cascader absolute 139×106 ← 浮层根(溢出了父块)
223
+ // div.item pointer 140×40 ← 「置顶」/「删除」是纯 div
224
+ // …两层包装…
225
+ // div.item-label "删除" 26×18
226
+ //
227
+ // 合并后的节点 label 是 "取消置顶 删除",看着像一个节点,实际**点不了**,
228
+ // 而且"删除"两个字就在里面,agent 会以为它拿到了删除入口。
229
+ //
230
+ // 判据两半:
231
+ // ① 浮层根 = 位置脱流(absolute/fixed)且盒子**溢出**了父块 —— 弹出层正是
232
+ // "画在自己触发区外面的一块板"。用"溢出"而不是"脱流"本身,是为了不把
233
+ // "整页都是绝对定位"的布局当成浮层(那种页面上这条规则会让节点数失控)。
234
+ // position:fixed 例外:它按定义锚在视口上,直接算浮层根。
235
+ // ② 浮层内只收**最内层**的可点元素(自身没有"带名字的可点后代")——
236
+ // 一个菜单项通常套着 item → custom → label 三层,只留能读到名字的那层,
237
+ // 既拿到正确 label,又不会一项收成三层。
238
+ var FLOAT_OVERFLOW_TOL = 8;
239
+ // 盒子是否溢出了父块。用「溢出」而不是「脱流」本身:整页绝对定位的布局也脱流,
240
+ // 但它没有溢出父块(body),不该被当成弹出层。
241
+ function overflowsParent(el) {
242
+ var p = el.parentElement;
243
+ if (!p) return false;
244
+ var a, b;
245
+ try { a = p.getBoundingClientRect(); b = el.getBoundingClientRect(); } catch (e) { return false; }
246
+ if (!(b.width > 0) || !(b.height > 0)) return false;
247
+ return b.left < a.left - FLOAT_OVERFLOW_TOL ||
248
+ b.top < a.top - FLOAT_OVERFLOW_TOL ||
249
+ b.right > a.right + FLOAT_OVERFLOW_TOL ||
250
+ b.bottom > a.bottom + FLOAT_OVERFLOW_TOL;
251
+ }
252
+ // el 是否位于某个浮层里;返回那个浮层根(不在浮层里返回 null)。
253
+ // 只沿 pointer 链上溯:一旦离开手型块(祖先不是 pointer),就不属于同一张菜单。
254
+ // 每层只读一次 computed style(这个函数对每个可点元素都要跑一遍,别重复读)。
255
+ function floatingLayerOf(el) {
256
+ var p = el.parentElement, g = 0;
257
+ while (p && p !== document.documentElement && g++ < 12) {
258
+ var cs;
259
+ try { cs = winOf(p).getComputedStyle(p); } catch (e) { return null; }
260
+ if (String(cs.cursor || "").toLowerCase() !== "pointer") return null;
261
+ if (cs.position === "fixed") return p; // 视口锚定,按定义就是浮层
262
+ if (cs.position === "absolute" && overflowsParent(p)) return p;
263
+ p = p.parentElement;
264
+ }
265
+ return null;
266
+ }
267
+
268
+ // 元素的可读文字名(aria-label / alt / title / 自身文本)。
269
+ function elementName(el) {
270
+ var name = "";
271
+ try {
272
+ name = String(el.getAttribute("aria-label") || el.getAttribute("title") || "");
273
+ } catch (e) {}
274
+ // alt 与标签读法同源(见 extractLabel):图标 img、表情图都靠它取名。
275
+ if (!name.trim()) {
276
+ try {
277
+ var a = el.getAttribute("alt");
278
+ if (a && String(a).trim()) name = String(a).trim();
279
+ } catch (e) {}
280
+ }
281
+ if (!name.trim()) {
282
+ var t = el.innerText || el.textContent || "";
283
+ if (t && t.trim()) name = t.trim().slice(0, 120);
284
+ }
285
+ return name;
286
+ }
287
+ // 没有文字但有图标 —— 也算"人看得出这是什么",否则右侧一排图标按钮会全部消失。
288
+ // 这条不能混进 elementName 的返回值:那样调用方就分不清"名字是空字符串"和
289
+ // "压根没有名字",而判重逻辑要的正是后者。
290
+ function hasIconInside(el) {
291
+ try { return !!(el.querySelector && el.querySelector("svg,img,i[class],[class*=icon]")); } catch (e) { return false; }
292
+ }
293
+ function isNamed(el) {
294
+ return !!elementName(el).trim() || hasIconInside(el);
295
+ }
296
+
297
+ // 浮层内是否还有"更内层的可点元素"——有的话当前这层只是包装,不该单独收。
298
+ // 只用于压掉 item → custom → label 这种多层包裹,不做通用遍历优化:
299
+ // 浮层本身很小(菜单/面板),后代数量有界。
300
+ function hasClickableNamedDescendant(el) {
301
+ var all;
302
+ try { all = el.querySelectorAll("*"); } catch (e) { return false; }
303
+ for (var i = 0; i < all.length; i++) {
304
+ var c = all[i];
305
+ var r;
306
+ try { r = c.getBoundingClientRect(); } catch (e) { continue; }
307
+ if (!r || r.width < CLICK_MIN_BOX || r.height < CLICK_MIN_BOX) continue;
308
+ if (ownCursor(c) !== "pointer") continue;
309
+ if (isNamed(c)) return true;
310
+ }
311
+ return false;
312
+ }
313
+
314
+ function isHumanClickable(el) {
315
+ // 顺序按代价排:几何 → 自身 cursor → 父 cursor → 命名 → 判重
316
+ var r;
317
+ try { r = el.getBoundingClientRect(); } catch (e) { return false; }
318
+ if (!r || r.width < CLICK_MIN_BOX || r.height < CLICK_MIN_BOX) return false;
319
+ if (ownCursor(el) !== "pointer") return false;
320
+ // 浮层内:只收最内层,且不参与"父元素 pointer 就抑制"的合并(理由见上面
321
+ // FLOAT 那段注释)。
322
+ if (floatingLayerOf(el)) {
323
+ if (hasClickableNamedDescendant(el)) return false;
324
+ return isNamed(el);
325
+ }
326
+ var p = el.parentElement;
327
+ if (p && p !== document.documentElement && ownCursor(p) === "pointer") return false;
328
+ if (!isNamed(el)) return false;
329
+ // 命名检查在前、判重在后:判重要遍历后代并量两个盒子,比读文本贵
330
+ if (wrapsSingleInteractive(el)) return false;
331
+ return true;
332
+ }
333
+
334
+ // 一个元素的"子节点" = 普通子元素 + 影子根里的元素。
335
+ //
336
+ // 为什么必须下探 shadowRoot:Web Component 的内容**不在 el.children 里**。
337
+ // 实测(2026-10-17,B站 opus 详情页):整块评论区(输入框、发送按钮、评论列表)
338
+ // 全部装在 <bili-comments> 的 shadow root 里 —— DOM 通路读出来只有 45 个节点、
339
+ // 评论面板是个空 tabpanel,而 AX 通路能读到 190 个节点。这不是 B站 特有的写法:
340
+ // YouTube 的 <ytd-*>、各种设计系统的 <ui-*> 都一样。
341
+ //
342
+ // closed shadow root 按设计拿不到(el.shadowRoot 为 null),这里不做任何绕过 ——
343
+ // 能拿到的是 open 的那部分,拿不到的由 AX 通路兜底。
344
+ function childrenOf(el) {
345
+ var out = [];
346
+ var kids = el.children;
347
+ if (kids) for (var i = 0; i < kids.length; i++) out.push(kids[i]);
348
+ try {
349
+ var sr = el.shadowRoot;
350
+ if (sr && sr.children) {
351
+ for (var j = 0; j < sr.children.length; j++) {
352
+ // slot 分配进 shadow 的 light DOM 元素已在 el.children 里出现过,
353
+ // seen 集合会去重,不会重复产出。
354
+ out.push(sr.children[j]);
355
+ }
356
+ }
357
+ } catch (e) { /* 拿不到就算了 */ }
358
+ return out;
359
+ }
360
+
91
361
  function isSemantic(el) {
92
362
  if (!isHtmlElement(el)) return false;
93
363
  if (el === document.body || el === document.documentElement) return true;
@@ -97,6 +367,12 @@ function extractTree() {
97
367
  if (el.hasAttribute("role")) return true;
98
368
  if (typeof el.onclick === "function") return true;
99
369
  if (el.contentEditable === "true" || el.getAttribute("contenteditable") === "true") return true;
370
+ // 必须排在 isPureLayout **之前**:isPureLayout 对裸 div/span 一律返回 true,
371
+ // 放在它后面就永远轮不到。而真正被它误杀的恰好就是这类元素(CSDN 导航项、
372
+ // 百度"设置/下载/辅助")。role/onclick/contenteditable/aria 这些显式特征
373
+ // 在上面已经各自 return true 了,所以把"手型边界"插在这里不会削弱原有判定,
374
+ // 只是补上 framework 站上唯一还剩的那条通路。
375
+ if (isHumanClickable(el)) return true;
100
376
  if (isPureLayout(el)) return false;
101
377
  if (tag === "div" || tag === "span") return false;
102
378
  // Non-interactive/non-visible tags — never semantic
@@ -249,6 +525,24 @@ function extractTree() {
249
525
  function extractLabel(el) {
250
526
  var ariaLabel = el.getAttribute("aria-label");
251
527
  if (ariaLabel) return ariaLabel;
528
+ // 图片的可访问名是 alt(HTML-AAM 规定),此前完全没读。
529
+ //
530
+ // 实测(B站动态正文):插入的表情是 `<img alt="[doge_金箍]">`,正文里看得见、
531
+ // 树里 role=image 但 label 是空串 —— "刚才插进去的是哪个表情"读不出来。
532
+ // 同理,带 alt 的图标按钮(img 包在 <button> 里)也变成了无名节点。
533
+ // 空 alt(alt="" 表示装饰性图片)继续往下走,不制造假名字。
534
+ var alt = el.getAttribute("alt");
535
+ if (alt && String(alt).trim()) return String(alt).trim();
536
+ // input[type=submit|button|reset] 的可访问名是 value(同理,标准规定)。
537
+ // 没有这条,提交按钮在树里是"无名可点节点",只能靠坐标点。
538
+ var tagName = String(el.tagName || "").toUpperCase();
539
+ if (tagName === "INPUT") {
540
+ var itype = String(el.getAttribute("type") || "").toLowerCase();
541
+ if (itype === "submit" || itype === "button" || itype === "reset") {
542
+ var iv = el.getAttribute("value");
543
+ if (iv && String(iv).trim()) return String(iv).trim();
544
+ }
545
+ }
252
546
  var title = el.getAttribute("title");
253
547
  if (title) return title;
254
548
  var text = (el.textContent || "").trim();
@@ -340,9 +634,37 @@ function extractTree() {
340
634
  };
341
635
  }
342
636
  } catch (e) { /* cross-origin:读不到就算了,交给后面的兜底 */ }
637
+ // 走到这里 = 同源 DOM 通路读不到内容的 iframe。绝大多数是跨进程子帧
638
+ // (OOPIF):Chromium 把它放在独立渲染进程,contentDocument 恒为 null,
639
+ // 同源递归对它完全无效。**此前这里静默丢弃** —— B站图文编辑器的正文
640
+ // 编辑器就是这么从默认语义树里整体消失的(事故复盘见
641
+ // docs/AGENT_OPERABILITY_FEEDBACK.md)。现在至少留下带 opaque 标记的
642
+ // 占位节点 + 汇总进 context.cross_process_frames 的线索,调用方才知道
643
+ // "这里有一块内容,且默认通路读不到,该切 AX"。
644
+ try {
645
+ if (!seenOpaque.has(el)) {
646
+ seenOpaque.add(el);
647
+ var ifrRect = el.getBoundingClientRect ? el.getBoundingClientRect() : { width: 0, height: 0 };
648
+ opaqueFrames.push({
649
+ id: el.id || null,
650
+ src: el.getAttribute("src") || null,
651
+ width: Math.round(ifrRect.width || 0),
652
+ height: Math.round(ifrRect.height || 0)
653
+ });
654
+ }
655
+ } catch (e2) { /* 拿不到尺寸也至少留下存在性线索 */ }
656
+ return {
657
+ id: "iframe-" + (el.id || counter++),
658
+ role: "iframe_body",
659
+ label: el.title || el.name || "iframe content (cross-process, not readable from here)",
660
+ // opaque: 内容在另一个渲染进程。同源读取(getBoundingClientRect 之外
661
+ // 的任何 DOM 访问)都到不了;读取要走 CDP Accessibility(ax:true)。
662
+ opaque: true,
663
+ src: el.getAttribute("src") || null
664
+ };
343
665
  }
344
666
  if (!isSemantic(el)) {
345
- var elChildren = el.children;
667
+ var elChildren = childrenOf(el);
346
668
  if (elChildren && elChildren.length > 0) {
347
669
  var results = [];
348
670
  for (var ci = 0; ci < elChildren.length; ci++) {
@@ -394,7 +716,33 @@ function extractTree() {
394
716
  if (el.disabled === true) states.push("disabled");
395
717
  if (el.readOnly === true) states.push("readonly");
396
718
  if (el.checked === true) states.push("checked");
397
- if (el === document.activeElement) states.push("focused");
719
+ // 选中态的第二来源:自绘单选/复选。
720
+ //
721
+ // 实测(CSDN 发布页的「可见范围」):视觉上是四个单选,DOM 是
722
+ // `<label><input type=radio style="opacity:0"></label>`,可点的是 label。
723
+ // label 自己没有 checked,于是树里四个选项**长得一模一样**,只有 visible ——
724
+ // 「我到底选中了哪个」读不出来,只能靠截图。这是 HTML 标准里写清楚的:
725
+ // label 的选中态来自它关联的控件(label.control / label[for]),
726
+ // WAI-ARIA 的 role=radio/checkbox 则用 aria-checked。两条都读,不猜。
727
+ // 顺序:真控件 → 关联控件 → aria 属性。
728
+ if (states.indexOf("checked") < 0) {
729
+ var ctrl = null;
730
+ try {
731
+ if (el.control) ctrl = el.control;
732
+ else if (el.htmlFor && el.ownerDocument) ctrl = el.ownerDocument.getElementById(el.htmlFor);
733
+ } catch (e) { ctrl = null; }
734
+ if (ctrl && ctrl.checked === true) states.push("checked");
735
+ else if (el.getAttribute && el.getAttribute("aria-checked") === "true") states.push("checked");
736
+ }
737
+ if (el.getAttribute && el.getAttribute("aria-selected") === "true") states.push("selected");
738
+ // 必填 / 已判错(P2-2)。此前只有 disabled/readonly/checked/focused/visible,
739
+ // 掘金的红*必填、aria-required、字段已判错在树里**完全不可见**:agent 只能
740
+ // 从"提交后页面没反应"倒推,而必填缺失正是提交失败最常见的原因。
741
+ // required 用与 context.fields[].required 同一判定(原生 + aria-required),
742
+ // 两条读层对同一件事只能有一个答案。
743
+ if (isRequiredField(el)) states.push("required");
744
+ if (isInvalidField(el)) states.push("invalid");
745
+ if (el === deepActive()) states.push("focused");
398
746
  if (!isHidden(el)) states.push("visible");
399
747
 
400
748
  var node = {
@@ -444,8 +792,8 @@ function extractTree() {
444
792
  if (editorType) node.editor_type = editorType;
445
793
  if (editorBlocks) node.editor_blocks = editorBlocks;
446
794
 
447
- // Children — including iframe content if same-origin
448
- var elChildren = el.children;
795
+ // Children — 普通子元素 + 影子根(Web Component),见 childrenOf 的注释
796
+ var elChildren = childrenOf(el);
449
797
  var childNodes = [];
450
798
 
451
799
  // 同源 iframe 的递归已在 process() 开头完成(必须在 isSemantic 之前),此处不再重复。
@@ -527,8 +875,23 @@ function extractTree() {
527
875
  } else if (iframeText) {
528
876
  extraIframes.push({ id: "iframe-text-" + counter++, role: "iframe_body", label: ifr.title || ifr.name || "iframe content", text: iframeText });
529
877
  } else {
530
- // At least add the iframe node itself
531
- extraIframes.push({ id: "iframe-empty-" + counter++, role: "iframe", label: ifr.title || ifr.name || "iframe" });
878
+ // 同源通路读不到内容。绝大多数是跨进程子帧(OOPIF)—— Chromium 把它
879
+ // 放在独立渲染进程,contentDocument 恒 null。**此前这里只产出一个不带
880
+ // 任何标记的空壳节点**,看起来像"这里有个 iframe 且里面是空的",
881
+ // 而真相是"这里有一整块内容,默认通路完全读不到"。标 opaque + 收集。
882
+ if (!seenOpaque.has(ifr)) {
883
+ seenOpaque.add(ifr);
884
+ try {
885
+ var oRect = ifr.getBoundingClientRect ? ifr.getBoundingClientRect() : { width: 0, height: 0 };
886
+ opaqueFrames.push({
887
+ id: ifr.id || null,
888
+ src: ifr.getAttribute("src") || null,
889
+ width: Math.round(oRect.width || 0),
890
+ height: Math.round(oRect.height || 0)
891
+ });
892
+ } catch (e3) { /* 尺寸拿不到也留下存在性线索 */ }
893
+ }
894
+ extraIframes.push({ id: "iframe-empty-" + counter++, role: "iframe", label: ifr.title || ifr.name || "iframe", opaque: true, src: ifr.getAttribute("src") || null });
532
895
  }
533
896
  }
534
897
  if (extraIframes.length && root) {
@@ -541,13 +904,13 @@ function extractTree() {
541
904
  // 当前焦点元素的 data-ai-id,挂根上。ui.get_focused 一直在读这个字段,而
542
905
  // extractor 从不产出它 —— 于是那个方法恒返回 null(e2e/audit_ws.cjs AU-B2 抓到)。
543
906
  // 焦点信息本来就在每个节点的 states:[...,"focused"] 里,但那是 O(n) 扫树;
544
- // 挂根上 O(1),且与节点状态同源(都是 document.activeElement)。
907
+ // 挂根上 O(1),且与节点状态同源(都是「真正的焦点元素」,见 deepActive)。
545
908
  //
546
909
  // 三种情况给 null,不猜:焦点在 body/documentElement(没有对应语义节点)、
547
910
  // 焦点在iframe 里(activeElement 是 iframe 元素本身,不是里面那个输入框)、
548
911
  // 焦点元素不是语义节点(没有 data-ai-id,比如被 isPureLayout 剪掉的容器)。
549
912
  var focusedEl = null;
550
- try { focusedEl = document.activeElement; } catch (e) {}
913
+ try { focusedEl = deepActive(); } catch (e) {}
551
914
  var focusedId = null;
552
915
  if (focusedEl && focusedEl !== document.body && focusedEl !== document.documentElement &&
553
916
  typeof focusedEl.getAttribute === "function") {
@@ -555,6 +918,10 @@ function extractTree() {
555
918
  }
556
919
  root.focused_element_id = focusedId;
557
920
  root._debug = { iframe_count: extraIframes.length, version: VERSION_TAG };
921
+ // 内容读不到的跨进程子帧:挂根上供调用方一眼看到"这里有 N 块默认通路
922
+ // 读不到的内容"。extractPageContext 会汇总成更结构化的
923
+ // context.cross_process_frames(带建议通路)。
924
+ root.cross_process_iframes = opaqueFrames;
558
925
  return root;
559
926
  }
560
927
 
@@ -562,6 +929,34 @@ function extractTree() {
562
929
  // 2. EXTRACT PAGE CONTEXT — modals, forms, iframes, session
563
930
  // ============================================================
564
931
 
932
+ // 收集当前页面里"内容读不到"的 iframe(跨进程子帧 OOPIF)。
933
+ // 为什么 extractPageContext 自己扫而不是复用 extractTree 闭包里的数组:
934
+ // bridge 的 ai:context 只调 extractPageContext(ui.auth_state 走的就是这
935
+ // 条),那条路径上 extractTree 从未运行,闭包数组是空的 —— 登录态自查
936
+ // 也必须能报告"这个页面有 OOPIF"。
937
+ function collectOpaqueFrames() {
938
+ var out = [];
939
+ var ifrs = document.querySelectorAll("iframe");
940
+ for (var i = 0; i < ifrs.length; i++) {
941
+ var f = ifrs[i];
942
+ var readable = false;
943
+ try {
944
+ var d = f.contentDocument || (f.contentWindow && f.contentWindow.document);
945
+ readable = !!(d && d.body);
946
+ } catch (e) { readable = false; }
947
+ if (readable) continue;
948
+ var r = { width: 0, height: 0 };
949
+ try { r = f.getBoundingClientRect() || r; } catch (e2) { /* 尺寸缺失不影响存在性 */ }
950
+ out.push({
951
+ id: f.id || null,
952
+ src: f.getAttribute("src") || null,
953
+ width: Math.round(r.width || 0),
954
+ height: Math.round(r.height || 0)
955
+ });
956
+ }
957
+ return out;
958
+ }
959
+
565
960
  function extractPageContext() {
566
961
  // MODALS — unified search across ALL patterns.
567
962
  // Visibility rule: do NOT use `el.offsetParent === null` to skip hidden nodes —
@@ -713,9 +1108,8 @@ function extractPageContext() {
713
1108
  // 必填判定:原生 required 或 aria-required="true"。站点/组件库常只用 ARIA 标必填
714
1109
  // (原生 required 会触发浏览器自带的气泡校验,很多站点刻意绕开它)。
715
1110
  function isRequired(el) {
716
- if (el.required === true) return true;
717
- var ar = el.getAttribute ? el.getAttribute("aria-required") : null;
718
- return ar === "true";
1111
+ // 单一实现:与 node.states 里的 "required" 同源,避免同一件事两个答案。
1112
+ return isRequiredField(el);
719
1113
  }
720
1114
 
721
1115
  // ---- 顶层错误 / 提示扫描(P0-2 / P1-6)----
@@ -760,7 +1154,11 @@ function extractPageContext() {
760
1154
  }
761
1155
  var p = el.parentElement;
762
1156
  if (!p) return "";
763
- var inps = p.querySelectorAll("input:not([type=hidden]),textarea,select");
1157
+ // 富文本/标签类字段常常不是 input/textarea,而是 contenteditable 容器
1158
+ // (掘金的标签输入区就是)。只数 input/textarea/select 时,这类字段的错误
1159
+ // 永远关联不上——"至少添加一个标签"因此彻底读不到(P2-2 的原始症状)。
1160
+ var inps = p.querySelectorAll(
1161
+ 'input:not([type=hidden]),textarea,select,[contenteditable="true"],[contenteditable=""]');
764
1162
  if (inps.length !== 1) return "";
765
1163
  return inps[0].id || inps[0].name || "";
766
1164
  }
@@ -894,24 +1292,112 @@ function extractPageContext() {
894
1292
  }
895
1293
  } catch (e) {}
896
1294
  }
1295
+ // 6) 占位符直接写着"请输入/请填写/必填"的字段。站点既没有 required 属性、
1296
+ // 也没有红星时,这是唯一读得到的"这里要填东西"的信号(B站图文的标题框
1297
+ // 就是这样:placeholder="请输入标题(建议30字以内)",无 required、无红星,
1298
+ // 于是 agent 只能从"发布没反应"倒推)。
1299
+ // 仍然只进 required_hints(启发式桶),不进 nodes.states —— 它不构成承诺。
1300
+ try {
1301
+ var phEls = document.querySelectorAll("input[placeholder],textarea[placeholder],[contenteditable][data-placeholder]");
1302
+ for (var ph = 0; ph < phEls.length && out.length < 40; ph++) {
1303
+ var pht = phEls[ph].getAttribute("placeholder") || phEls[ph].getAttribute("data-placeholder");
1304
+ if (pht && /请\s*(输入|填写|选择)|必填|必选|不能为空/.test(pht)) push(pht);
1305
+ }
1306
+ } catch (e) {}
897
1307
  return out;
898
1308
  }
899
1309
  var requiredHints = scanRequiredHints();
900
1310
 
901
- // SESSION — more robust detection
902
- var hasPwdInput = document.querySelector("input[type=password]");
903
- var bodyText = document.body.innerText || "";
904
- var isLoginPage = bodyText.indexOf("登录") >= 0 || bodyText.indexOf("Sign In") >= 0 ||
905
- document.title.indexOf("登录") >= 0 || document.URL.indexOf("login") >= 0 || document.URL.indexOf("signin") >= 0;
906
- var hasAvatar = document.querySelector("[class*=avatar],img[class*=avatar],img[class*=user]");
907
- // Cookie-based check
908
- var hasLoginCookie = document.cookie.indexOf("token") >= 0 || document.cookie.indexOf("session") >= 0
909
- || document.cookie.indexOf("auth") >= 0;
910
- var loggedIn = !hasPwdInput && !isLoginPage && (hasAvatar !== null || hasLoginCookie || bodyText.length > 1000);
1311
+ // ---- validation_errors(P2-2)------------------------------------------
1312
+ // "哪个字段被页面判错了、错在哪儿" 之前只能是两片碎片:context.errors 是
1313
+ // 一堆不知道归属的文案,context.field_errors 要求错误元素能被 fieldOf 关联上。
1314
+ // 掘金的行内错误"至少添加一个标签"既不在 modals 里、也没有 aria-invalid,
1315
+ // 于是 agent 提交了、页面拒绝了、而回执里什么都没有 —— 这正是 1.2 次事故的
1316
+ // 全部条件。这里给出结构化的一条:[{ text, near }],near 是字段的定位串。
1317
+ //
1318
+ // near 的取值顺序与 context.fields[].id 一致,方便两边对照;关联不上字段时
1319
+ // near 为空串,而不是猜一个"最近的输入框"(位置猜测一直是本项目明令禁止的)。
1320
+ function locatorOf(el) {
1321
+ if (!el) return "";
1322
+ return el.id || el.name || el.getAttribute("aria-label") || el.placeholder ||
1323
+ (el.tagName || "").toLowerCase();
1324
+ }
1325
+ var validationErrors = [];
1326
+ var seenVE = {};
1327
+ function pushVE(text, near) {
1328
+ var t2 = (text || "").trim();
1329
+ if (!t2) return;
1330
+ if (t2.length > 120) t2 = t2.slice(0, 120);
1331
+ var key = (near || "") + "\u0000" + t2;
1332
+ if (seenVE[key]) return;
1333
+ if (validationErrors.length >= 30) return;
1334
+ seenVE[key] = 1;
1335
+ validationErrors.push({ text: t2, near: near || "" });
1336
+ }
1337
+ // 1) 页面明说"这个控件无效"(aria-invalid=true/grammar/spelling)—— 最强信号。
1338
+ // 即使拿不到错误文案,字段本身也已经通过 node.states 的 invalid 暴露了。
1339
+ try {
1340
+ var invEls = document.querySelectorAll(
1341
+ '[aria-invalid="true"],[aria-invalid=grammar],[aria-invalid=spelling]');
1342
+ for (var ivi = 0; ivi < invEls.length && ivi < 40; ivi++) {
1343
+ pushVE(fieldErrorOf(invEls[ivi]) || "", locatorOf(invEls[ivi]));
1344
+ }
1345
+ } catch (e) {}
1346
+ // 2) 已经能对上字段的错误文案(fieldErrors 是同一批 DOM 的结论,换形状给调用方)
1347
+ for (var vei = 0; vei < fieldErrors.length; vei++) {
1348
+ pushVE(fieldErrors[vei].text, fieldErrors[vei].field);
1349
+ }
1350
+
1351
+ // SESSION / AUTH WALL — 见 src/shared/auth_wall.cjs 顶部的事故注释。
1352
+ // 旧实现在这里犯过两个错,都已修正:
1353
+ // 1. document.cookie.indexOf("token") >= 0 —— CSDN 未登录时 cookie 里有
1354
+ // csrfToken,子串命中,于是 logged_in 被判成 true(主动谎报);
1355
+ // 2. bodyText.length > 1000 也算登录证据 —— CSDN 登录页正文远超 1000 字。
1356
+ // 现在 cookie 按名字判(csrf 类显式排除),并且新增顶层 auth_wall 硬信号。
1357
+ // 密码框必须**可见才算**。旧实现是裸 querySelector:CSDN 文章页常驻一个
1358
+ // display:none 的登录表单(视觉隐藏但 DOM 在),于是每篇文章页都被误报成
1359
+ // "登录墙"—— 一个把已登录工作页说成登录墙的信号,比没有信号更糟:它会
1360
+ // 让调用方在正确页面上停下"重新登录",或干脆不信任这个字段。
1361
+ // 判定与 ui.screenshot 的密码框探测同一口径:有尺寸 + 不在隐藏祖先链上。
1362
+ var hasPwdInput = false;
1363
+ try {
1364
+ var pwdEls = document.querySelectorAll("input[type=password]");
1365
+ for (var pi = 0; pi < pwdEls.length; pi++) {
1366
+ var pe = pwdEls[pi];
1367
+ var pr = pe.getBoundingClientRect ? pe.getBoundingClientRect() : { width: 0, height: 0 };
1368
+ if (!(pr.width > 0 && pr.height > 0)) continue;
1369
+ var pHidden = false;
1370
+ var pp = pe.parentElement;
1371
+ while (pp && pp !== document.body) {
1372
+ var pst = window.getComputedStyle(pp);
1373
+ if (pst && (pst.display === "none" || pst.visibility === "hidden")) { pHidden = true; break; }
1374
+ if (pp.hidden === true || pp.getAttribute("aria-hidden") === "true") { pHidden = true; break; }
1375
+ pp = pp.parentElement;
1376
+ }
1377
+ if (!pHidden) { hasPwdInput = true; break; }
1378
+ }
1379
+ } catch (ePwd) { /* 探测失败按"没有密码框"处理:宁可漏报也不要误报登录墙 */ }
1380
+ var hasAvatar = !!document.querySelector("[class*=avatar],img[class*=avatar],img[class*=user]");
1381
+ var authWall = authWallLib.detectAuthWall({
1382
+ url: document.URL,
1383
+ hasPasswordInput: hasPwdInput,
1384
+ });
1385
+ var login = authWallLib.looksLoggedIn({
1386
+ url: document.URL,
1387
+ hasPasswordInput: hasPwdInput,
1388
+ hasAvatar: hasAvatar,
1389
+ cookieString: document.cookie,
1390
+ });
911
1391
 
912
1392
  return {
913
1393
  title: document.title,
914
1394
  url: document.URL,
1395
+ // 登录墙硬信号。detected=true 时,任何"操作怎么没生效"的排查都该先停下:
1396
+ // 当前 tab 大概率不在目标站点的工作页,而在它的登录页。
1397
+ // reason: 'auth_host' | 'login_path' | 'password_field';matched 是命中的那一段。
1398
+ auth_wall: authWall.detected
1399
+ ? { detected: true, reason: authWall.reason, matched: authWall.matched, url: document.URL }
1400
+ : null,
915
1401
  forms: formList.length ? formList : null,
916
1402
  modals: modals.length ? modals : null,
917
1403
  // 顶层报错/提示/必填:都不依赖容器 class 是否含 modal 关键词,普通页面也能读到。
@@ -921,9 +1407,18 @@ function extractPageContext() {
921
1407
  // 权威判定看 fields[].required。
922
1408
  errors: topErrors.length ? topErrors : null,
923
1409
  field_errors: fieldErrors.length ? fieldErrors : null,
1410
+ // validation_errors —— [{text, near}]:字段级校验结论,near 是该字段的定位串
1411
+ // (id/name/aria-label/placeholder)。与 field_errors 的区别:这里包含
1412
+ // aria-invalid 标记但文案缺失的控件(text 为空串时只在 states 里体现,不占条目)。
1413
+ validation_errors: validationErrors.length ? validationErrors : null,
924
1414
  hints: topHints.length ? topHints : null,
925
1415
  required_hints: requiredHints.length ? requiredHints : null,
926
- session: { logged_in: loggedIn },
1416
+ session: { logged_in: login.logged_in, evidence: login.evidence },
1417
+ // 跨进程子帧(OOPIF)汇总。count>0 意味着"页面有一块内容,默认 DOM
1418
+ // 通路读不到"——调用方该切 AX 读法(ax:true)。suggestion 里带通路名。
1419
+ // 事故背景:B站图文编辑器正文在 OOPIF 里,此前树里连占位都没有,
1420
+ // agent 得出"编辑器没加载"的错误结论。
1421
+ cross_process_frames: frameProbe.summarizeOpaqueFrames(collectOpaqueFrames()),
927
1422
  stats: {
928
1423
  inputs: document.querySelectorAll("input:not([type=hidden])").length,
929
1424
  buttons: document.querySelectorAll("button").length,