@glittering0717/ai-browser 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -20
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +9 -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 +487 -16
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/network_monitor.js +86 -23
- package/src/main/page_manager.js +1040 -115
- package/src/main/ws_server.js +127 -16
- 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/renderer/error_page.html +125 -0
- package/src/renderer/tab_bar.html +116 -56
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/config.js +8 -2
- 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 +15 -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,130 @@
|
|
|
1
|
+
// src/shared/screenshot_opts.cjs — ui.screenshot / browse_screenshot 参数规范化
|
|
2
|
+
//
|
|
3
|
+
// 为什么值得单独一个模块:截图是"把页面原样交给调用方"的动作,参数错了
|
|
4
|
+
// (quality 传了字符串、clip 越界、fullPage 与 clip 同传)要么静默出坏图,
|
|
5
|
+
// 要么让 CDP 报一句看不懂的错。这里在入口一次性把参数收敛成合法形状,
|
|
6
|
+
// 坏参数当场给出可读错误,而不是拖到 CDP 那一层。
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const FORMATS = ['png', 'jpeg', 'webp'];
|
|
11
|
+
const DEFAULT_FORMAT = 'png';
|
|
12
|
+
// 512KB base64 ≈ 684KB 原始字节。单次回执再大,WS 帧和模型上下文都吃不消。
|
|
13
|
+
const MAX_DATA_URL_CHARS = 512 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 规范化截图参数。
|
|
17
|
+
* @param {object} opts 调用方传入(可能缺项/错型)
|
|
18
|
+
* @returns {{ok: true, opts: object} | {ok: false, error: string}}
|
|
19
|
+
*/
|
|
20
|
+
function normalizeScreenshotOpts(opts) {
|
|
21
|
+
const src = opts && typeof opts === 'object' ? opts : {};
|
|
22
|
+
const out = {};
|
|
23
|
+
|
|
24
|
+
// --- format ---
|
|
25
|
+
let format = DEFAULT_FORMAT;
|
|
26
|
+
if (src.format !== undefined && src.format !== null) {
|
|
27
|
+
if (typeof src.format !== 'string' || !FORMATS.includes(src.format)) {
|
|
28
|
+
return { ok: false, error: `format must be one of ${FORMATS.join('/')} (got ${JSON.stringify(src.format)})` };
|
|
29
|
+
}
|
|
30
|
+
format = src.format;
|
|
31
|
+
}
|
|
32
|
+
out.format = format;
|
|
33
|
+
|
|
34
|
+
// --- quality(仅 jpeg/webp 有意义;png 无损,传了忽略而不是报错)---
|
|
35
|
+
if (src.quality !== undefined && src.quality !== null) {
|
|
36
|
+
const q = Number(src.quality);
|
|
37
|
+
if (!Number.isFinite(q) || q < 0 || q > 100) {
|
|
38
|
+
return { ok: false, error: 'quality must be a number in 0..100' };
|
|
39
|
+
}
|
|
40
|
+
out.quality = Math.round(q);
|
|
41
|
+
} else if (format === 'jpeg') {
|
|
42
|
+
out.quality = 85; // 文字页面 85 肉眼无损,体积约为 png 的 1/5
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- fullPage(captureBeyondViewport)---
|
|
46
|
+
// null 与 undefined 同样视为"未提供":JSON 里显式传 null 表示"用默认值",
|
|
47
|
+
// 不该和传错类型('yes'/1)一样报错。
|
|
48
|
+
if (src.fullPage != null && typeof src.fullPage !== 'boolean') {
|
|
49
|
+
return { ok: false, error: 'fullPage must be a boolean' };
|
|
50
|
+
}
|
|
51
|
+
out.fullPage = src.fullPage === true;
|
|
52
|
+
|
|
53
|
+
// --- path(可选:把图直接落盘,省得调用方自己解 base64)---
|
|
54
|
+
// 此前传 path 会被**静默忽略**:调用方拿到 ok:true 以为文件写好了。
|
|
55
|
+
// 这是本项目反复踩的同一类坑(参数被吞 → 结论错),所以在规范层直接支持。
|
|
56
|
+
if (src.path !== undefined && src.path !== null && src.path !== '') {
|
|
57
|
+
if (typeof src.path !== 'string' || !src.path.trim()) {
|
|
58
|
+
return { ok: false, error: 'path must be a non-empty absolute file path' };
|
|
59
|
+
}
|
|
60
|
+
out.path = src.path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- clip(设备像素坐标;不传则整视口)---
|
|
64
|
+
if (src.clip !== undefined && src.clip !== null) {
|
|
65
|
+
const c = src.clip;
|
|
66
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : NaN);
|
|
67
|
+
const clip = {
|
|
68
|
+
x: num(c.x),
|
|
69
|
+
y: num(c.y),
|
|
70
|
+
width: num(c.width),
|
|
71
|
+
height: num(c.height),
|
|
72
|
+
scale: c.scale === undefined ? 1 : num(c.scale),
|
|
73
|
+
};
|
|
74
|
+
if (['x', 'y', 'width', 'height'].some((k) => Number.isNaN(clip[k]))) {
|
|
75
|
+
return { ok: false, error: 'clip.x/y/width/height must all be numbers' };
|
|
76
|
+
}
|
|
77
|
+
if (!Number.isFinite(clip.scale) || clip.scale <= 0 || clip.scale > 4) {
|
|
78
|
+
return { ok: false, error: 'clip.scale must be a number in (0, 4]' };
|
|
79
|
+
}
|
|
80
|
+
if (clip.width <= 0 || clip.height <= 0) {
|
|
81
|
+
return { ok: false, error: 'clip.width/height must be positive' };
|
|
82
|
+
}
|
|
83
|
+
if (clip.x < 0 || clip.y < 0) {
|
|
84
|
+
return { ok: false, error: 'clip.x/y must be >= 0' };
|
|
85
|
+
}
|
|
86
|
+
// clip 与 captureBeyondViewport 同时传时,Chromium 的语义是"从 clip 顶部
|
|
87
|
+
// 开始一路拍到页面底部"——调用方九成不是这个意思,且极易拍出几十 MB 的图。
|
|
88
|
+
// 当场拦下,让调用方明确二选一。
|
|
89
|
+
if (out.fullPage) {
|
|
90
|
+
return { ok: false, error: 'fullPage and clip are mutually exclusive (captureBeyondViewport+clip means "clip top through page bottom" — pick one)' };
|
|
91
|
+
}
|
|
92
|
+
out.clip = clip;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ok: true, opts: out };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 回执摘要:调用方拿到图之后最想知道的几件事,省得它再发一轮查询。
|
|
100
|
+
* 敏感字段(密码框在场)单独标,因为 captureScreenshot 不会模糊它。
|
|
101
|
+
*/
|
|
102
|
+
function describeShot(meta) {
|
|
103
|
+
const m = meta && typeof meta === 'object' ? meta : {};
|
|
104
|
+
const out = {
|
|
105
|
+
format: m.format || 'png',
|
|
106
|
+
mime: m.mime || 'image/png',
|
|
107
|
+
bytes: typeof m.bytes === 'number' ? m.bytes : null,
|
|
108
|
+
width: typeof m.width === 'number' ? m.width : null,
|
|
109
|
+
height: typeof m.height === 'number' ? m.height : null,
|
|
110
|
+
truncated: false,
|
|
111
|
+
};
|
|
112
|
+
if (typeof m.url === 'string') out.url = m.url;
|
|
113
|
+
if (typeof m.title === 'string') out.title = m.title;
|
|
114
|
+
// CDP 的 clip/视口坐标是 CSS 像素,输出图是设备像素。带上 DPR,调用方才能
|
|
115
|
+
// 把图上的像素坐标换算回页面坐标(Retina 上差 2 倍)。
|
|
116
|
+
if (typeof m.devicePixelRatio === 'number' && m.devicePixelRatio > 0) {
|
|
117
|
+
out.device_pixel_ratio = m.devicePixelRatio;
|
|
118
|
+
}
|
|
119
|
+
if (m.hasVisiblePasswordInput === true) {
|
|
120
|
+
out.warning = 'page has a visible password input — the screenshot may contain typed secrets';
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
FORMATS,
|
|
127
|
+
MAX_DATA_URL_CHARS,
|
|
128
|
+
normalizeScreenshotOpts,
|
|
129
|
+
describeShot,
|
|
130
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// 悬停前后两棵语义树的差异(ui.peek 的核心)。
|
|
2
|
+
//
|
|
3
|
+
// 数据源从 AX 换成 DOM 语义树,是实测逼出来的两个理由:
|
|
4
|
+
//
|
|
5
|
+
// 1. 自绘菜单在 AX 里是「无名的 generic」。实测(tools/_oneoff/peek 对照页):
|
|
6
|
+
// 一个 hover 后展开的纯 div 菜单,AX 报 7 个新节点,但两个菜单项都长这样 ——
|
|
7
|
+
// generic "" ← 名字是空的,文字跑到子节点 StaticText 去了
|
|
8
|
+
// StaticText "普通选项一"
|
|
9
|
+
// 而 AX 侧的"可交互"判据是 role 名单 / editable / focusable,generic 一条都不占
|
|
10
|
+
// → 被过滤掉 → peek 对这类菜单一律报空。DOM 读层有 isHumanClickable +
|
|
11
|
+
// floatingLayerOf + elementName,正好能认出它们(同一次实测:DOM 侧报出 2 项,
|
|
12
|
+
// 都带 actions=["click"] 和坐标)。
|
|
13
|
+
//
|
|
14
|
+
// 2. 更致命:AX 的 id 形如 ax-{backendNodeId},动作层解析不了(实测
|
|
15
|
+
// "Target not found for ref: ax-291")。就算 AX 探到了,报出去的 id 也点不了。
|
|
16
|
+
// DOM 的 e:div-N 是 ui.act 直接可用的。
|
|
17
|
+
//
|
|
18
|
+
// 代价:DOM 抽取会写 data-ai-id 属性(get_tree 本来就在做,peek 此前也调
|
|
19
|
+
// ensureHandles 打过标记),不再是"纯读"。换取的是报出来的东西真能点。
|
|
20
|
+
|
|
21
|
+
// 拍平。保留根节点(它是 body 的映射,diff 时两边一致,会被自然抵消)。
|
|
22
|
+
function flattenTree(root) {
|
|
23
|
+
var out = [];
|
|
24
|
+
(function walk(n) {
|
|
25
|
+
if (!n) return;
|
|
26
|
+
out.push(n);
|
|
27
|
+
var kids = n.children || [];
|
|
28
|
+
for (var i = 0; i < kids.length; i++) walk(kids[i]);
|
|
29
|
+
})(root);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 输出给调用方的精简节点。带 actions 与 bounds,是为了让"探到了"直接等于"能点"。
|
|
34
|
+
function toLite(n) {
|
|
35
|
+
var o = { id: n.id, role: n.role || 'generic', label: n.label == null ? '' : n.label };
|
|
36
|
+
if (n.actions && n.actions.length) o.actions = n.actions.slice();
|
|
37
|
+
if (n.bounds) o.bounds = n.bounds;
|
|
38
|
+
return o;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 位置对得上就算同一个(菜单重开时会有 1~2px 的抖动)。
|
|
42
|
+
function sameBounds(a, b) {
|
|
43
|
+
if (!a || !b) return false;
|
|
44
|
+
return Math.abs(a.x - b.x) <= 4 && Math.abs(a.y - b.y) <= 4
|
|
45
|
+
&& Math.abs(a.width - b.width) <= 4 && Math.abs(a.height - b.height) <= 4;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 菜单收起再展开时,DOM 节点是新造的,id 跟着变 —— 上一张快照报出去的 id 就点不动了
|
|
49
|
+
// (实测 B站 头部「收藏」:revertMs=0 把鼠标移回目标后,原先报出的三个 id
|
|
50
|
+
// e:div-208 / e:div-209 / e:a-229 全部消失)。想让"探到就能点"成立,必须把 id
|
|
51
|
+
// 重新绑到新节点上。
|
|
52
|
+
//
|
|
53
|
+
// 认亲用 (role, label, bounds) 三元组:同一个菜单项重开后文本和位置不会变,
|
|
54
|
+
// 而 id 会。三者全中才认,宁可对不上也不乱指 —— 对不上时保留原 id,调用方会拿到
|
|
55
|
+
// target_not_found,这比悄悄点到别的东西上安全得多。
|
|
56
|
+
function remapIds(liteNodes, nowNodes) {
|
|
57
|
+
var now = nowNodes || [];
|
|
58
|
+
return (liteNodes || []).map(function (n) {
|
|
59
|
+
var i;
|
|
60
|
+
for (i = 0; i < now.length; i++) if (now[i].id === n.id) return n; // 还在就别动
|
|
61
|
+
for (i = 0; i < now.length; i++) {
|
|
62
|
+
var c = now[i];
|
|
63
|
+
if (c.role === n.role && (c.label || '') === (n.label || '') && sameBounds(c.bounds, n.bounds)) {
|
|
64
|
+
// 只换 id,其余(actions / bounds)沿用原来那份 —— 重绑是为了让 id 活过来,
|
|
65
|
+
// 不是为了重算一次,顺手把已经报给调用方的信息弄丢反而更糟。
|
|
66
|
+
var o = {};
|
|
67
|
+
for (var k in n) if (Object.prototype.hasOwnProperty.call(n, k)) o[k] = n[k];
|
|
68
|
+
o.id = c.id;
|
|
69
|
+
return o;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return n;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 可点的排前面:悬停探出来的东西里,菜单项永远比包裹它的容器有用。
|
|
77
|
+
function actionableFirst(a, b) {
|
|
78
|
+
var ac = (a.actions && a.actions.length) ? 1 : 0;
|
|
79
|
+
var bc = (b.actions && b.actions.length) ? 1 : 0;
|
|
80
|
+
if (ac !== bc) return bc - ac;
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function diffTree(beforeNodes, afterNodes) {
|
|
85
|
+
var before = beforeNodes || [];
|
|
86
|
+
var after = afterNodes || [];
|
|
87
|
+
var bi = {}, ai = {}, i;
|
|
88
|
+
for (i = 0; i < before.length; i++) if (before[i] && before[i].id != null) bi[before[i].id] = before[i];
|
|
89
|
+
for (i = 0; i < after.length; i++) if (after[i] && after[i].id != null) ai[after[i].id] = after[i];
|
|
90
|
+
var revealed = [], hidden = [];
|
|
91
|
+
for (i = 0; i < after.length; i++) {
|
|
92
|
+
var n = after[i];
|
|
93
|
+
if (!n || n.id == null) continue;
|
|
94
|
+
if (!Object.prototype.hasOwnProperty.call(bi, n.id)) revealed.push(toLite(n));
|
|
95
|
+
}
|
|
96
|
+
for (i = 0; i < before.length; i++) {
|
|
97
|
+
var m = before[i];
|
|
98
|
+
if (!m || m.id == null) continue;
|
|
99
|
+
if (!Object.prototype.hasOwnProperty.call(ai, m.id)) hidden.push(toLite(m));
|
|
100
|
+
}
|
|
101
|
+
revealed.sort(actionableFirst);
|
|
102
|
+
return { revealed: revealed, hidden: hidden };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { flattenTree, diffTree, toLite, remapIds, sameBounds };
|
package/src/shared/version.js
CHANGED
|
@@ -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.
|
|
5
|
+
export const VERSION = '1.3.0';
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/coverage_probe.cjs — 「页面上一半能点的东西,语义树里看不见」的量化探针。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要它(2026-10-17 实测):
|
|
5
|
+
// 在真实站点上量「用户看着能点」的元素有多少真的进了语义树,结果是
|
|
6
|
+
// 百度 43%、B站 33%、CSDN 60%。也就是说**一半左右的可点目标 agent 拿不到 ref**,
|
|
7
|
+
// 于是每次任务都退化成一个站点专用脚本去 querySelector 狙击 —— 26 个临时脚本里
|
|
8
|
+
// 142 个是站点特化、731 处硬编码选择器,就是这么来的。
|
|
9
|
+
//
|
|
10
|
+
// 这个数字此前没人量过。没有它,"通用能力够不够"只能靠感觉争论;有了它,
|
|
11
|
+
// 改进就能被验证:改完抽取层再跑一遍,覆盖率应该上去,而不是"感觉好点了"。
|
|
12
|
+
//
|
|
13
|
+
// 判据为什么用 cursor:pointer:
|
|
14
|
+
// 它是**浏览器自己算出来的可点性** —— 人手看到手型光标就知道这儿能点,浏览器
|
|
15
|
+
// 也是这么画的。用它当分母,比"猜哪些 div 挂了监听器"更接近"人看到的东西"。
|
|
16
|
+
//
|
|
17
|
+
// 用法:
|
|
18
|
+
// node tools/coverage_probe.cjs https://www.bilibili.com/
|
|
19
|
+
// node tools/coverage_probe.cjs https://www.csdn.net/ 12000 --json
|
|
20
|
+
//
|
|
21
|
+
// 需要 ai-browser 正在运行(默认 9223,AI_BROWSER_PORT 可改)。
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { WebSocket } = require('ws');
|
|
26
|
+
|
|
27
|
+
const URL_ARG = process.argv[2];
|
|
28
|
+
const WAIT = Number(process.argv[3]) || 9000;
|
|
29
|
+
const AS_JSON = process.argv.indexOf('--json') >= 0;
|
|
30
|
+
const PORT = Number(process.env.AI_BROWSER_PORT) || 9223;
|
|
31
|
+
|
|
32
|
+
if (!URL_ARG) {
|
|
33
|
+
console.error('usage: node tools/coverage_probe.cjs <url> [waitMs] [--json]');
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeClient(port) {
|
|
38
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
39
|
+
let id = 1;
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
ws.on('message', (raw) => {
|
|
42
|
+
const m = JSON.parse(raw.toString());
|
|
43
|
+
if (m.id && pending.has(m.id)) {
|
|
44
|
+
const p = pending.get(m.id);
|
|
45
|
+
pending.delete(m.id);
|
|
46
|
+
m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const call = (method, params = {}, timeout = 120000) => new Promise((resolve, reject) => {
|
|
50
|
+
const i = id++;
|
|
51
|
+
pending.set(i, { resolve, reject });
|
|
52
|
+
ws.send(JSON.stringify({ jsonrpc: '2.0', id: i, method, params }));
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
if (pending.has(i)) { pending.delete(i); reject(new Error('timeout ' + method)); }
|
|
55
|
+
}, timeout);
|
|
56
|
+
});
|
|
57
|
+
const ready = new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
|
|
58
|
+
return { ws, call, ready, close: () => { try { ws.close(); } catch {} } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
62
|
+
|
|
63
|
+
// 在页面里数:手型元素总数 / 其中带 data-ai-id 的(= 真的进了树)
|
|
64
|
+
//
|
|
65
|
+
// ⚠️ 分母必须先修正,否则整件事都是错的。
|
|
66
|
+
// cursor 是**可继承**的 CSS 属性:给一张卡片设了 cursor:pointer,卡片里所有
|
|
67
|
+
// div/span/svg/path 计算出来全是 pointer。按"元素"数分母,CSDN 的
|
|
68
|
+
// .layout-menu-item / _info / _icon / _name 会被算成 4 个目标,而人看着只有 1 个
|
|
69
|
+
// 可点区块(点哪个位置效果都一样)。B站 nav 的 svg+path 同理。
|
|
70
|
+
// 所以"能点的东西"的计量单位是**手型边界** = 自己的 cursor 是 pointer、
|
|
71
|
+
// 而父元素的 cursor **不是** pointer 的那个最外层元素。
|
|
72
|
+
//
|
|
73
|
+
// 两个口径都输出:element 口径(和此前数据可比)+ boundary 口径(人的视角)。
|
|
74
|
+
//
|
|
75
|
+
// 分类(两个口径各自算一遍):
|
|
76
|
+
// inTree 自己带 id → 直接可操作(真覆盖)
|
|
77
|
+
// byDesc 自己被单个交互后代盖满,而那个后代在树里
|
|
78
|
+
// (<div class=x><a>首页</a></div> 这种壳子,点里面的 a 就是点它)
|
|
79
|
+
// ancOnly 最近带 id 的祖先下只有它这一个手型后代 → 点祖先等价于点它
|
|
80
|
+
// ambiguous 同一祖先下有多个手型后代 → 点祖先指不到唯一目标
|
|
81
|
+
// noIdChain 自己和整条祖先链都没有 id → **真缺口**
|
|
82
|
+
// 真缺口 = ambiguous + noIdChain。
|
|
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]';
|
|
85
|
+
function coveredByDesc(el, r){
|
|
86
|
+
var hits;
|
|
87
|
+
try { hits = el.querySelectorAll(INTER); } catch(e) { return false; }
|
|
88
|
+
if (!hits || hits.length !== 1) return false;
|
|
89
|
+
var inner = hits[0];
|
|
90
|
+
if (!inner.hasAttribute('data-ai-id')) return false;
|
|
91
|
+
var b = inner.getBoundingClientRect();
|
|
92
|
+
if (!(b.width > 0) || !(b.height > 0)) return false;
|
|
93
|
+
var w = Math.min(r.right, b.right) - Math.max(r.left, b.left);
|
|
94
|
+
var h = Math.min(r.bottom, b.bottom) - Math.max(r.top, b.top);
|
|
95
|
+
if (!(w > 0) || !(h > 0)) return false;
|
|
96
|
+
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;
|
|
101
|
+
}
|
|
102
|
+
function scan(onlyBoundary){
|
|
103
|
+
var s = {pointer: 0, inTree: 0, byDesc: 0, ancOnly: 0, ambiguous: 0, noIdChain: 0, missing: []};
|
|
104
|
+
var all = document.querySelectorAll('*');
|
|
105
|
+
var list = [];
|
|
106
|
+
for (var i = 0; i < all.length; i++) {
|
|
107
|
+
var el = all[i];
|
|
108
|
+
var r = el.getBoundingClientRect();
|
|
109
|
+
if (r.width < 4 || r.height < 4) continue; // 不可见/装饰性小块
|
|
110
|
+
var cs = getComputedStyle(el);
|
|
111
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
|
|
112
|
+
if (cs.cursor !== 'pointer') continue;
|
|
113
|
+
if (onlyBoundary) {
|
|
114
|
+
var p = el.parentElement;
|
|
115
|
+
// 父元素也是 pointer → 它是继承来的,不是独立的可点目标
|
|
116
|
+
if (p && getComputedStyle(p).cursor === 'pointer') continue;
|
|
117
|
+
}
|
|
118
|
+
s.pointer++;
|
|
119
|
+
if (el.hasAttribute('data-ai-id')) { s.inTree++; continue; }
|
|
120
|
+
if (coveredByDesc(el, r)) { s.byDesc++; continue; }
|
|
121
|
+
var anc = el.parentElement, anchor = null;
|
|
122
|
+
while (anc && anc !== document.body) {
|
|
123
|
+
if (anc.hasAttribute('data-ai-id')) { anchor = anc; break; }
|
|
124
|
+
anc = anc.parentElement;
|
|
125
|
+
}
|
|
126
|
+
list.push({ el: el, anchor: anchor });
|
|
127
|
+
}
|
|
128
|
+
var perAnchor = new Map();
|
|
129
|
+
for (var j = 0; j < list.length; j++) {
|
|
130
|
+
if (!list[j].anchor) continue;
|
|
131
|
+
perAnchor.set(list[j].anchor, (perAnchor.get(list[j].anchor) || 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
for (var k = 0; k < list.length; k++) {
|
|
134
|
+
var it = list[k], el = it.el, kind;
|
|
135
|
+
if (!it.anchor) { s.noIdChain++; kind = 'noIdChain'; }
|
|
136
|
+
else if (perAnchor.get(it.anchor) === 1) { s.ancOnly++; kind = 'ancOnly'; }
|
|
137
|
+
else { s.ambiguous++; kind = 'ambiguous'; }
|
|
138
|
+
if (s.missing.length < 40 && kind !== 'ancOnly') {
|
|
139
|
+
s.missing.push({
|
|
140
|
+
tag: el.tagName.toLowerCase(),
|
|
141
|
+
cls: String(el.className || '').slice(0, 44),
|
|
142
|
+
text: (el.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 28),
|
|
143
|
+
// onclick 属性 vs addEventListener:后者是 Vue/React 的默认写法,
|
|
144
|
+
// 而抽取层只看得到前者
|
|
145
|
+
reg: el.onclick === null ? 'listener' : 'attr',
|
|
146
|
+
kind: kind,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
var byEl = scan(false);
|
|
153
|
+
var byBound = scan(true);
|
|
154
|
+
return JSON.stringify({ el: byEl, boundary: byBound });
|
|
155
|
+
})()`;
|
|
156
|
+
|
|
157
|
+
(async () => {
|
|
158
|
+
const c = makeClient(PORT);
|
|
159
|
+
await c.ready;
|
|
160
|
+
|
|
161
|
+
const nt = await c.call('ui.new_tab', { url: URL_ARG });
|
|
162
|
+
const TAB = nt && nt.tab;
|
|
163
|
+
await sleep(WAIT);
|
|
164
|
+
|
|
165
|
+
const info = await c.call('ui.evaluate', {
|
|
166
|
+
tab: TAB,
|
|
167
|
+
js: 'JSON.stringify({url:location.href,title:document.title})',
|
|
168
|
+
}).then((r) => JSON.parse(String(r.value))).catch(() => ({ url: URL_ARG, title: '' }));
|
|
169
|
+
|
|
170
|
+
// 先读树:抽取过程才会给命中的元素打上 data-ai-id
|
|
171
|
+
const raw = await c.call('ui.get_tree', { tab: TAB });
|
|
172
|
+
const root = raw.tree || raw.root || raw;
|
|
173
|
+
let nodes = 0;
|
|
174
|
+
(function walk(n) {
|
|
175
|
+
if (!n) return;
|
|
176
|
+
nodes++;
|
|
177
|
+
(n.children || []).forEach(walk);
|
|
178
|
+
})(root);
|
|
179
|
+
|
|
180
|
+
const r = await c.call('ui.evaluate', { tab: TAB, js: COUNT_JS }, 60000);
|
|
181
|
+
const raw2 = JSON.parse(String(r.value));
|
|
182
|
+
const d = raw2.boundary; // 人的视角:手型边界
|
|
183
|
+
const e = raw2.el; // 元素视角:和此前数据可比,但分母被继承放大了
|
|
184
|
+
const pct = (n, tot) => (tot ? Math.round((n / tot) * 100) : null);
|
|
185
|
+
|
|
186
|
+
const report = {
|
|
187
|
+
url: info.url,
|
|
188
|
+
title: info.title,
|
|
189
|
+
tree_nodes: nodes,
|
|
190
|
+
// 主要口径:手型边界
|
|
191
|
+
click_targets: d.pointer,
|
|
192
|
+
in_tree: d.inTree,
|
|
193
|
+
covered_by_descendant: d.byDesc,
|
|
194
|
+
covered_by_ancestor_only: d.ancOnly,
|
|
195
|
+
ambiguous_ancestor: d.ambiguous,
|
|
196
|
+
no_id_chain: d.noIdChain,
|
|
197
|
+
coverage_pct: pct(d.inTree, d.pointer),
|
|
198
|
+
usable_pct: pct(d.inTree + d.byDesc + d.ancOnly, d.pointer),
|
|
199
|
+
blind: d.ambiguous + d.noIdChain,
|
|
200
|
+
// 次要口径:所有 cursor:pointer 元素(含继承),仅供与历史数据对比
|
|
201
|
+
legacy: {
|
|
202
|
+
pointer_elements: e.pointer,
|
|
203
|
+
in_tree: e.inTree,
|
|
204
|
+
coverage_pct: pct(e.inTree, e.pointer),
|
|
205
|
+
},
|
|
206
|
+
missing: d.missing,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (AS_JSON) {
|
|
210
|
+
console.log(JSON.stringify(report, null, 2));
|
|
211
|
+
} else {
|
|
212
|
+
console.log('页面:', report.url);
|
|
213
|
+
console.log('树节点数:', nodes);
|
|
214
|
+
console.log('可点目标(手型边界):', d.pointer);
|
|
215
|
+
console.log(' ├ 自己带 data-ai-id(直接可操作):', d.inTree, '→ ' + (report.coverage_pct ?? 'n/a') + '%');
|
|
216
|
+
console.log(' ├ 被单个交互后代盖满且它在树里(壳子,点内层等价):', d.byDesc);
|
|
217
|
+
console.log(' ├ 仅祖先可定位(点祖先等价于它,且祖先下只有它一个):', d.ancOnly);
|
|
218
|
+
console.log(' ├ 祖先歧义(同一祖先下多个手型后代,点祖先指不准):', d.ambiguous);
|
|
219
|
+
console.log(' └ 整条祖先链无 id(真看不见):', d.noIdChain);
|
|
220
|
+
console.log('可用率(in_tree + by_desc + anc_only):', report.usable_pct ?? 'n/a', '%', '| 真缺口:', report.blind);
|
|
221
|
+
console.log('(历史口径 cursor:pointer 元素数 ' + e.pointer + ',覆盖 ' + (report.legacy.coverage_pct ?? 'n/a') + '%)');
|
|
222
|
+
if (d.missing.length) {
|
|
223
|
+
console.log('\n没进树的可点目标(ancOnly 已排除,前 40 个):');
|
|
224
|
+
console.log(' ' + 'tag'.padEnd(7) + 'class'.padEnd(46) + 'text'.padEnd(30) + 'kind');
|
|
225
|
+
for (const m of d.missing) {
|
|
226
|
+
console.log(' ' + m.tag.padEnd(7)
|
|
227
|
+
+ ('[' + m.cls + ']').padEnd(46)
|
|
228
|
+
+ JSON.stringify(m.text).padEnd(30)
|
|
229
|
+
+ m.kind + (m.reg === 'attr' ? ' attr' : ''));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
c.close();
|
|
235
|
+
process.exit(report.usable_pct !== null && report.usable_pct < 80 ? 1 : 0);
|
|
236
|
+
})().catch((e) => {
|
|
237
|
+
console.error('coverage_probe error:', e && e.message);
|
|
238
|
+
process.exit(2);
|
|
239
|
+
});
|