@glittering0717/ai-browser 2.1.9 → 2.1.10
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/package.json +3 -2
- package/src/main/axExtractor.js +38 -1
- package/src/main/page_manager.js +24 -0
- package/src/shared/version.js +1 -1
- package/src/shared/wrapper_hint.js +115 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.10",
|
|
4
4
|
"description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
|
|
5
5
|
"main": "src/main/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -81,7 +81,8 @@
|
|
|
81
81
|
"verify:build": "node scripts/verify_build.cjs",
|
|
82
82
|
"install:app": "node scripts/install_app.cjs",
|
|
83
83
|
"dialog": "node e2e/dialog_ws.cjs",
|
|
84
|
-
"verify:app": "node e2e/shipped_ws.cjs"
|
|
84
|
+
"verify:app": "node e2e/shipped_ws.cjs",
|
|
85
|
+
"wrapper": "node e2e/wrapper_ws.cjs"
|
|
85
86
|
},
|
|
86
87
|
"dependencies": {
|
|
87
88
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/src/main/axExtractor.js
CHANGED
|
@@ -295,6 +295,42 @@ function isRedundantInlineBox(rawChild, parentLabel) {
|
|
|
295
295
|
|
|
296
296
|
let readValue = null; // opts.readValue — per-node DOM read hook for textbox values
|
|
297
297
|
|
|
298
|
+
// 容器角色:这些角色的 AX name 在 Chromium 里是**从子树内容算出来的**(name from
|
|
299
|
+
// contents),所以一个 listitem / generic 的 label 可能是一整段预览文字的拼接。
|
|
300
|
+
// 2026-10-25 知乎私信:`<div class="ChatUserListItem">` 的 label 就是
|
|
301
|
+
// "创作者小助手 10-21 ☄️最新一期「创作打卡挑战赛」活动来喽!…",于是按文字找链接
|
|
302
|
+
// 会命中这个行容器(点它只选中会话,不跳转),而真正的 `<a>创作打卡挑战赛第五十四期</a>`
|
|
303
|
+
// 在会话面板里。用户看到的现象就是"树里有、点不动"。
|
|
304
|
+
//
|
|
305
|
+
// 判据(纯结构、不看站点):角色是容器 + 有子孙节点 + 某个子孙的 label(≥4 字)是
|
|
306
|
+
// 自己 label 的**真子串且更短** —— 说明这段文字不是它自己的,是从下面拼上来的。
|
|
307
|
+
// 命中就打 `agg: 1`。读树的人(和 agent)看到 agg 就知道"这是容器,文字归子节点"。
|
|
308
|
+
const AGG_CONTAINER_ROLES = {
|
|
309
|
+
generic: 1, group: 1, list: 1, listitem: 1, row: 1, cell: 1, gridcell: 1,
|
|
310
|
+
section: 1, main: 1, article: 1, navigation: 1, complementary: 1, form: 1,
|
|
311
|
+
table: 1, rowgroup: 1, toolbar: 1, tablist: 1, menu: 1, tabpanel: 1, document: 1,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export function markAggregatedLabels(tree) {
|
|
315
|
+
const visit = (n) => {
|
|
316
|
+
if (!n) return { labels: [] };
|
|
317
|
+
const kids = (n.children || []).map(visit);
|
|
318
|
+
const descendantLabels = [];
|
|
319
|
+
for (const k of kids) {
|
|
320
|
+
if (k.self) descendantLabels.push(k.self);
|
|
321
|
+
for (const l of k.labels) descendantLabels.push(l);
|
|
322
|
+
}
|
|
323
|
+
const label = String(n.label || '');
|
|
324
|
+
if (AGG_CONTAINER_ROLES[n.role] && label.length >= 8 && descendantLabels.length) {
|
|
325
|
+
const aggregated = descendantLabels.some((l) => l.length >= 4 && l.length < label.length && label.indexOf(l) >= 0);
|
|
326
|
+
if (aggregated) n.agg = 1;
|
|
327
|
+
}
|
|
328
|
+
return { self: label.length >= 4 ? label : '', labels: descendantLabels };
|
|
329
|
+
};
|
|
330
|
+
visit(tree);
|
|
331
|
+
return tree;
|
|
332
|
+
}
|
|
333
|
+
|
|
298
334
|
// Public pure API.
|
|
299
335
|
// opts: { idFor: (node)=>string, readValue: (node)=>string }
|
|
300
336
|
export function normalizeAxTree(rawNodes, opts = {}) {
|
|
@@ -331,6 +367,7 @@ export function normalizeAxTree(rawNodes, opts = {}) {
|
|
|
331
367
|
const tree = built.length === 1
|
|
332
368
|
? built[0]
|
|
333
369
|
: { id: 'root', role: 'generic', label: '', states: ['visible'], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, children: built };
|
|
370
|
+
markAggregatedLabels(tree);
|
|
334
371
|
tree._debug = { layer: 'ax', version: VERSION_TAG };
|
|
335
372
|
// 有歧义时才给索引(见 summarizeEditors)。挂在树根而不是散在各节点上:
|
|
336
373
|
// 调用方读一次根就能挑好目标,不用把整棵树扫一遍再自己比面积。
|
|
@@ -632,4 +669,4 @@ export function diffLite(prevLite, newLite) {
|
|
|
632
669
|
return { revealed, hidden };
|
|
633
670
|
}
|
|
634
671
|
|
|
635
|
-
export default { normalizeAxTree, mergeFrameTrees, modalsFromAx, extractFromDebugger, axRead, axRaw, ensureHandles, attachHrefs, filterInteractive, liteSnapshot, diffLite, VERSION_TAG };
|
|
672
|
+
export default { normalizeAxTree, markAggregatedLabels, mergeFrameTrees, modalsFromAx, extractFromDebugger, axRead, axRaw, ensureHandles, attachHrefs, filterInteractive, liteSnapshot, diffLite, VERSION_TAG };
|
package/src/main/page_manager.js
CHANGED
|
@@ -22,6 +22,9 @@ import pageProbe from '../shared/page_probe.js';
|
|
|
22
22
|
// Electron 画的原生 sheet,不在 DOM、不在 AX 树,而且**打开期间渲染器被阻塞**,
|
|
23
23
|
// 所以必须由主进程通过 CDP Page.handleJavaScriptDialog 应答 —— 见该模块头部说明。
|
|
24
24
|
import dialogPolicy from '../shared/dialog_policy.cjs';
|
|
25
|
+
// "你点的是容器"诊断:容器节点的 label 是从子孙文字拼出来的,点它往往什么都不发生。
|
|
26
|
+
// 见 shared/wrapper_hint.js 顶部(2026-10-25 知乎私信活动链接事故)。
|
|
27
|
+
import { wrapperHintExpr, wrapperHintText } from '../shared/wrapper_hint.js';
|
|
25
28
|
// 默认读法(DOM extractor)的"哪个 editable 才是正文"索引。AX 路径在
|
|
26
29
|
// normalizeAxTree 里已经自带,这条补的是默认路径 —— 见文件内注释。
|
|
27
30
|
import editorsProbe from '../shared/editors_probe.js';
|
|
@@ -1485,6 +1488,13 @@ class PageManager {
|
|
|
1485
1488
|
}
|
|
1486
1489
|
const dlg = this._dialogSince(tid, startedAt);
|
|
1487
1490
|
if (dlg) out = { ...out, dialog: dlg };
|
|
1491
|
+
// 点"成功"了但页面没变,而目标自身不带文字(文字是从子节点拼出来的)——
|
|
1492
|
+
// 这是"树里有、点不动"最常见的一种:点在了包着一整段文字的容器上。
|
|
1493
|
+
// 只在这种组合下才去页面里找真正的归属节点:正常成功的点击不付这份代价。
|
|
1494
|
+
if (action === 'click' && out && out.success && out.page_changed === false && target) {
|
|
1495
|
+
const w = await this._wrapperHint(tid, target).catch(() => null);
|
|
1496
|
+
if (w) out = { ...out, wrapper: w, effect_hint: wrapperHintText(w) };
|
|
1497
|
+
}
|
|
1488
1498
|
return out;
|
|
1489
1499
|
} catch (e) {
|
|
1490
1500
|
if (e && e.__deadline) {
|
|
@@ -1544,6 +1554,20 @@ class PageManager {
|
|
|
1544
1554
|
return withEffectState(res, extras);
|
|
1545
1555
|
}
|
|
1546
1556
|
|
|
1557
|
+
/**
|
|
1558
|
+
* 「这个目标是不是个包着文字的容器」—— 是的话返回那段文字真正的归属节点。
|
|
1559
|
+
*
|
|
1560
|
+
* 为什么只在 click 且 page_changed=false 时才跑:这是"点不动"的信号,
|
|
1561
|
+
* 正常成功的点击没有理由再做一次页面扫描。诊断永远不能成为常开开销。
|
|
1562
|
+
* 任何异常/超时都吞掉(返回 null):观测能力不足不能让动作本身失败。
|
|
1563
|
+
*/
|
|
1564
|
+
async _wrapperHint(tid, target) {
|
|
1565
|
+
const js = wrapperHintExpr(target);
|
|
1566
|
+
const v = await withDeadline(this.evaluate(js, tid), 1500, 'wrapper_hint').catch(() => null);
|
|
1567
|
+
if (!v || !v.is_wrapper) return null;
|
|
1568
|
+
return v;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1547
1571
|
async _executeActionInner(action, target, params = {}, tabId) {
|
|
1548
1572
|
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
1549
1573
|
const view = this._getView(tid);
|
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 = '2.1.
|
|
5
|
+
export const VERSION = '2.1.10';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// shared/wrapper_hint.js — 「你点的这个节点是个容器,文字其实归别人」的诊断。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要(2026-10-25 知乎私信):私信列表行是 `<div class="ChatUserListItem">`,
|
|
4
|
+
// 它的 AX name 是**整段预览文字的拼接**("创作者小助手 10-21 ☄️最新一期「创作打卡
|
|
5
|
+
// 挑战赛」活动来喽!…")。于是按文字找链接时,命中的是这个行容器而不是会话里的
|
|
6
|
+
// `<a>创作打卡挑战赛第五十四期</a>`:
|
|
7
|
+
//
|
|
8
|
+
// ● [e:div-30] generic acts=["click"] ← label 是拼接出来的,点它只会选中会话
|
|
9
|
+
// ● [e:a-236] link acts=["click","focus","hover"] ← 真链接,点了才跳活动页
|
|
10
|
+
//
|
|
11
|
+
// 实测:点 e:div-30 回执 success=true / clicked_via=cdp-click,但 url 不动 —— 用户看到
|
|
12
|
+
// 的就是「树里有、点不动」。而回执里当时没有任何一句话说明"你点的是容器"。
|
|
13
|
+
//
|
|
14
|
+
// 修法分两层:
|
|
15
|
+
// ① 树里给这类容器打 agg 标记(见 axExtractor.markAggregatedLabels),让"文字属于谁"
|
|
16
|
+
// 在读树时就能分辨;
|
|
17
|
+
// ② 动作回执里补一条诊断:**目标自身没有文字**(own text 为空)却带着拼接出来的大段
|
|
18
|
+
// 文字、且页面没变时,把"这段文字真正的归属节点"找出来告诉调用方。
|
|
19
|
+
//
|
|
20
|
+
// 判定"自身没有文字"是关键:像 `<a>创作打卡…</a>` 这种,文字是它的**直接子文本节点**,
|
|
21
|
+
// own text 非空 → 不是容器,不该报。只有 own text 为空、文字全在子孙里的,才是拼接。
|
|
22
|
+
|
|
23
|
+
// 在页面里跑:给一个 data-ai-id,返回这段文字的真正归属节点(若目标只是个容器)。
|
|
24
|
+
// 返回 null 表示"目标自己就有文字,不是容器"或"找不到更合适的归属"。
|
|
25
|
+
//
|
|
26
|
+
// 为什么候选集限定在 a/button/[role]/[tabindex]/[data-ai-id]:
|
|
27
|
+
// 知乎私信页有上万个元素,全量扫描 + 逐个拼子文本会明显卡页面;而"能承担一次点击"
|
|
28
|
+
// 的元素几乎都在这个集合里。宁可漏报,也不要让诊断本身成为性能问题。
|
|
29
|
+
export function wrapperHintExpr(aiId) {
|
|
30
|
+
const ID_LITERAL = JSON.stringify(String(aiId == null ? '' : aiId));
|
|
31
|
+
return `(function(){
|
|
32
|
+
var ID = ${ID_LITERAL};
|
|
33
|
+
var el = null;
|
|
34
|
+
try { el = document.querySelector('[data-ai-id="' + ID + '"]'); } catch (e) { el = null; }
|
|
35
|
+
if (!el) return null;
|
|
36
|
+
function ownText(n) {
|
|
37
|
+
var s = '';
|
|
38
|
+
for (var i = 0; i < n.childNodes.length; i++) {
|
|
39
|
+
var c = n.childNodes[i];
|
|
40
|
+
if (c.nodeType === 3) s += c.textContent;
|
|
41
|
+
}
|
|
42
|
+
return s.replace(/\\s+/g, ' ').trim();
|
|
43
|
+
}
|
|
44
|
+
if (ownText(el).length >= 3) return null; // 自己就有文字 → 它是内容的归属,不是容器
|
|
45
|
+
var agg = String(el.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
46
|
+
if (agg.length < 6) return null; // 太短的容器没有诊断价值
|
|
47
|
+
var cands = document.querySelectorAll('a,button,[role="link"],[role="button"],[tabindex],[data-ai-id]');
|
|
48
|
+
var best = null;
|
|
49
|
+
var limit = Math.min(cands.length, 4000);
|
|
50
|
+
// "这段文字跟容器那段文字有关"的判据。为什么不用 agg.indexOf(t):
|
|
51
|
+
// 容器 label 是**拼接**出来的,中间的标点/空格/被截断的尾巴会让整串对不上。
|
|
52
|
+
// 实测知乎私信:label 里写的是「创作打卡挑战赛第五十四期 」,链接的 own text
|
|
53
|
+
// 是"创作打卡挑战赛第五十四期"(没有书名号、没有空格)。整串匹配会漏掉它,
|
|
54
|
+
// 而这恰恰就是要找的那个链接。退一步用"8 字窗口命中":只要候选文字里有连续
|
|
55
|
+
// 8 个字出现在容器文字里,就算相关 —— 中文 8 个字已经足够特异。
|
|
56
|
+
function related(aggText, t) {
|
|
57
|
+
if (aggText.indexOf(t) >= 0) return true;
|
|
58
|
+
if (t.length < 8) return false;
|
|
59
|
+
for (var k = 0; k + 8 <= t.length; k++) {
|
|
60
|
+
if (aggText.indexOf(t.slice(k, k + 8)) >= 0) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
for (var i = 0; i < limit; i++) {
|
|
65
|
+
var c = cands[i];
|
|
66
|
+
if (c === el) continue;
|
|
67
|
+
if (c.contains(el)) continue; // 目标的祖先:点它等于又点一次外层
|
|
68
|
+
var t = ownText(c);
|
|
69
|
+
if (t.length < 6) continue;
|
|
70
|
+
if (!related(agg, t)) continue;
|
|
71
|
+
var r = c.getBoundingClientRect();
|
|
72
|
+
if (r.width < 4 || r.height < 4) continue;
|
|
73
|
+
var cs = getComputedStyle(c);
|
|
74
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
|
|
75
|
+
var tag = String(c.tagName || '').toUpperCase();
|
|
76
|
+
var role = String((c.getAttribute && c.getAttribute('role')) || '');
|
|
77
|
+
var clickable = (tag === 'A' || tag === 'BUTTON' || role === 'link' || role === 'button' || cs.cursor === 'pointer');
|
|
78
|
+
var area = Math.max(1, Math.round(r.width) * Math.round(r.height));
|
|
79
|
+
// 先看能不能点(拦不住点击的节点没有价值),再比谁更精确(面积小的更贴近那段文字)
|
|
80
|
+
var score = (clickable ? 0 : 1e9) + area;
|
|
81
|
+
if (!best || score < best.score) {
|
|
82
|
+
best = {
|
|
83
|
+
score: score,
|
|
84
|
+
id: (c.getAttribute && c.getAttribute('data-ai-id')) || null,
|
|
85
|
+
tag: tag,
|
|
86
|
+
role: role || null,
|
|
87
|
+
text: t.slice(0, 60),
|
|
88
|
+
box: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
|
|
89
|
+
clickable: clickable,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!best) {
|
|
94
|
+
// 是容器,但页面里找不到"更该点"的节点:仍然要把结论说出来(别让调用方
|
|
95
|
+
// 再点第二次同样的容器),只是不编造一个 id。
|
|
96
|
+
return { is_wrapper: true, own_text: '', agg_text: agg.slice(0, 80), better_target: null };
|
|
97
|
+
}
|
|
98
|
+
delete best.score;
|
|
99
|
+
return { is_wrapper: true, own_text: '', agg_text: agg.slice(0, 80), better_target: best };
|
|
100
|
+
})()`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 回执里给调用方看的下一步。措辞刻意点名"容器",因为下一步动作完全取决于这个判断:
|
|
104
|
+
// 容器要点它里面/旁边的叶子节点,而不是再点一次同一个容器。
|
|
105
|
+
export function wrapperHintText(w) {
|
|
106
|
+
if (!w || !w.is_wrapper) return '';
|
|
107
|
+
const bt = w.better_target;
|
|
108
|
+
if (bt && bt.id) {
|
|
109
|
+
return `target is a container: its own text is empty and the label is aggregated from descendants ("${w.agg_text}"). A more precise node owns that text: ${bt.id} (${bt.role || bt.tag}${bt.clickable ? ', clickable' : ''}) — click that instead of retrying this one.`;
|
|
110
|
+
}
|
|
111
|
+
if (bt) {
|
|
112
|
+
return `target is a container: its own text is empty and the label is aggregated from descendants ("${w.agg_text}"). The text actually lives on <${bt.tag}> "${bt.text}"${bt.clickable ? ' (clickable)' : ' (not clearly clickable)'}, which has no ref yet — re-read the tree with subset:"all" to address it.`;
|
|
113
|
+
}
|
|
114
|
+
return `target is a container: its own text is empty and the label is aggregated from descendants ("${w.agg_text}"). Retrying the same node will not help — target the descendant that actually holds the text.`;
|
|
115
|
+
}
|