@glittering0717/ai-browser 1.2.2 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -19
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +7 -5
- package/scripts/build_dmg.cjs +220 -0
- package/scripts/marketing/data_snapshot.sh +69 -0
- package/scripts/render_icon.cjs +118 -0
- package/src/main/axExtractor.js +103 -36
- package/src/main/error_log.js +245 -0
- package/src/main/index.js +276 -7
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/page_manager.js +936 -128
- package/src/main/ws_server.js +73 -12
- package/src/preload/actions.cjs +61 -2
- package/src/preload/bridge.cjs +31 -1
- package/src/preload/extractor.cjs +520 -25
- package/src/renderer/error_banner.html +90 -0
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/deadline.js +6 -0
- package/src/shared/deep_active.cjs +31 -0
- package/src/shared/error_banner.cjs +43 -0
- package/src/shared/find_ref.cjs +44 -0
- package/src/shared/frame_probe.cjs +112 -0
- package/src/shared/hit_point.cjs +80 -0
- package/src/shared/input_verify.js +47 -0
- package/src/shared/key_dispatch.cjs +37 -0
- package/src/shared/nav_wait.cjs +47 -0
- package/src/shared/protocol.js +8 -0
- package/src/shared/ref_guard.cjs +133 -0
- package/src/shared/screenshot_opts.cjs +130 -0
- package/src/shared/tree_diff.cjs +105 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +239 -0
|
@@ -0,0 +1,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
|
+
});
|