@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,117 @@
1
+ // window_open_shim_source.js — 注入页面主 world 的 window.open 兼容垫片源码
2
+ //
3
+ // 背景:站点惯用法(超星专技平台等大量老站)是
4
+ // var w = window.open("", "_blank"); // 先开一个新页面
5
+ // w.location = res.url; // 再把真实地址赋给它
6
+ // 主进程的 setWindowOpenHandler 对 window.open 一律 deny(改为自建 tab),
7
+ // deny 之后 window.open 返回 null,页面再赋 w.location 直接抛 TypeError ——
8
+ // 用户看到的就是一个光秃秃的 about:blank 标签,站点逻辑静默断裂。
9
+ // (实测:pxsxzj.xidian.edu.cn「进入学习」,2026-10-19。)
10
+ //
11
+ // ⚠️ 第二层根因(同一天发现,比第一层更隐蔽):光让 window.open 返回一个
12
+ // 非 null 的对象还不够。真实 Window 的 location 是 [LegacyUnforgeable] +
13
+ // [PutForwards=href],所以 `w.location = url` 等价于 `w.location.href = url`,
14
+ // 会导航。第一版垫片把 location 写成普通数据属性(location: loc),站点
15
+ // 这一赋值只是把字符串塞进属性、覆盖掉 loc 对象,**静默不导航**——垫片
16
+ // 注入了、window.open 也返回了对象、站点不报错,标签页却永远停在
17
+ // about:blank。现在的实现用 Object.defineProperty 装一个带 setter 的
18
+ // 访问器来复刻 PutForwards 语义。
19
+ //
20
+ // 为什么必须走 CDP 注入而不是 preload:tab 的 BrowserView 是
21
+ // contextIsolation:true,preload 里写的 window.open 只作用于 isolated
22
+ // world 的 window,页面脚本看到的仍是原生 window.open(第一版就是这么
23
+ // 失败的——隔离测试里 window.open() 返回 falsy)。Page.addScriptToEvaluate
24
+ // OnNewDocument 注入的脚本跑在主 world,才能真正替换掉页面可见的那个。
25
+ //
26
+ // 通信:垫片本体没有 ipcRenderer,经 preload 用 contextBridge 暴露的
27
+ // __ai_browser__.windowOpenBridge(op, token, url, name)(单一入口,
28
+ // fire-and-forget)把事件送到主进程;主进程建 tab / 导航 / 关 tab。
29
+ // ⚠️ 入口 arity 固定 4:contextBridge 两岸参数数量不一致时调用会抛错
30
+ // (实测少传一个 → 抛错被 catch 吞掉 → window.open 静默返回 null,
31
+ // 站点看到的就是"弹窗被拦截"的老症状)。垫片所有调用必须传满 4 个参数。
32
+ export const WINDOW_OPEN_SHIM_SOURCE = `(function () {
33
+ if (window.__aiWindowOpenShimV2) return;
34
+ window.__aiWindowOpenShimV2 = true;
35
+ var seq = 0;
36
+
37
+ function send(op, token, url, name) {
38
+ var b = window.__ai_browser__;
39
+ if (!b || typeof b.windowOpenBridge !== 'function') return false;
40
+ try { b.windowOpenBridge(op, token, url === undefined ? '' : url, name === undefined ? '' : name); return true; } catch (e) { return false; }
41
+ }
42
+
43
+ // 相对地址按发起页面的 baseURI 解析(真实 window 的 location 也这么做)。
44
+ function abs(v) {
45
+ var s = String(v);
46
+ try { return new URL(s, document.baseURI).href; } catch (e) { return s; }
47
+ }
48
+
49
+ window.open = function (url, name, _features) {
50
+ var target = (url === undefined || url === null) ? '' : String(url);
51
+ var token = 'wo' + (++seq) + '_' + Date.now();
52
+ var ok = send('open', token, target, name || '');
53
+ if (!ok) {
54
+ // 没有桥:退回原生语义(deny → null),至少不像成功那样骗站点。
55
+ return null;
56
+ }
57
+ var currentHref = target ? abs(target) : 'about:blank';
58
+ var closed = false;
59
+
60
+ function navigate(v) {
61
+ currentHref = abs(v);
62
+ send('nav', token, currentHref, '');
63
+ }
64
+
65
+ var loc = {
66
+ get href() { return currentHref; },
67
+ set href(v) { navigate(v); },
68
+ assign: function (v) { navigate(v); },
69
+ replace: function (v) { navigate(v); },
70
+ reload: function () {},
71
+ toString: function () { return currentHref; }
72
+ };
73
+
74
+ var doc = {
75
+ write: function () {}, writeln: function () {},
76
+ open: function () {}, close: function () {},
77
+ get location() { return loc; },
78
+ set location(v) { navigate(v); },
79
+ get URL() { return currentHref; }
80
+ };
81
+
82
+ var win = {
83
+ close: function () {
84
+ if (closed) return;
85
+ closed = true;
86
+ send('close', token, '', '');
87
+ },
88
+ focus: function () {},
89
+ blur: function () {},
90
+ get closed() { return closed; },
91
+ name: name || '',
92
+ opener: null,
93
+ document: doc
94
+ };
95
+
96
+ // ⚠️ 关键:真实 Window 的 location 是 [LegacyUnforgeable] +
97
+ // [PutForwards=href] 的访问器——w.location = url 会被转发到 href
98
+ // 上变成导航。如果这里写成普通数据属性(location: loc),站点的
99
+ // w.location = res.url 就只是把字符串写进属性、覆盖掉 loc,
100
+ // **静默不导航**,标签页永远停在 about:blank(这正是本 bug 的根因:
101
+ // 垫片注入是成功的,window.open 也返回了对象,站点不报错,
102
+ // 但赋值被吞掉)。所以必须用带 setter 的访问器,并设成不可配置
103
+ // 以贴近 unforgeable 语义。
104
+ try {
105
+ Object.defineProperty(win, 'location', {
106
+ get: function () { return loc; },
107
+ set: function (v) { navigate(v); },
108
+ enumerable: true,
109
+ configurable: false
110
+ });
111
+ } catch (e) {
112
+ win.location = loc;
113
+ }
114
+
115
+ return win;
116
+ };
117
+ })();`;
@@ -20,7 +20,19 @@ function sendEvent(event, data) {
20
20
 
21
21
  contextBridge.exposeInMainWorld("__ai_browser__", {
22
22
  sendTree: function(tree) { ipcRenderer.send("ai:tree", tree); },
23
- sendDiff: function(changes) { ipcRenderer.send("ai:diff", changes); }
23
+ sendDiff: function(changes) { ipcRenderer.send("ai:diff", changes); },
24
+ // window.open 垫片的桥(主 world 侧脚本见 main/window_open_shim_source.js)。
25
+ // contextIsolation 下 preload 改不了页面的 window.open(那是 isolated
26
+ // world 的 window),所以垫片本体由 CDP 注入进主 world,只把这一个
27
+ // 出口经 contextBridge 暴露给它。
28
+ // ⚠️ arity 必须固定为 4:contextBridge 两岸参数数量不一致时调用会抛错
29
+ // (实测:垫片少传一个参数 → 抛错被 catch 吞掉 → window.open 静默返回
30
+ // null,站点拿到的还是"弹窗被拦"的老症状)。垫片侧所有调用都传满 4 个。
31
+ windowOpenBridge: function(op, token, url, name) {
32
+ if (op === 'open') ipcRenderer.send('ai:window_open', { token: token, url: url, name: name || '' });
33
+ else if (op === 'nav') ipcRenderer.send('ai:window_open_nav', { token: token, url: url });
34
+ else if (op === 'close') ipcRenderer.send('ai:window_open_close', { token: token });
35
+ }
24
36
  });
25
37
 
26
38
  // Extract tree request — echo back the request id so the main process can
@@ -34,10 +46,16 @@ ipcRenderer.on("ai:extract", function(_event, params) {
34
46
  ipcRenderer.send("ai:tree", { tree: tree, context: ctx, id: id });
35
47
  } catch(e) {
36
48
  console.error("[bridge] extractTree crash:", e.message, e.stack);
37
- // Return empty fallback
49
+ // 兜底必须**声明自己是兜底**。
50
+ // 旧实现只把异常文本塞进一个节点的 label 就当树返回了:调用方看到一张
51
+ // "只有一个节点、没有可操作元素"的页面,无从判断是"页面本来就空"还是
52
+ // "我们没读成"。2026-10-19 实测在 about:blank 上就踩到了(document.cookie
53
+ // 在 opaque origin 抛 SecurityError),而那一刻 page_coverage 也只能给
54
+ // null —— 又一个"失败"和"没内容"长得一样的例子。
55
+ // 现在 read_ok/read_error 显式写进 context,main 侧会保留它。
38
56
  ipcRenderer.send("ai:tree", {
39
57
  tree: { id: "root", role: "generic", label: "[error: " + e.message + "]", states: ["visible"], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 } },
40
- context: { modals: [], forms: [], session: {}, auth_wall: null, stats: {} },
58
+ context: { modals: [], forms: [], session: {}, auth_wall: null, stats: {}, read_ok: false, read_error: String(e && e.message || e) },
41
59
  id: id
42
60
  });
43
61
  }
@@ -55,7 +73,7 @@ ipcRenderer.on("ai:context", function(_event, params) {
55
73
  } catch(e) {
56
74
  ipcRenderer.send("ai:tree", {
57
75
  tree: null,
58
- context: { modals: [], forms: [], session: {}, auth_wall: null, stats: {} },
76
+ context: { modals: [], forms: [], session: {}, auth_wall: null, stats: {}, read_ok: false, read_error: String(e && e.message || e) },
59
77
  id: id
60
78
  });
61
79
  }
@@ -18,6 +18,25 @@ var frameProbe = require("../shared/frame_probe.cjs");
18
18
  // (旧元素沿用自身 data-ai-id,新元素却可能生成出同名的号),所以它只增不减。
19
19
  var AI_ID_SEQ = 0;
20
20
 
21
+ // document.cookie 在 **opaque origin** 上会**抛 SecurityError**,不是返回空串。
22
+ // 哪些页面是 opaque origin:about:blank、sandbox 属性的 iframe、data: 文档、
23
+ // 导航失败后 Chromium 留下的空白页。
24
+ //
25
+ // 2026-10-19 实测事故:`ui.new_tab` 给一个打不开的域名,标签页停在 about:blank,
26
+ // 于是 extractPageContext 在这里抛 SecurityError,**整个抽取器崩掉**,
27
+ // bridge 的兜底把异常文本塞进一个节点当树返回 —— 调用方看到的是"页面只有
28
+ // 一个节点",而不是"读数失败"。一个可选字段(cookie 只用于登录态启发式)
29
+ // 炸掉了整条读路径,这是纯粹的自伤。
30
+ function readCookieSafely() {
31
+ try {
32
+ return document.cookie || "";
33
+ } catch (e) {
34
+ // 拿不到就当没有:登录态判定本来就把"没有 cookie 证据"当作未登录倾向,
35
+ // 这个降级方向是安全的(宁可说"需要确认登录",不可谎报已登录)。
36
+ return "";
37
+ }
38
+ }
39
+
21
40
  // ---- 必填 / 已判错(P2-2)----------------------------------------------
22
41
  // 这两条判定同时服务两处输出:语义树的 node.states 与 context.fields[].required。
23
42
  // 两个地方必须给同一个答案 —— 树里说"可选"而 context 说"必填",比只给一处更糟。
@@ -522,9 +541,22 @@ function extractTree() {
522
541
  return null;
523
542
  }
524
543
 
525
- function extractLabel(el) {
544
+ // label 的来源。**只有 kind === "text" 的字才在页面的 innerText 里**,
545
+ // 其余(aria-label / alt / title / placeholder)都是可访问名,不是渲染出来的文字。
546
+ //
547
+ // 为什么必须区分:coverage 的分母是 innerText,分子是树里 label 的字数。
548
+ // 把可访问名算进分子,等于拿两把尺子量 —— 实测 /basic.html:
549
+ // `<input placeholder="随便填">` 让分子 46 大于分母 43,明明只读到 43 字却报 100%。
550
+ // 这类节点在真实站点上极其常见(每个图标按钮都挂 aria-label),
551
+ // 所以这不是零点几个百分比的噪声,而是系统性虚高。
552
+ // 节点上只在"不是渲染文字"时才写 label_kind,正常节点不加字段(省 token)。
553
+ function extractLabel(el, meta) {
554
+ var setKind = function (kind, value) {
555
+ if (meta) meta.kind = kind;
556
+ return value;
557
+ };
526
558
  var ariaLabel = el.getAttribute("aria-label");
527
- if (ariaLabel) return ariaLabel;
559
+ if (ariaLabel) return setKind("aria", ariaLabel);
528
560
  // 图片的可访问名是 alt(HTML-AAM 规定),此前完全没读。
529
561
  //
530
562
  // 实测(B站动态正文):插入的表情是 `<img alt="[doge_金箍]">`,正文里看得见、
@@ -532,7 +564,7 @@ function extractTree() {
532
564
  // 同理,带 alt 的图标按钮(img 包在 <button> 里)也变成了无名节点。
533
565
  // 空 alt(alt="" 表示装饰性图片)继续往下走,不制造假名字。
534
566
  var alt = el.getAttribute("alt");
535
- if (alt && String(alt).trim()) return String(alt).trim();
567
+ if (alt && String(alt).trim()) return setKind("alt", String(alt).trim());
536
568
  // input[type=submit|button|reset] 的可访问名是 value(同理,标准规定)。
537
569
  // 没有这条,提交按钮在树里是"无名可点节点",只能靠坐标点。
538
570
  var tagName = String(el.tagName || "").toUpperCase();
@@ -540,17 +572,45 @@ function extractTree() {
540
572
  var itype = String(el.getAttribute("type") || "").toLowerCase();
541
573
  if (itype === "submit" || itype === "button" || itype === "reset") {
542
574
  var iv = el.getAttribute("value");
543
- if (iv && String(iv).trim()) return String(iv).trim();
575
+ if (iv && String(iv).trim()) return setKind("value", String(iv).trim());
544
576
  }
545
577
  }
546
578
  var title = el.getAttribute("title");
547
- if (title) return title;
548
- var text = (el.textContent || "").trim();
549
- if (text.length > 60) text = text.slice(0, 60);
550
- if (text) return text;
579
+ if (title) return setKind("title", title);
580
+ var text = renderedText(el).trim();
581
+ // 截断必须**留痕**。原来这里是裸 `text.slice(0, 60)`:一段 500 字的正文在树里
582
+ // 变成 60 字,而且看不出被砍过 —— agent 会把它当成"这一段就这么点字"。
583
+ // 实测 /long.html:60 段全在树里,覆盖率只有 70%,原因就是每段被裁到 60;
584
+ // 而回执上没有任何一处说"我裁过"。抄 frame_read_source 的 clip() 口径,补一个省略号。
585
+ if (text.length > 60) text = text.slice(0, 59) + "\u2026";
586
+ if (text) return setKind("text", text);
551
587
  var placeholder = el.getAttribute("placeholder");
552
- if (placeholder) return placeholder;
553
- return "";
588
+ if (placeholder) return setKind("placeholder", placeholder);
589
+ return setKind("none", "");
590
+ }
591
+
592
+ // 元素上**看得见**的文字。
593
+ //
594
+ // 为什么不用裸 textContent:它把**不渲染的东西**也算进去,而 label 是
595
+ // 直接给 agent 当"这个元素写着什么"读的。2026-10-19 用受控页面实测了两类泄漏:
596
+ // 1. `<div style="display:none">…说明文字…</div>` 整段进了父节点的 label,
597
+ // agent 会把一段用户根本看不见的说明当成页面正文;
598
+ // 2. `<canvas>` 页面的容器 label 里装的是 `<script>` 的 JS 源码
599
+ // ("绘图工具 const c = document.getElementById('cv')…")。
600
+ // 更根本的是**口径**问题:文字覆盖率的分母(stats.text_chars)用的是
601
+ // innerText,分子来自 label;label 用 textContent 就等于"分子分母两把尺子",
602
+ // 那这个比例算出来没有意义。
603
+ //
604
+ // innerText 是浏览器算出来的"渲染后可见文字":display:none / visibility:hidden /
605
+ // <script> / <style> 天然排除,换行也按渲染结果归一。
606
+ //
607
+ // 但要留 textContent 兜底:`<option>`、`<title>` 这类元素 innerText 可能为空,
608
+ // 而它们的文字是有效的可访问名。宁可多读,不可让按钮变无声。
609
+ function renderedText(el) {
610
+ var t = "";
611
+ try { t = el.innerText; } catch (e) { t = ""; }
612
+ if (t && t.trim()) return t;
613
+ try { return el.textContent || ""; } catch (e) { return ""; }
554
614
  }
555
615
 
556
616
  function extractBounds(el) {
@@ -647,7 +707,9 @@ function extractTree() {
647
707
  var ifrRect = el.getBoundingClientRect ? el.getBoundingClientRect() : { width: 0, height: 0 };
648
708
  opaqueFrames.push({
649
709
  id: el.id || null,
650
- src: el.getAttribute("src") || null,
710
+ // 绝对地址(el.src 会被解析成绝对 URL)。相对地址对调用方没用:
711
+ // 树里写着 /mooc2-ans/xxx,agent 拿它去 navigate 只会失败。
712
+ src: (el.src || '').trim() || el.getAttribute("src") || null,
651
713
  width: Math.round(ifrRect.width || 0),
652
714
  height: Math.round(ifrRect.height || 0)
653
715
  });
@@ -660,7 +722,8 @@ function extractTree() {
660
722
  // opaque: 内容在另一个渲染进程。同源读取(getBoundingClientRect 之外
661
723
  // 的任何 DOM 访问)都到不了;读取要走 CDP Accessibility(ax:true)。
662
724
  opaque: true,
663
- src: el.getAttribute("src") || null
725
+ // 绝对地址:树里出现的 iframe 必须能被直接打开(ui.new_tab)。
726
+ src: (el.src || '').trim() || el.getAttribute("src") || null
664
727
  };
665
728
  }
666
729
  if (!isSemantic(el)) {
@@ -679,7 +742,8 @@ function extractTree() {
679
742
  return null;
680
743
  }
681
744
 
682
- var label = extractLabel(el);
745
+ var labelMeta = {};
746
+ var label = extractLabel(el, labelMeta);
683
747
  var role = inferRole(el);
684
748
  var actions = extractActions(el, role);
685
749
  var bounds = extractBounds(el);
@@ -753,6 +817,10 @@ function extractTree() {
753
817
  actions: actions,
754
818
  bounds: bounds
755
819
  };
820
+ // 只有当 label **不是**页面上渲染出来的文字时才写这一位。coverage 分子靠它
821
+ // 把 aria-label / alt / title / placeholder 排除掉(它们不在 innerText 里)。
822
+ // 正常节点不写,省 token。
823
+ if (labelMeta.kind && labelMeta.kind !== "text") node.label_kind = labelMeta.kind;
756
824
  // Attach current value for input/textarea/contenteditable.
757
825
  // 截断走共享契约(src/shared/value_contract.cjs):按 Unicode code point
758
826
  // 计数,截断时带 value_truncated / value_full_length / value_fetch_ref,
@@ -885,13 +953,13 @@ function extractTree() {
885
953
  var oRect = ifr.getBoundingClientRect ? ifr.getBoundingClientRect() : { width: 0, height: 0 };
886
954
  opaqueFrames.push({
887
955
  id: ifr.id || null,
888
- src: ifr.getAttribute("src") || null,
956
+ src: (ifr.src || '').trim() || ifr.getAttribute("src") || null,
889
957
  width: Math.round(oRect.width || 0),
890
958
  height: Math.round(oRect.height || 0)
891
959
  });
892
960
  } catch (e3) { /* 尺寸拿不到也留下存在性线索 */ }
893
961
  }
894
- extraIframes.push({ id: "iframe-empty-" + counter++, role: "iframe", label: ifr.title || ifr.name || "iframe", opaque: true, src: ifr.getAttribute("src") || null });
962
+ extraIframes.push({ id: "iframe-empty-" + counter++, role: "iframe", label: ifr.title || ifr.name || "iframe", opaque: true, src: (ifr.src || '').trim() || ifr.getAttribute("src") || null });
895
963
  }
896
964
  }
897
965
  if (extraIframes.length && root) {
@@ -1386,7 +1454,7 @@ function extractPageContext() {
1386
1454
  url: document.URL,
1387
1455
  hasPasswordInput: hasPwdInput,
1388
1456
  hasAvatar: hasAvatar,
1389
- cookieString: document.cookie,
1457
+ cookieString: readCookieSafely(),
1390
1458
  });
1391
1459
 
1392
1460
  return {
@@ -1423,7 +1491,83 @@ function extractPageContext() {
1423
1491
  inputs: document.querySelectorAll("input:not([type=hidden])").length,
1424
1492
  buttons: document.querySelectorAll("button").length,
1425
1493
  links: document.querySelectorAll("a[href]").length,
1426
- iframes: document.querySelectorAll("iframe").length
1494
+ // 没有 href 的锚点(onclick / JS 驱动)。它们仍然是可点目标,而 a[href]
1495
+ // 数不到 —— 超星泛雅的章节条目就是这一类。只报 links 会让人以为这页
1496
+ // 一个链接都没有(实测该页 links=2、links_without_href=6)。
1497
+ links_without_href: (function() {
1498
+ try { return document.querySelectorAll("a:not([href])").length; } catch(e) { return 0; }
1499
+ })(),
1500
+ iframes: document.querySelectorAll("iframe").length,
1501
+ // 页面**看得见的文字**总量 —— 文字覆盖率的分母(见 shared/doc_coverage.js)。
1502
+ // 用 innerText 而不是 textContent:前者是浏览器算出来的"渲染后可见文字",
1503
+ // display:none / 折叠 / visibility:hidden 都不算。
1504
+ // 只数**非空白字符**:innerText 会把结构分隔符也吐出来(表格里的 \t、
1505
+ // 段落间的 \n),而分子(label)没有这些。实测一张 8×4 表格因此虚低到 68%,
1506
+ // 实际每个单元格都在。两边同一把尺子,这个数字才有意义。
1507
+ text_chars: (function() {
1508
+ try {
1509
+ var t = document.body && document.body.innerText;
1510
+ if (!t) return 0;
1511
+ var m = String(t).match(/[^\s\u00a0]/g);
1512
+ return m ? m.length : 0;
1513
+ } catch(e) { return 0; }
1514
+ })(),
1515
+ // 同源子帧里的可见文字。**必须单独算、并进分母**:抽取器会走进同源
1516
+ // iframe(见 winOf 的注释),所以树里有它们的文字,但
1517
+ // `document.body.innerText` 不会下钻到 iframe(iframe 是替换元素)。
1518
+ // 2026-10-19 实测:主文档 21 字 + 同源子帧 330 字,分子 83、分母 20,
1519
+ // 比例 4.15 被钳到 100% —— 一个 24% 的页面被报成"全读到了"。
1520
+ // 比例大于 1 从来不是"读得特别好",是**分母漏了一块**。
1521
+ same_origin_chars: (function() {
1522
+ var total = 0;
1523
+ try {
1524
+ var frames = document.querySelectorAll("iframe");
1525
+ for (var i = 0; i < frames.length; i++) {
1526
+ var doc = null;
1527
+ try { doc = frames[i].contentDocument; } catch(e) { doc = null; }
1528
+ if (!doc || !doc.body) continue; // 跨进程帧拿不到 contentDocument
1529
+ try {
1530
+ var t = doc.body.innerText;
1531
+ // 与 text_chars 同一把尺子:只数非空白字符
1532
+ if (t) { var m = String(t).match(/[^\s\u00a0]/g); total += m ? m.length : 0; }
1533
+ } catch(e) {}
1534
+ }
1535
+ } catch(e) {}
1536
+ return total;
1537
+ })(),
1538
+ // canvas 数量。innerText 永远看不见画在画布上的字,所以这类页面的
1539
+ // 文字覆盖率**不可度量** —— 报 100% 是彻头彻尾的谎报(实测:一个只画
1540
+ // 了 12 行字的 canvas 页,分母 4 字、报 100%)。有它才能说"量不了"。
1541
+ canvases: (function() {
1542
+ try { return document.querySelectorAll("canvas").length; } catch(e) { return 0; }
1543
+ })(),
1544
+ // open shadow root 里的文字。
1545
+ //
1546
+ // 为什么必须算:抽取器会下探 shadowRoot(见 childrenOf 的注释),所以
1547
+ // **树里有 shadow 内容**;但 `body.innerText` 不穿过 shadow 边界 ——
1548
+ // 分母里没有。实测一个 shadow 页:树里 35 字、分母只有 8 字 → 比例 4.4
1549
+ // → over_measured,看起来像"分子在重复计数",实际是分母漏了一个表面。
1550
+ //
1551
+ // 这里用 textContent 而不是 innerText,原因很实在:拿不到某个 shadow root
1552
+ // 的"渲染后文本"(host.innerText 不含 shadow 内容,逐个元素算 innerText
1553
+ // 要遍历整棵影子树并触发布局)。所以这是**已知偏保守**的一侧:shadow 里
1554
+ // 被 display:none 藏掉的文字也会被算进分母,让覆盖率略微偏低。
1555
+ // 偏低的方向是安全的 —— 它只会让"读不全"更容易被发现,不会掩盖。
1556
+ shadow_chars: (function() {
1557
+ var total = 0;
1558
+ try {
1559
+ var all = document.querySelectorAll("*");
1560
+ for (var i = 0; i < all.length; i++) {
1561
+ var sr = all[i].shadowRoot;
1562
+ if (!sr) continue;
1563
+ try {
1564
+ var t = sr.textContent;
1565
+ if (t) total += String(t).replace(/[\s\u00a0]+/g, "").length;
1566
+ } catch(e) {}
1567
+ }
1568
+ } catch(e) {}
1569
+ return total;
1570
+ })()
1427
1571
  }
1428
1572
  };
1429
1573
  }