@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.
@@ -0,0 +1,47 @@
1
+ // 输入校验用的文本归一化。
2
+ //
3
+ // 为什么需要它:`ui.act {action:'type'|'setContent'}` 走 CDP 受信键入,输完之后要
4
+ // 回读可编辑区内容,确认"字真的打出去了"。回读来的文本和输入的目标文本不会逐字节
5
+ // 相等 —— 富文本编辑器会按自己的排版规则重写内容(最常见的是把一个语义换行渲染成
6
+ // 一串 \n)。直接逐字比对会让**已经成功**的输入被判成失败,回一个
7
+ // `cdp-input-failed`,调用方据此重试或放弃。这属于谎报,是本项目最忌讳的一类问题。
8
+ //
9
+ // 用法:
10
+ // normalizeInputText(s) —— 校验主口径:忽略排版空白 + 压缩连续换行
11
+ // skeletalInputText(s) —— 兜底口径:连换行一起剥,只用于判断"内容有没有落上去"
12
+ //
13
+ // 设计取舍(重要):绝不能简单粗暴地把 \s 全剥掉。那样 "a\nb\nc" 与 "abc" 会等价,
14
+ // 段落结构整个没了还判成功 —— 等于把谎报换成另一种谎报。所以:
15
+ // - 排版空白(空格 / tab)压掉:编辑器加缩进用户看不出来,不算差异;
16
+ // - 换行**参与**比对:段落结构必须保住;
17
+ // - 但连续换行压成一个:分几层由编辑器决定,不该由调用方的文本决定。
18
+
19
+ /** 校验主口径:\r\n 归一 → 压排版空白 → 连续换行压成一个。 */
20
+ export function normalizeInputText(s) {
21
+ return String(s || '')
22
+ .replace(/\r\n?/g, '\n')
23
+ .replace(/[^\S\n]+/g, '')
24
+ .replace(/\n{2,}/g, '\n');
25
+ }
26
+
27
+ /** 兜底口径:在 normalizeInputText 之上再剥掉所有换行,得到纯内容指纹。 */
28
+ export function skeletalInputText(s) {
29
+ return normalizeInputText(s).replace(/\n+/g, '');
30
+ }
31
+
32
+ /**
33
+ * 判定一次输入是否达到了预期。
34
+ * @param {string} expected 调用方想写入的文本
35
+ * @param {string} actual 回读到的可编辑区内容
36
+ * @returns {{ok: boolean, landed: boolean}} ok=按主口径达标;landed=忽略全部空白后内容已到位(排版被重排过)
37
+ */
38
+ export function verifyInputText(expected, actual) {
39
+ const want = normalizeInputText(expected);
40
+ const got = normalizeInputText(actual);
41
+ // clear(空目标):允许残留空块外壳 —— 浏览器永远会留一个空块承载光标。
42
+ if (!want) return { ok: skeletalInputText(actual) === '', landed: true };
43
+ const ok = got.includes(want);
44
+ // 忽略全部空白后再比一次:相等说明东西其实打进去了、只是被编辑器重新排了版。
45
+ const landed = ok || skeletalInputText(actual) === skeletalInputText(expected);
46
+ return { ok, landed };
47
+ }
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ // src/shared/key_dispatch.cjs —— 「这一次按键该发什么 CDP 事件」的纯决策。
3
+ //
4
+ // 抽成纯函数只为一件事:这是"按了等于没按"那类缺陷的唯一入口,必须能单测。
5
+ // 藏在 PageManager 里就只能靠真起一个 Electron 才能验证。
6
+ //
7
+ // 核心事实:CDP 的 `rawKeyDown` **刻意不带 text**,浏览器只派发 keydown、
8
+ // 不产生 keypress。而 Enter 激活聚焦元素(<button> 的 click、<a> 的跳转、
9
+ // 表单的隐式提交)走的正是默认动作 —— 也就是 keypress。
10
+ // 实测(tools/_oneoff/probe_press_enter.cjs,file:// 页面):focus 一个 button
11
+ // 后 ui.act press Enter,页面收到 keydown(e.key==='Enter'、isTrusted=true),
12
+ // 但 onclick **一次都没触发**;同元素 click 正常。
13
+ // Puppeteer 的 keyboard.press('Enter') 有效,是因为它的键表给 Enter 配了
14
+ // text='\r',走的是**带 text 的 keyDown**。
15
+ //
16
+ // 两种语义必须分开,不能全局替换:
17
+ // activate=true → 带 text 的 keyDown(产生默认动作)
18
+ // activate 缺省 → rawKeyDown(+ 可选 char),type 的段间换行依赖它
19
+ // 理由见 page_manager._cdpKey 的注释:type 里"先发不带 text 的 Enter,看编辑器
20
+ // 有没有自己分段,没分才补 char"这个策略不能被改坏。
21
+ function keyDispatchPlan(p, opts) {
22
+ const spec = p || {};
23
+ const activating = !!(opts && opts.activate);
24
+ // 只有 Enter 需要"补"一个默认文本:它的默认动作在 keypress 上。
25
+ // 其它键(Tab/Delete/方向键)的默认动作在 keydown 上,rawKeyDown 已经够。
26
+ let text = '';
27
+ if (activating) {
28
+ if (spec.text === undefined) text = spec.key === 'Enter' ? '\r' : '';
29
+ else text = spec.text || '';
30
+ }
31
+ if (activating && text) {
32
+ return { type: 'keyDown', text, charText: '' };
33
+ }
34
+ return { type: 'rawKeyDown', text: '', charText: spec.text || '' };
35
+ }
36
+
37
+ module.exports = { keyDispatchPlan };
@@ -0,0 +1,47 @@
1
+ // src/shared/nav_wait.cjs — ui.navigate 的完成信号(纯函数)
2
+ //
3
+ // 事故:SPA(尤其带 markdown 高亮的编辑器)永远不 fire `load`,而 navigate 走的是
4
+ // `webContents.loadURL()` —— 它在 did-finish-load 才 resolve。于是导航"成功"的页面
5
+ // 被当成超时:30s 空等,期间 tab 被拖到无响应,之后所有 act/evaluate 连锁超时,
6
+ // agent 据此得出"编辑器坏了"的结论。
7
+ //
8
+ // load 不是可用的完成信号。这里把"等到哪一步"显式化:
9
+ // commit 主帧导航已提交(最快;内容可能还没渲染)
10
+ // domcontentloaded DOM 就绪(默认;SPA 在这一步就能返回)
11
+ // load 连子资源一起等(老行为,只在确实需要时用)
12
+
13
+ 'use strict';
14
+
15
+ const LEVELS = ['commit', 'domcontentloaded', 'load'];
16
+ const DEFAULT_LEVEL = 'domcontentloaded';
17
+
18
+ // 同义词归一。刻意不接受 networkidle:它需要网络空闲探测,本实现里没有对应
19
+ // 机制 —— 承诺一个做不到的档位,比只给三个真实档位更糟。
20
+ const ALIASES = {
21
+ commit: 'commit',
22
+ dom: 'domcontentloaded',
23
+ domready: 'domcontentloaded',
24
+ domcontentloaded: 'domcontentloaded',
25
+ interactive: 'domcontentloaded',
26
+ load: 'load',
27
+ full: 'load',
28
+ };
29
+
30
+ /**
31
+ * 归一 wait_until。返回 { value } 或 { error }(error 可直接给调用方看)。
32
+ */
33
+ function normalizeWaitUntil(v) {
34
+ if (v === undefined || v === null || v === '') return { value: DEFAULT_LEVEL };
35
+ const key = String(v).toLowerCase().replace(/[\s_-]/g, '');
36
+ const hit = ALIASES[key];
37
+ if (!hit) {
38
+ return {
39
+ error: 'unknown wait_until: ' + JSON.stringify(String(v))
40
+ + ' — valid values are ' + LEVELS.join(' | ')
41
+ + '. Default is domcontentloaded; use load only when you really need subresources.',
42
+ };
43
+ }
44
+ return { value: hit };
45
+ }
46
+
47
+ module.exports = { normalizeWaitUntil, LEVELS, DEFAULT_LEVEL };
@@ -59,6 +59,10 @@ export function parseMessage(msg) {
59
59
  export const VALID_METHODS = [
60
60
  'ui.get_tree',
61
61
  'ui.act',
62
+ 'ui.auth_state',
63
+ // 视口截图(P0-1):操作失败时的第一诊断手段,弥补元素树看不见的
64
+ // 浮层/canvas/遮挡/渲染错位。返回 base64,默认 png。
65
+ 'ui.screenshot',
62
66
  'ui.navigate',
63
67
  'ui.repaint',
64
68
  'ui.snapshot',
@@ -68,6 +72,10 @@ export const VALID_METHODS = [
68
72
  'ui.subscribe',
69
73
  'ui.unsubscribe',
70
74
  'ui.downloads',
75
+ // 主进程错误日志(只读)。界面弹过"js 崩溃"原生框、或某个动作莫名失败时,
76
+ // 这里是唯一能查到 stack 与"出错那一刻在调什么"的地方 —— 此前这类异常只活在
77
+ // 那个框里,点掉即永久丢失。
78
+ 'ui.errors',
71
79
  'ui.get_focused',
72
80
  'ui.peek',
73
81
  'ui.wait',
@@ -0,0 +1,133 @@
1
+ // src/shared/ref_guard.cjs — ui.act target 的形状体检(纯函数)
2
+ //
3
+ // 坑:ui.act 的 target 是 get_tree 返回的 data-ai-id(如 `e:button-12`、
4
+ // `axf-3-1`,或元素原生 id),**不是按钮上可见的文字**。传 "发布" 这类
5
+ // label 时,最终以 `[data-ai-id="发布"]` 去查,命中不到,回一句
6
+ // "Target not found for ref: 发布" —— 字面没错,但完全没有指出"你传的是
7
+ // 文字不是 id"这个真正原因,调用方容易反复重试同一个错误形状。
8
+ //
9
+ // 这里在入口把明显不像 ref 的形状挡下来,并给可操作的提示。判定从宽:
10
+ // 只拒**几乎不可能是 id** 的输入(含空白字符、超长、以引号/括号开头),
11
+ // 原生 id 一律放行 —— 误拒一个合法 id 比漏放一个 label 更糟。
12
+
13
+ 'use strict';
14
+
15
+ const MAX_REF_LEN = 120;
16
+
17
+ // CJK(含全角标点)。ref 里不会出现,文案里必然出现。
18
+ const CJK_RE = /[\u3000-\u303f\u4e00-\u9fff\uff00-\uffef]/;
19
+ // 选择器的"结构符"。要求**至少有一个**,而不是"有空格就算"——
20
+ // 否则 "Publish now" 这种英文按钮文案会被当成后代选择器放行,
21
+ // 那恰恰是这套体检要拦的东西。
22
+ const SELECTOR_MARK_RE = /[.#\[>]|\*/;
23
+ // 选择器里允许出现的字符集合(够覆盖后代/子代/属性/伪类/分组选择器的常见写法)。
24
+ const SELECTOR_CHARS_RE = /^[A-Za-z0-9_\-.#\[\]="'>+~:*()\s]+$/;
25
+
26
+ /**
27
+ * 是否"像一条 CSS 选择器"。
28
+ * 用于 upload:上传控件常常既没有 data-ai-id、也没有可用的原生 id
29
+ * (B站「本地上传」是个纯 div 菜单项,AX 树里根本没有它),只能靠选择器定位。
30
+ * 判定刻意从严:必须**无 CJK + 含结构符 + 字符集合法**,避免把页面文案放进来。
31
+ */
32
+ function looksLikeCssSelector(t) {
33
+ if (!t) return false;
34
+ if (CJK_RE.test(t)) return false;
35
+ if (!SELECTOR_MARK_RE.test(t)) return false;
36
+ return SELECTOR_CHARS_RE.test(t);
37
+ }
38
+
39
+ /**
40
+ * 把复合选择器拆成"逐段"的简单选择器(只按**顶层**复合子拆,属性值/伪类
41
+ * 参数里的空格引号不算)。返回形如 ['.a', '.b', '[type=file]']。
42
+ * 拆的时候丢掉组合子(`>` `+` `~` 与空格一样处理),因为回退只关心"哪几层",
43
+ * 不关心层与层之间是父子还是兄弟 —— 原始写法会由调用方原样保留为第一个候选。
44
+ */
45
+ function splitCompoundSelector(t) {
46
+ const parts = [];
47
+ let depth = 0;
48
+ let quote = null;
49
+ let cur = '';
50
+ for (let i = 0; i < t.length; i++) {
51
+ const ch = t[i];
52
+ if (quote) {
53
+ cur += ch;
54
+ if (ch === '\\') { cur += t[i + 1] || ''; i++; continue; }
55
+ if (ch === quote) quote = null;
56
+ continue;
57
+ }
58
+ if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
59
+ if (ch === '[' || ch === '(') { depth++; cur += ch; continue; }
60
+ if (ch === ']' || ch === ')') { depth = Math.max(0, depth - 1); cur += ch; continue; }
61
+ if (depth === 0 && /[\s>+~]/.test(ch)) {
62
+ if (cur) { parts.push(cur); cur = ''; }
63
+ continue;
64
+ }
65
+ cur += ch;
66
+ }
67
+ if (cur) parts.push(cur);
68
+ return parts;
69
+ }
70
+
71
+ /**
72
+ * 选择器的回退候选,**由长到短**:`.a .b .c` → `['.a .b .c', '.a .b', '.a']`。
73
+ *
74
+ * 为什么需要:大量上传组件在"已经有文件"的状态下会把上传区(连同它内部那个
75
+ * 永远隐藏的 `<input type=file>`)整个卸载,只留一个封面预览。此时
76
+ * `.container-coverimage-box .el-upload__input` 查不到任何元素 —— 但它的祖先
77
+ * `.container-coverimage-box` 还在,点它能重新唤出选择器。调用方拿这串候选
78
+ * 逐个试,第一个命中的就是"还能点的那一层"。
79
+ *
80
+ * 非选择器形状(真的 data-ai-id)原样返回单元素数组,不改变既有行为。
81
+ */
82
+ function selectorAncestors(t) {
83
+ const s = String(t || '').trim();
84
+ if (!s) return [];
85
+ if (!looksLikeCssSelector(s)) return [s];
86
+ const parts = splitCompoundSelector(s);
87
+ if (parts.length <= 1) return [s];
88
+ // 第一个永远是原样的完整选择器(保留 `>` `+` 这些精确组合子),后面才是
89
+ // 逐层退化的版本 —— 优先精确命中,实在没有才放宽成后代匹配。
90
+ const out = [s];
91
+ for (let n = parts.length - 1; n >= 1; n--) {
92
+ const cand = parts.slice(0, n).join(' ');
93
+ if (cand !== s) out.push(cand);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * target 是否"看着像"一个 data-ai-id。
100
+ * @param {string} target
101
+ * @param {{allowSelector?: boolean}} [opts] allowSelector:该动作是否接受 CSS
102
+ * 选择器(目前只有 upload)。开启后选择器形态直接放行,其余判定不变。
103
+ * @returns {string|null} null 表示放行;字符串是拒绝原因(可直接给调用方看)。
104
+ */
105
+ function refShapeError(target, opts) {
106
+ if (target === undefined || target === null || target === '') return null; // 空由调用方按各动作规则处理
107
+ const t = String(target);
108
+ const allowSelector = !!(opts && opts.allowSelector);
109
+ if (allowSelector && looksLikeCssSelector(t)) return null;
110
+ if (/\s/.test(t)) {
111
+ return 'target looks like visible text, not a data-ai-id: ' + JSON.stringify(t.slice(0, 60))
112
+ + ' — pass the id from get_tree (e.g. "e:button-12", "axf-3-1", or the element\'s native id), not the label on screen'
113
+ + (allowSelector ? '; a CSS selector (e.g. ".el-upload__input") is also accepted for upload' : '');
114
+ }
115
+ // 纯 CJK、且不含任何 ASCII 字母/数字/结构符(`:` `-` `_` 等)——不可能是
116
+ // data-ai-id。树里的 ref 要么带结构前缀(`e:button-12` / `axf-3-1`),要么
117
+ // 是站点的原生 id(ASCII)。真实站点上"发布"/"点击发布"这类输入就是按钮
118
+ // 文案,放行只会让它变成一句无方向的 "Target not found"。
119
+ // 混了 ASCII 的一律放行(`e:按钮-1` 可能是合法 id)。
120
+ if (CJK_RE.test(t) && !/[A-Za-z0-9]/.test(t)) {
121
+ return 'target looks like visible text, not a data-ai-id: ' + JSON.stringify(t.slice(0, 60))
122
+ + ' — pass the id from get_tree (e.g. "e:button-12", "axf-3-1"). If this really is the element\'s native id, locate it with ui.evaluate instead';
123
+ }
124
+ if (t.length > MAX_REF_LEN) {
125
+ return 'target is ' + t.length + ' chars — data-ai-id values are short ids from get_tree, not text';
126
+ }
127
+ if (/^["'`()[\]{}]/.test(t)) {
128
+ return 'target starts with a quote/bracket — pass the bare id from get_tree, not a selector';
129
+ }
130
+ return null;
131
+ }
132
+
133
+ module.exports = { refShapeError, looksLikeCssSelector, selectorAncestors, splitCompoundSelector, MAX_REF_LEN };
@@ -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 };
@@ -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.2.2';
5
+ export const VERSION = '1.3.1';