@glittering0717/ai-browser 1.3.1 → 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.
- package/README.md +56 -5
- package/bin/ai-browser.cjs +32 -4
- package/examples/AGENT_INSTRUCTIONS.md +49 -164
- package/llms-install.md +27 -2
- package/package.json +3 -1
- package/scripts/cli_call.cjs +178 -0
- package/scripts/skill.cjs +133 -0
- package/skills/ai-browser/SKILL.md +55 -0
- package/skills/ai-browser/references/hard-pages.md +52 -0
- package/skills/ai-browser/references/network.md +39 -0
- package/skills/ai-browser/references/tasks.md +44 -0
- package/skills/ai-browser/references/troubleshooting.md +31 -0
- package/skills/content-publish/SKILL.md +61 -0
- package/skills/read-webpage/SKILL.md +52 -0
- package/skills/web-network-monitor/SKILL.md +52 -0
- package/src/main/axExtractor.js +50 -6
- package/src/main/capability_probe.js +93 -5
- package/src/main/index.js +99 -6
- package/src/main/mcp_args.js +122 -0
- package/src/main/mcp_server.js +55 -72
- package/src/main/mcp_tools.js +127 -86
- package/src/main/mcp_ws.js +7 -1
- package/src/main/page_manager.js +481 -71
- package/src/main/window_open_shim_source.js +117 -0
- package/src/preload/bridge.cjs +22 -4
- package/src/preload/extractor.cjs +161 -17
- package/src/shared/doc_coverage.js +264 -0
- package/src/shared/effect_state.js +160 -0
- package/src/shared/frame_probe.cjs +91 -1
- package/src/shared/frame_read_source.js +260 -0
- package/src/shared/opened_tab.js +71 -0
- package/src/shared/ref_epoch.js +99 -0
- package/src/shared/tab_hooks.js +33 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +111 -9
package/src/main/page_manager.js
CHANGED
|
@@ -10,6 +10,12 @@ import axExtractor from './axExtractor.js';
|
|
|
10
10
|
import { NetworkMonitor, MAX_POST_DATA_SIZE } from './network_monitor.js';
|
|
11
11
|
import { CanvasMonitor, isCanvasHookEnabled } from './canvas_monitor.js';
|
|
12
12
|
import { buildCanvasHookSource } from './canvas_hook_source.js';
|
|
13
|
+
// window.open("") + location 赋值模式的兼容垫片(主 world 源码,见文件头注释)
|
|
14
|
+
import { WINDOW_OPEN_SHIM_SOURCE } from './window_open_shim_source.js';
|
|
15
|
+
import { pickOpenedTab, openedTabGraceMs } from '../shared/opened_tab.js';
|
|
16
|
+
// 动作回执自带的"页面到底动没动"三态,以及 ref 过期诊断(两个纯函数模块)。
|
|
17
|
+
import { withEffectState, isTargetMiss as isTargetMissError } from '../shared/effect_state.js';
|
|
18
|
+
import { newTracker, noteTree, diagnoseMissingRef } from '../shared/ref_epoch.js';
|
|
13
19
|
import { diffValues, previewOf, safeByteLength, DEFAULT_SNAPSHOT_JS, DIFF_MAX_ENTRIES, evictOldest } from './snapshot_diff.js';
|
|
14
20
|
import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
|
|
15
21
|
import { DownloadMonitor } from './download_monitor.js';
|
|
@@ -23,6 +29,12 @@ import screenshotOpts from '../shared/screenshot_opts.cjs';
|
|
|
23
29
|
// 跨进程子帧(OOPIF)判定与自动路由回执(纯函数):默认读法检测到不可读
|
|
24
30
|
// 子帧时自己补一次 AX 读取,调用方无需知道 ax:true 的存在。
|
|
25
31
|
import frameProbe from '../shared/frame_probe.cjs';
|
|
32
|
+
// 子帧内容读取器(在帧内执行)。选择器/上限/覆盖率口径全在那个文件里,
|
|
33
|
+
// 这里只负责把它喂进去 —— 采集规则不该散落在 page_manager 的字符串里。
|
|
34
|
+
import { buildFrameReadSource } from '../shared/frame_read_source.js';
|
|
35
|
+
// 文字覆盖率(纯函数):树里的正文字数 / 页面上看得见的字数。"读到了"必须
|
|
36
|
+
// 是能算出来的结论,见 shared/doc_coverage.js 顶部的事故说明。
|
|
37
|
+
import docCoverage from '../shared/doc_coverage.js';
|
|
26
38
|
// ui.act target 形状体检(纯函数):传了可见文字而不是 data-ai-id 时,
|
|
27
39
|
// 入口就给可操作的错误,而不是等到选择器查空才说 "Target not found"。
|
|
28
40
|
import refGuard from '../shared/ref_guard.cjs';
|
|
@@ -160,6 +172,8 @@ class PageManager {
|
|
|
160
172
|
this._requestId = 0;
|
|
161
173
|
this._wsClients = new Map();
|
|
162
174
|
this._openedTabs = new Map(); // sourceTabId -> { id, url } (new tab opened by window.open)
|
|
175
|
+
this._windowOpenTokens = new Map(); // token -> { tabId, sourceTabId } (window.open shim, preload bridge.cjs)
|
|
176
|
+
this._windowOpenShimTabs = new Set(); // 已注入 window.open 垫片的 tabId
|
|
163
177
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
164
178
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
165
179
|
this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
|
|
@@ -286,6 +300,64 @@ class PageManager {
|
|
|
286
300
|
// Tag events with active tab
|
|
287
301
|
this._broadcast(payload.event, payload.data || {});
|
|
288
302
|
});
|
|
303
|
+
|
|
304
|
+
// === window.open 垫片的主进程侧(见 preload/bridge.cjs 的注释)===
|
|
305
|
+
// 站点模式:window.open("", "_blank") 先开空窗口,再把 location 赋成
|
|
306
|
+
// 真实地址。setWindowOpenHandler 那条路给不了页面可用的 window 引用
|
|
307
|
+
// (deny 后 window.open 返回 null),所以页面侧改为走这两条 IPC:
|
|
308
|
+
// ai:window_open 建真实 tab(空 url → about:blank 占位),
|
|
309
|
+
// ai:window_open_nav 把 location 赋值翻译成对该 tab 的导航。
|
|
310
|
+
// 同 sender 的 IPC 保序,nav 到达时 tab 必已建好。
|
|
311
|
+
ipcMain.on('ai:window_open', (_event, payload) => {
|
|
312
|
+
const p = payload || {};
|
|
313
|
+
const token = p.token;
|
|
314
|
+
if (!token) return;
|
|
315
|
+
const sourceTabId = this._tabIdForContents(_event.sender);
|
|
316
|
+
const url = typeof p.url === 'string' ? p.url : '';
|
|
317
|
+
const childId = this.newTab(url && url !== 'about:blank' ? url : null);
|
|
318
|
+
this._windowOpenTokens.set(token, { tabId: childId, sourceTabId });
|
|
319
|
+
// 与 setWindowOpenHandler 路径共用 _openedTabs:executeAction 的
|
|
320
|
+
// auto-follow(点击后 Agent 视角跟到新 tab)对两种打开方式一致。
|
|
321
|
+
if (sourceTabId !== null) {
|
|
322
|
+
this._openedTabs.set(sourceTabId, { id: childId, at: Date.now(), url: url || 'about:blank' });
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
ipcMain.on('ai:window_open_nav', (_event, payload) => {
|
|
327
|
+
const p = payload || {};
|
|
328
|
+
const entry = this._windowOpenTokens.get(p.token);
|
|
329
|
+
const url = typeof p.url === 'string' ? p.url : '';
|
|
330
|
+
if (!entry || !url) return;
|
|
331
|
+
const view = this._getView(entry.tabId);
|
|
332
|
+
if (view) {
|
|
333
|
+
// 占位 tab(about:blank)→ 真实学习页。loadURL 的 rejection 由
|
|
334
|
+
// 页面自身处理(站点可能会立刻再导航一次),这里只防 unhandled。
|
|
335
|
+
view.webContents.loadURL(url).catch(() => {});
|
|
336
|
+
} else {
|
|
337
|
+
// tab 已被用户/Agent 关掉:赋值静默丢弃(真实浏览器里关掉的
|
|
338
|
+
// 窗口被赋 location 也是无人接收)。
|
|
339
|
+
console.warn('[window_open] nav target tab gone:', entry.tabId, url);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
ipcMain.on('ai:window_open_close', (_event, payload) => {
|
|
344
|
+
const token = (payload || {}).token;
|
|
345
|
+
const entry = this._windowOpenTokens.get(token);
|
|
346
|
+
if (!entry) return;
|
|
347
|
+
this._windowOpenTokens.delete(token);
|
|
348
|
+
if (this._getView(entry.tabId)) this.closeTab(entry.tabId);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// webContents -> tabId 反查。window.open 垫片需要知道是哪个 tab 发起的
|
|
353
|
+
// (记 _openedTabs 供 auto-follow 用);ipc-message 的 sender 就是发起
|
|
354
|
+
// tab 的 webContents。
|
|
355
|
+
_tabIdForContents(wc) {
|
|
356
|
+
if (!wc) return null;
|
|
357
|
+
for (const [tid, view] of this.tabs) {
|
|
358
|
+
if (view && view.webContents === wc) return tid;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
289
361
|
}
|
|
290
362
|
|
|
291
363
|
_broadcast(eventType, data) {
|
|
@@ -636,10 +708,31 @@ class PageManager {
|
|
|
636
708
|
// 此前这条通路只在 ax:true 下存在,而**没有任何地方告诉调用方 ax:true
|
|
637
709
|
// 的存在** —— B站图文编辑器(正文在 OOPIF 里)因此隐形了两小时,agent
|
|
638
710
|
// 得出"编辑器没加载"的错误结论。现在默认读法自己补上这一步。
|
|
639
|
-
|
|
711
|
+
const frames = focusedOnly ? [] : await this._autoMergeOpaqueFrames(res, tid);
|
|
712
|
+
// 「这棵树读到了多少页面文字」—— 与 frame_hints 的 text_coverage(只看帧)
|
|
713
|
+
// 互补:那个回答"帧读够了吗",这个回答"整页读够了吗"。
|
|
714
|
+
// 事故里 27 个节点、正文 0 字的页面,两个数字都会报警,而此前一个都没有。
|
|
715
|
+
this._annotateCoverage(res, frames);
|
|
716
|
+
// 记下"这一版树里有哪些 ref"。之后 act 报 target_not_found 时,靠它区分
|
|
717
|
+
// 「ref 过期了」(页面变过,重读树即可)和「ref 从来没对过」(改传参)。
|
|
718
|
+
// 见 shared/ref_epoch.js 顶部注释。
|
|
719
|
+
this._noteTreeRefs(tid, res && res.tree);
|
|
640
720
|
return res;
|
|
641
721
|
}
|
|
642
722
|
|
|
723
|
+
/** per-tab 的 ref 版本追踪器(懒建,避免动构造函数)。 */
|
|
724
|
+
_refTracker(tid) {
|
|
725
|
+
if (!this._refTrackers) this._refTrackers = new Map();
|
|
726
|
+
let t = this._refTrackers.get(tid);
|
|
727
|
+
if (!t) { t = newTracker(); this._refTrackers.set(tid, t); }
|
|
728
|
+
return t;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
_noteTreeRefs(tid, tree) {
|
|
732
|
+
if (!tree) return;
|
|
733
|
+
try { noteTree(this._refTracker(tid), tree); } catch (_) { /* 追踪失败绝不影响读树 */ }
|
|
734
|
+
}
|
|
735
|
+
|
|
643
736
|
// 把跨进程子帧的内容读出来、合并进默认(DOM)树。轻量:只读子帧,
|
|
644
737
|
// 不重建主树,所以默认树的形状/id 全部保持原样。
|
|
645
738
|
// 三种结局都写进 context.frame_hints(见 shared/frame_probe.describeAutoAx):
|
|
@@ -647,12 +740,12 @@ class PageManager {
|
|
|
647
740
|
// "还有一条没走过的路"。
|
|
648
741
|
async _autoMergeOpaqueFrames(res, tid) {
|
|
649
742
|
try {
|
|
650
|
-
if (!res || !res.tree || !res.context) return;
|
|
651
|
-
if (res.tree.__oopif_merged) return; // 幂等:合并过的树不重复做
|
|
743
|
+
if (!res || !res.tree || !res.context) return [];
|
|
744
|
+
if (res.tree.__oopif_merged) return []; // 幂等:合并过的树不重复做
|
|
652
745
|
const summary = res.context.cross_process_frames;
|
|
653
|
-
if (!frameProbe.shouldAutoAx(summary)) return;
|
|
746
|
+
if (!frameProbe.shouldAutoAx(summary)) return [];
|
|
654
747
|
const view = this._getView(tid);
|
|
655
|
-
if (!view) return;
|
|
748
|
+
if (!view) return [];
|
|
656
749
|
const frames = await withDeadline(this._readOopifFrames(view, tid), 15000, 'get_tree(auto-oopif)');
|
|
657
750
|
if (frames && frames.length) {
|
|
658
751
|
res.tree = axExtractor.mergeFrameTrees(res.tree, frames);
|
|
@@ -665,7 +758,11 @@ class PageManager {
|
|
|
665
758
|
res.context.frame_hints = frameProbe.describeAutoAx({
|
|
666
759
|
merged: !!(frames && frames.length),
|
|
667
760
|
framesRead: (frames || []).length,
|
|
761
|
+
// 测量值:光说 merged 等于只说"我试过了"。见 frame_probe.summarizeFrameRead。
|
|
762
|
+
summary: frameProbe.summarizeFrameRead(frames),
|
|
668
763
|
});
|
|
764
|
+
// 返回帧记录:文字覆盖率要用它们的 text_chars 当分母的一部分。
|
|
765
|
+
return frames || [];
|
|
669
766
|
} catch (e) {
|
|
670
767
|
// 自动合并失败绝不拖垮主读法:默认树照常返回,只在 hints 里说明原因。
|
|
671
768
|
// 调用方看到 failed 可以手动切 ax:true 重试。
|
|
@@ -674,9 +771,44 @@ class PageManager {
|
|
|
674
771
|
error: (e && e.__deadline ? e.message + ' — the page main thread is likely blocked' : e && e.message) || String(e),
|
|
675
772
|
});
|
|
676
773
|
}
|
|
774
|
+
return [];
|
|
677
775
|
}
|
|
678
776
|
}
|
|
679
777
|
|
|
778
|
+
/**
|
|
779
|
+
* 「这棵树读到了多少页面文字」写进 context.page_coverage。
|
|
780
|
+
*
|
|
781
|
+
* 分子 = 树里所有正文 label 的字符数;分母 = 主文档 innerText 长度 +
|
|
782
|
+
* 每个跨进程帧里可见文字的字符数(帧那边在 frame_read_source 里已经算过)。
|
|
783
|
+
*
|
|
784
|
+
* 为什么必须分成"主文档 + 帧"两半:innerText 不跨 iframe,把两者混在一个
|
|
785
|
+
* 数里,任何一侧读漏都会被另一侧的平均值盖住 —— 泛雅那次就是这样,
|
|
786
|
+
* 主文档读得很好(外壳 100%),正文一个字没有,而没有任何数字表达这件事。
|
|
787
|
+
*/
|
|
788
|
+
_annotateCoverage(res, frames, opts) {
|
|
789
|
+
if (!res || !res.tree || !res.context) return;
|
|
790
|
+
// 读失败时不算覆盖率:这一版树是 bridge 的兜底占位(只有一个装着异常文本的
|
|
791
|
+
// 节点),拿它当分子只会得出一个看似正常的小数字,把"没读成"包装成
|
|
792
|
+
// "页面内容少"。宁可让 page_coverage 缺席,让 read_ok/read_error 说话。
|
|
793
|
+
if (res.context.read_ok === false) return;
|
|
794
|
+
try {
|
|
795
|
+
const stats = res.context.stats || {};
|
|
796
|
+
const frameChars = (frames || []).reduce(
|
|
797
|
+
(s, f) => s + Number((f && f.stats && f.stats.text_chars) || 0), 0);
|
|
798
|
+
const summed = docCoverage.sumLabelChars(res.tree);
|
|
799
|
+
res.context.page_coverage = docCoverage.pageCoverage({
|
|
800
|
+
treeChars: summed.chars,
|
|
801
|
+
mainChars: Number(stats.text_chars) || 0,
|
|
802
|
+
sameOriginChars: Number(stats.same_origin_chars) || 0,
|
|
803
|
+
shadowChars: Number(stats.shadow_chars) || 0,
|
|
804
|
+
frameChars,
|
|
805
|
+
canvases: Number(stats.canvases) || 0,
|
|
806
|
+
// AX 树算的是**可访问名**,天生比 innerText 多(aria-label / alt / title)。
|
|
807
|
+
ax: !!(opts && opts.ax),
|
|
808
|
+
});
|
|
809
|
+
} catch (_) { /* 度量失败绝不影响读树 */ }
|
|
810
|
+
}
|
|
811
|
+
|
|
680
812
|
// 登录态 / 登录墙自查。长任务里每个关键步骤前都可以问一次,成本只是走一次
|
|
681
813
|
// extractPageContext(不建树)。
|
|
682
814
|
//
|
|
@@ -881,9 +1013,15 @@ class PageManager {
|
|
|
881
1013
|
if (out.context[k] == null && ctx[k] != null) out.context[k] = ctx[k];
|
|
882
1014
|
}
|
|
883
1015
|
if (!out.context.session || !Object.keys(out.context.session).length) out.context.session = ctx.session || {};
|
|
884
|
-
if (out.context.stats && ctx.stats) Object.assign(out.context.stats, { inputs: ctx.stats.inputs, buttons: ctx.stats.buttons, links: ctx.stats.links, iframes: ctx.stats.iframes });
|
|
1016
|
+
if (out.context.stats && ctx.stats) Object.assign(out.context.stats, { inputs: ctx.stats.inputs, buttons: ctx.stats.buttons, links: ctx.stats.links, links_without_href: ctx.stats.links_without_href, iframes: ctx.stats.iframes, text_chars: ctx.stats.text_chars });
|
|
885
1017
|
} catch (e) { /* context 补全失败不影响树 */ }
|
|
886
1018
|
}
|
|
1019
|
+
// 分母(text_chars)补完了,这时候才算覆盖率。frames 由 _finishAxTree 带过来。
|
|
1020
|
+
if (out) {
|
|
1021
|
+
const framesForCoverage = out.__frames || [];
|
|
1022
|
+
delete out.__frames;
|
|
1023
|
+
this._annotateCoverage(out, framesForCoverage, { ax: true });
|
|
1024
|
+
}
|
|
887
1025
|
return out;
|
|
888
1026
|
} catch (e) {
|
|
889
1027
|
if (e && e.__deadline) {
|
|
@@ -905,8 +1043,20 @@ class PageManager {
|
|
|
905
1043
|
if (main.context) {
|
|
906
1044
|
const mergedCtx = frameProbe.mergeFrameContext(main.context, frames);
|
|
907
1045
|
if (mergedCtx) Object.assign(main.context, mergedCtx);
|
|
1046
|
+
// 与默认读法同一份回执:走 AX 这条路的调用方也要能看到"并进去几个节点、
|
|
1047
|
+
// 帧里还有多少字没进树"。两条读法给不同口径的提示,等于让调用方猜。
|
|
1048
|
+
main.context.frame_hints = frameProbe.describeAutoAx({
|
|
1049
|
+
merged: true,
|
|
1050
|
+
framesRead: frames.length,
|
|
1051
|
+
summary: frameProbe.summarizeFrameRead(frames),
|
|
1052
|
+
});
|
|
908
1053
|
}
|
|
909
1054
|
}
|
|
1055
|
+
// 覆盖率在两条读法上是同一个口径(同一个函数),否则"ax:true 覆盖率 60%、
|
|
1056
|
+
// 默认读法 40%"只会让调用方怀疑读数本身。
|
|
1057
|
+
// 这里只把 frames 挂给调用方,**不能在这里算**:AX 的 context.stats 还要等
|
|
1058
|
+
// getTreeViaAx 补完 DOM 侧字段(含 text_chars)才有分母。
|
|
1059
|
+
if (main) main.__frames = frames || [];
|
|
910
1060
|
// #4: incremental diff — return only newly appeared / hidden interactive
|
|
911
1061
|
// nodes since the last ax read on this tab (feeds: small payload per scroll).
|
|
912
1062
|
if (opts.mode === 'diff') {
|
|
@@ -969,12 +1119,20 @@ class PageManager {
|
|
|
969
1119
|
})()`;
|
|
970
1120
|
}
|
|
971
1121
|
|
|
972
|
-
// Read
|
|
1122
|
+
// Read the **content** of each out-of-process sub-frame via
|
|
973
1123
|
// Frame.executeJavaScript (runs in that frame's own context). Returns
|
|
974
|
-
// [{ key, label, tree }] — pure mergeFrameTrees input.
|
|
975
|
-
// data-ai-id = `axf-{frameSeq}-{idx}` inside each frame so
|
|
976
|
-
// (a) find the owning frame by testing which frame has
|
|
977
|
-
// (b) run its JS in that frame's own context (top
|
|
1124
|
+
// [{ key, label, src, tree, context, stats }] — pure mergeFrameTrees input.
|
|
1125
|
+
// Stamps idempotent data-ai-id = `axf-{frameSeq}-{idx}` inside each frame so
|
|
1126
|
+
// the act layer can (a) find the owning frame by testing which frame has
|
|
1127
|
+
// `[data-ai-id=...]`, and (b) run its JS in that frame's own context (top
|
|
1128
|
+
// querySelector can't reach OOPIF).
|
|
1129
|
+
//
|
|
1130
|
+
// 采集规则在 shared/frame_read_source.js —— 这里原本内联了一份只认表单控件
|
|
1131
|
+
// 的选择器,于是「没有可点元素」的 frame(正文、目录、成绩表)读出来是空的,
|
|
1132
|
+
// 而回执还说 merged。事故与口径见那个文件顶部。
|
|
1133
|
+
//
|
|
1134
|
+
// frameSeq 在 try 之前自增:某个帧读不到时序号不能回填,否则树里的
|
|
1135
|
+
// `frame-N` 会指向另一个帧(agents 会照着 ref 去操作,指错比没有更坏)。
|
|
978
1136
|
async _readOopifFrames(view, tabId) {
|
|
979
1137
|
try {
|
|
980
1138
|
const root = view.webContents.mainFrame;
|
|
@@ -987,45 +1145,15 @@ class PageManager {
|
|
|
987
1145
|
const key = 'frame-' + frameSeq;
|
|
988
1146
|
const prefix = 'axf-' + frameSeq + '-';
|
|
989
1147
|
try {
|
|
990
|
-
const dto = await frame.executeJavaScript(
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
}
|
|
1000
|
-
var r=e.getBoundingClientRect();
|
|
1001
|
-
if(r.width<4||r.height<4)continue; // hidden
|
|
1002
|
-
if(e.tagName==='BUTTON'||(e.getAttribute&&e.getAttribute('role')==='button')){} // buttons not relevant for read-editor; keep anyway
|
|
1003
|
-
var ai=prefix+idx;
|
|
1004
|
-
e.setAttribute('data-ai-id',ai);
|
|
1005
|
-
var isEditable=e.isContentEditable||e.tagName==='TEXTAREA'||((e.tagName==='INPUT'));
|
|
1006
|
-
var role;
|
|
1007
|
-
if(e.isContentEditable)role='textbox';
|
|
1008
|
-
else if(e.tagName==='TEXTAREA')role='textbox';
|
|
1009
|
-
else if(e.tagName==='INPUT')role='textbox';
|
|
1010
|
-
else if(e.tagName==='BUTTON'||e.getAttribute&&e.getAttribute('role')==='button')role='button';
|
|
1011
|
-
else if(e.tagName==='SELECT'||e.getAttribute&&e.getAttribute('role')==='combobox')role='select';
|
|
1012
|
-
else if(e.tagName==='A')role='link';
|
|
1013
|
-
else role='textbox';
|
|
1014
|
-
var label=e.getAttribute('aria-label')||e.getAttribute('placeholder')||e.getAttribute('title')||'';
|
|
1015
|
-
if(!label&&e.getAttribute&&e.getAttribute('data-placeholder'))label=e.getAttribute('data-placeholder');
|
|
1016
|
-
if(!label&&(e.tagName==='BUTTON'||(e.getAttribute&&e.getAttribute('role')==='button'))){label=(e.innerText||e.textContent||'').trim();}
|
|
1017
|
-
if(!label&&e.tagName==='A'){label=(e.innerText||e.textContent||'').trim();}
|
|
1018
|
-
var url=(e.tagName==='A'&&e.href)||(e.closest&&e.closest('a[href]')?e.closest('a[href]').href:'');
|
|
1019
|
-
var editor_type=null;
|
|
1020
|
-
if(e.isContentEditable)editor_type='richtext';
|
|
1021
|
-
else if(e.tagName==='TEXTAREA')editor_type='textarea';
|
|
1022
|
-
else if(e.tagName==='INPUT')editor_type='textbox';
|
|
1023
|
-
out.push({id:ai,role:role,label:label,editor_type:editor_type,url:url,bounds:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)}});
|
|
1024
|
-
idx++;
|
|
1025
|
-
}
|
|
1026
|
-
return out.length?out:null;
|
|
1027
|
-
})(${JSON.stringify(prefix)})`);
|
|
1028
|
-
if (!dto || !dto.length) continue;
|
|
1148
|
+
const dto = await frame.executeJavaScript(buildFrameReadSource(prefix));
|
|
1149
|
+
if (!dto) continue;
|
|
1150
|
+
const nodes = Array.isArray(dto.nodes) ? dto.nodes : [];
|
|
1151
|
+
const chars = (dto.stats && Number(dto.stats.text_chars)) || 0;
|
|
1152
|
+
// 一个节点都没有、也没有可见文字 = 这个帧确实是空的,跳过。
|
|
1153
|
+
// 有文字却一个节点都没读出来 = 内容在更深一层 iframe 里,或者结构完全
|
|
1154
|
+
// 超出采集规则 —— 必须留下门节点 + 绝对 src,让调用方知道"这里有一块
|
|
1155
|
+
// 内容没读到";让它从树里彻底消失,就回到了这轮修的起点。
|
|
1156
|
+
if (!nodes.length && chars < 20) continue;
|
|
1029
1157
|
// 子帧里的"该填什么 / 错在哪"。顶层 extractPageContext 只能看顶层文档,
|
|
1030
1158
|
// 而 B站图文的标题框、掘金编辑器都住在 OOPIF 里 —— 上下文扫描不跟着
|
|
1031
1159
|
// 走一遍子帧,required_hints / validation_errors 就会在**真正需要它们
|
|
@@ -1042,19 +1170,39 @@ class PageManager {
|
|
|
1042
1170
|
label: frame.name || ('frame-' + frameSeq),
|
|
1043
1171
|
states: [],
|
|
1044
1172
|
actions: [],
|
|
1173
|
+
// 门节点带上**绝对** src:树里出现的东西,调用方就应该能直接用。
|
|
1174
|
+
// 相对地址(/mooc2-ans/...)没法喂给 ui.new_tab / ui.navigate,
|
|
1175
|
+
// 等于把"我知道内容在哪"变成一句废话。
|
|
1176
|
+
...(dto.url ? { src: dto.url } : {}),
|
|
1045
1177
|
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
1046
|
-
children:
|
|
1178
|
+
children: nodes.map((n) => ({
|
|
1047
1179
|
id: n.id,
|
|
1048
1180
|
role: n.role,
|
|
1049
1181
|
label: n.label || '',
|
|
1050
1182
|
...(n.url ? { url: n.url } : {}),
|
|
1051
|
-
states: n.editor_type ? ['editable=' + n.editor_type, 'focusable'] : ['focusable'],
|
|
1183
|
+
states: n.states || (n.editor_type ? ['editable=' + n.editor_type, 'focusable'] : ['focusable']),
|
|
1052
1184
|
...(n.editor_type ? { editor_type: n.editor_type } : {}), // first-class, mirrors main AX path
|
|
1053
|
-
|
|
1185
|
+
// 只读内容块没有动作。此前一律给 ['click','focus','type','setContent','clear'],
|
|
1186
|
+
// 于是每个段落都被报成"可以往里打字" —— agent 照着做,然后拿到一串
|
|
1187
|
+
// 莫名其妙的失败。
|
|
1188
|
+
actions: (n.states || []).indexOf('readonly') >= 0
|
|
1189
|
+
? []
|
|
1190
|
+
: (n.role === 'button'
|
|
1191
|
+
? ['click', 'focus']
|
|
1192
|
+
: ['click', 'focus', 'type', 'setContent', 'clear']),
|
|
1054
1193
|
bounds: n.bounds,
|
|
1055
1194
|
})),
|
|
1056
1195
|
};
|
|
1057
|
-
out.push({
|
|
1196
|
+
out.push({
|
|
1197
|
+
key,
|
|
1198
|
+
label: frame.name || ('frame-' + frameSeq),
|
|
1199
|
+
// frame.url 是 WebFrameMain 的绝对地址;dto.url 是页内 location.href。
|
|
1200
|
+
// 两个都试,拿到的第一个能用的就写进 hints(走新标签打开时要用绝对地址)。
|
|
1201
|
+
src: dto.url || frame.url || null,
|
|
1202
|
+
tree,
|
|
1203
|
+
context: frameCtx,
|
|
1204
|
+
stats: dto.stats || null,
|
|
1205
|
+
});
|
|
1058
1206
|
} catch (e) { /* skip unreachable frame */ }
|
|
1059
1207
|
}
|
|
1060
1208
|
return out;
|
|
@@ -1187,33 +1335,75 @@ class PageManager {
|
|
|
1187
1335
|
*/
|
|
1188
1336
|
async executeAction(action, target, params = {}, tabId) {
|
|
1189
1337
|
const ms = actionDeadlineMs(action, params);
|
|
1338
|
+
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
1190
1339
|
try {
|
|
1191
|
-
|
|
1340
|
+
const res = await withDeadline(
|
|
1192
1341
|
this._executeActionInner(action, target, params, tabId),
|
|
1193
1342
|
ms,
|
|
1194
1343
|
`action:${action}`
|
|
1195
1344
|
);
|
|
1345
|
+
return this._annotateActResult(res, action, target, tid);
|
|
1196
1346
|
} catch (e) {
|
|
1197
1347
|
if (e && e.__deadline) {
|
|
1198
1348
|
// 明确告诉 agent 两件事:① 它该重试/换策略;② 这个动作**可能已经部分生效**
|
|
1199
1349
|
// (超时时指针可能已经按下、字符可能已经输入了一半)—— 后者必须说,否则
|
|
1200
1350
|
// agent 会以为页面没被动过而直接重试,造成双倍输入。
|
|
1201
|
-
return {
|
|
1351
|
+
return this._annotateActResult({
|
|
1202
1352
|
success: false,
|
|
1203
1353
|
error: `${e.message} — the page's main thread is likely blocked; the action may have partially applied`,
|
|
1204
1354
|
timed_out: true,
|
|
1205
1355
|
deadline_ms: ms,
|
|
1206
1356
|
action,
|
|
1207
|
-
};
|
|
1357
|
+
}, action, target, tid);
|
|
1208
1358
|
}
|
|
1209
1359
|
throw e;
|
|
1210
1360
|
}
|
|
1211
1361
|
}
|
|
1212
1362
|
|
|
1363
|
+
/**
|
|
1364
|
+
* 动作回执的最终加工:补 effect_state / retry,以及 ref 过期诊断。
|
|
1365
|
+
*
|
|
1366
|
+
* 为什么集中在这一层而不是各分支里:_executeActionInner 有三十多处 return,
|
|
1367
|
+
* 逐个补等于逐个漏(这条教训在 ui.act 的超时兜底上已经吃过一次 —— 那里也是
|
|
1368
|
+
* 因为挂住的位置不止一个才上移到唯一出口)。唯一出口是唯一可靠的覆盖方式。
|
|
1369
|
+
*/
|
|
1370
|
+
_annotateActResult(res, action, target, tid) {
|
|
1371
|
+
if (!res || typeof res !== 'object') return res;
|
|
1372
|
+
const extras = {};
|
|
1373
|
+
// 只有"目标找不到"这一种失败需要额外诊断 —— 其它错误跟 ref 无关。
|
|
1374
|
+
// 判定用 effect_state.js 导出的同一个函数,避免这里和分类器各写一套正则。
|
|
1375
|
+
const isTargetMiss = isTargetMissError(res.error);
|
|
1376
|
+
if (isTargetMiss && target) {
|
|
1377
|
+
try {
|
|
1378
|
+
const d = diagnoseMissingRef(target, this._refTracker(tid));
|
|
1379
|
+
extras.stale_ref = d.stale_ref;
|
|
1380
|
+
extras.ref_epoch = d.ref_epoch;
|
|
1381
|
+
extras.tree_epoch = d.tree_epoch;
|
|
1382
|
+
extras.ref_diagnosis = d.reason;
|
|
1383
|
+
// 具体的、可执行的下一步,直接给出来。诊断 hint 比通用错误文案更有用,
|
|
1384
|
+
// 所以这里覆盖 effect_hint 的位置由 withEffectState 之后合并。
|
|
1385
|
+
extras.ref_hint = d.hint;
|
|
1386
|
+
// 关键:ref 类失败一律不能算"照原样重试是安全的"。三种处境(过期 / 从没
|
|
1387
|
+
// 见过 / 树里有 DOM 里没有)都意味着**同一个调用再发一次一定还失败**,
|
|
1388
|
+
// 只是失败原因不同。effect_state 仍然是 none(确实没副作用),
|
|
1389
|
+
// 但 retry 必须改成 verify_first,否则等于告诉 agent"可以再来一遍"。
|
|
1390
|
+
extras.retry = 'verify_first';
|
|
1391
|
+
} catch (_) { /* 诊断失败不影响主回执 */ }
|
|
1392
|
+
}
|
|
1393
|
+
return withEffectState(res, extras);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1213
1396
|
async _executeActionInner(action, target, params = {}, tabId) {
|
|
1214
1397
|
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
1215
1398
|
const view = this._getView(tid);
|
|
1216
1399
|
if (!view) return { success: false, error: 'Tab not found' };
|
|
1400
|
+
// 本次动作的开始时刻。用来判断"这个新标签是不是**这次**动作打开的" ——
|
|
1401
|
+
// _openedTabs 里的条目可能来自更早的一次 window.open(例如 ui.evaluate 里
|
|
1402
|
+
// 调 el.click() 触发的那种:它没有经过 act,所以没人消费那条记录)。
|
|
1403
|
+
// 之前没有这个时间戳,陈旧条目会被下一次点击/悬停当成"我刚开了个标签",
|
|
1404
|
+
// 导致 opened_tab 报一个与本次操作无关的 tab(实测:hover 报出上一次
|
|
1405
|
+
// 「进入学习」打开的 about:blank)。
|
|
1406
|
+
const actionStartedAt = Date.now();
|
|
1217
1407
|
// press 是唯一不要求目标的动作:快捷键通常挂在 document/window 上(删除节点、
|
|
1218
1408
|
// 撤销重做、取消选中),发给"当前焦点"就是正确语义。
|
|
1219
1409
|
// drag 给了显式起点坐标(params.from)时同样不要求目标 —— "从空白处起拖"正是
|
|
@@ -1251,7 +1441,7 @@ class PageManager {
|
|
|
1251
1441
|
const res = await this._resolveClickByUrl(view, target, params.url, tid);
|
|
1252
1442
|
if (!res || !res.success) return { ...(res || {}), success: false, opened_tab: null };
|
|
1253
1443
|
// #3: 若此点击触发了 window.open → 新 tab,记录并默认接管焦点。
|
|
1254
|
-
res.opened_tab = this.
|
|
1444
|
+
res.opened_tab = await this._awaitOpenedTab(tid, params.keep_tab === true, actionStartedAt, params, target);
|
|
1255
1445
|
return res;
|
|
1256
1446
|
} catch (e) {
|
|
1257
1447
|
return { success: false, error: String((e && e.message) || e).slice(0, 200), opened_tab: null };
|
|
@@ -1260,7 +1450,10 @@ class PageManager {
|
|
|
1260
1450
|
try {
|
|
1261
1451
|
await this._ensureAxHandles(view, tid);
|
|
1262
1452
|
const via = await this._cdpPointerTarget(view, action, target, tid);
|
|
1263
|
-
if (via)
|
|
1453
|
+
if (via) {
|
|
1454
|
+
const opened = await this._awaitOpenedTab(tid, params.keep_tab === true, actionStartedAt, params, target);
|
|
1455
|
+
return { success: true, clicked_via: via, target, opened_tab: opened };
|
|
1456
|
+
}
|
|
1264
1457
|
} catch (e) { /* fall through to preload */ }
|
|
1265
1458
|
} else if (action === 'type' || action === 'setContent' || action === 'clear') {
|
|
1266
1459
|
try {
|
|
@@ -1336,9 +1529,17 @@ class PageManager {
|
|
|
1336
1529
|
}
|
|
1337
1530
|
|
|
1338
1531
|
const preloadRes = await this._executeViaPreload(action, target, params, tid);
|
|
1532
|
+
// preload 只在**顶层文档**里查 data-ai-id。目标住在跨进程帧里时,它必然
|
|
1533
|
+
// 报 Target not found —— 而树里明明有那个 ref(focus 就是这样:click/type
|
|
1534
|
+
// 各自有 OOPIF 分支,focus 没有)。这里补一次帧内原生动作,只在"找不到"
|
|
1535
|
+
// 时触发,不去抢其它失败原因的处理权。
|
|
1536
|
+
if (preloadRes && preloadRes.success === false && isTargetMissError(preloadRes.error)) {
|
|
1537
|
+
const inFrame = await this._actInOwningFrame(view, action, target, tid);
|
|
1538
|
+
if (inFrame) return inFrame;
|
|
1539
|
+
}
|
|
1339
1540
|
// preload 合成点击也可能触发 window.open → 新 tab,同样回报 opened_tab。
|
|
1340
1541
|
if (action === 'click' && preloadRes && preloadRes.success) {
|
|
1341
|
-
preloadRes.opened_tab = this.
|
|
1542
|
+
preloadRes.opened_tab = await this._awaitOpenedTab(tid, params.keep_tab === true, actionStartedAt, params, target);
|
|
1342
1543
|
// 路径可区分:CDP 受信点击报 clicked_via='cdp-click',回退路径报
|
|
1343
1544
|
// 'preload-click'。两种都只报 success 的话,agent(和人)无从判断这一下
|
|
1344
1545
|
// 到底是真实输入还是合成事件 —— 双发 bug 正是靠这个字段才暴露出来的。
|
|
@@ -1347,6 +1548,41 @@ class PageManager {
|
|
|
1347
1548
|
return preloadRes;
|
|
1348
1549
|
}
|
|
1349
1550
|
|
|
1551
|
+
// === 帧内原生动作(跨进程子帧里那些"preload 够不到"的动作)===
|
|
1552
|
+
// 只处理少数几个有明确原生等价物的动作,并且**只在顶层报 target miss 后**
|
|
1553
|
+
// 调用。返回 null 表示"不是我该管的",让原错误照常上报。
|
|
1554
|
+
// 为什么不在这里补 type/paste/upload:它们各自有 OOPIF 分支(受信输入、
|
|
1555
|
+
// 合成 ClipboardEvent、DOM.setFileInputFiles),抢过来只会绕开那些更可靠的实现。
|
|
1556
|
+
async _actInOwningFrame(view, action, target, tabId) {
|
|
1557
|
+
if (action !== 'focus' && action !== 'click') return null;
|
|
1558
|
+
let frame = null;
|
|
1559
|
+
try { frame = await this._owningFrameFor(view, target, tabId); } catch (_) { frame = null; }
|
|
1560
|
+
if (!frame) return null;
|
|
1561
|
+
const js = `(function(){
|
|
1562
|
+
var el=${findRefExpr(JSON.stringify(String(target)))};
|
|
1563
|
+
if(!el) return {__miss:true};
|
|
1564
|
+
if(${JSON.stringify(action)}==='focus'){
|
|
1565
|
+
try{ el.scrollIntoView({block:'nearest',behavior:'instant'}); }catch(e){}
|
|
1566
|
+
try{ el.focus(); }catch(e){}
|
|
1567
|
+
return {success:true, focused: document.activeElement===el};
|
|
1568
|
+
}
|
|
1569
|
+
try{ el.scrollIntoView({block:'center',behavior:'instant'}); }catch(e){}
|
|
1570
|
+
try{ el.click(); }catch(e){ return {success:false, error:String(e&&e.message||e).slice(0,200)}; }
|
|
1571
|
+
return {success:true};
|
|
1572
|
+
})()`;
|
|
1573
|
+
try {
|
|
1574
|
+
const r = await frame.executeJavaScript(js);
|
|
1575
|
+
if (!r || r.__miss) return null;
|
|
1576
|
+
if (r.success === false) return { success: false, error: r.error || 'frame action failed', method: 'frame-js' };
|
|
1577
|
+
const out = { success: true, target, method: 'frame-js' };
|
|
1578
|
+
if (action === 'click') out.clicked_via = 'frame-native';
|
|
1579
|
+
else out.focused = r.focused !== false;
|
|
1580
|
+
return out;
|
|
1581
|
+
} catch (e) {
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1350
1586
|
// === 只读取全量 node.value(ui.act {action:'get_value'})===
|
|
1351
1587
|
// 语义树里 node.value 只给前 200 Unicode code point;超长的节点带
|
|
1352
1588
|
// value_fetch 提示,调用方用它取全量(支持 offset/limit 分页)。
|
|
@@ -1402,12 +1638,98 @@ class PageManager {
|
|
|
1402
1638
|
|
|
1403
1639
|
// #3 helper: read & clear the "new tab opened by this source tab" entry.
|
|
1404
1640
|
// Returns { id, url } or null. Unless keep===true, auto-activates the new tab.
|
|
1405
|
-
|
|
1641
|
+
//
|
|
1642
|
+
// startedAt(可选):本次动作的开始时刻。只有**在这之后**记录的条目才算
|
|
1643
|
+
// "这次动作开的"。没有它时,任何没被 act 消费的记录(例如 ui.evaluate 里
|
|
1644
|
+
// el.click() 触发的 window.open)会一直挂在 map 里,被下一次点击/悬停
|
|
1645
|
+
// 误当成"刚开的标签"。陈旧条目在读取时顺带清掉,避免长期堆积。
|
|
1646
|
+
_consumeOpenedTab(tid, keep, startedAt) {
|
|
1406
1647
|
const entry = this._openedTabs.get(tid);
|
|
1407
1648
|
this._openedTabs.delete(tid);
|
|
1408
|
-
|
|
1409
|
-
if (
|
|
1410
|
-
|
|
1649
|
+
const mine = pickOpenedTab(entry, startedAt);
|
|
1650
|
+
if (!mine) return null;
|
|
1651
|
+
if (keep !== true) this.setActive(mine.id);
|
|
1652
|
+
// 记录里的 url 是**建标签那一刻**的值。window.open("") 这条路上它永远是
|
|
1653
|
+
// 'about:blank' —— 真实地址由随后的 ai:window_open_nav 才载入。直接回
|
|
1654
|
+
// 'about:blank' 会让 agent 以为"打开了个空白页",这恰恰是当初那个 bug 的样子。
|
|
1655
|
+
// 所以这里读一次实时 URL:已经导航了就用真地址,还没导航就明说 pending。
|
|
1656
|
+
let url = mine.url;
|
|
1657
|
+
let pending = false;
|
|
1658
|
+
try {
|
|
1659
|
+
const view = this._getView(mine.id);
|
|
1660
|
+
const live = view && view.webContents.getURL();
|
|
1661
|
+
if (live && live !== 'about:blank') url = live;
|
|
1662
|
+
// 仍然没有真实地址(占位页还没被导航)→ 明说 pending,而不是回一个
|
|
1663
|
+
// 看起来像"空白页"的 about:blank。
|
|
1664
|
+
if (!url || url === 'about:blank') pending = true;
|
|
1665
|
+
} catch (_) { /* 读不到就维持记录里的值 */ }
|
|
1666
|
+
const out = { id: mine.id, url };
|
|
1667
|
+
if (pending) out.url_pending = true;
|
|
1668
|
+
return out;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* 点完**等一下**再收新标签记录(只给 click 用)。
|
|
1673
|
+
*
|
|
1674
|
+
* 为什么需要:实测(见 shared/opened_tab.js 顶部)点击派发后 105~119ms 才出现
|
|
1675
|
+
* 新标签,而 act 在 10~15ms 就返回了 —— 回执里的 `opened_tab: null` 是**错的**,
|
|
1676
|
+
* agent 会据此认为"点了没反应",然后重试。
|
|
1677
|
+
*
|
|
1678
|
+
* 代价:不开标签的点击会等满宽限窗口(默认 150ms,可关)。所以这里是
|
|
1679
|
+
* 「先看一眼,有就立刻返回」,只有确实没有记录时才进入轮询。
|
|
1680
|
+
*/
|
|
1681
|
+
async _awaitOpenedTab(tid, keep, startedAt, params, target) {
|
|
1682
|
+
const graceMs = openedTabGraceMs(params);
|
|
1683
|
+
// 快路径 1:已经有属于本次动作的记录,零等待。
|
|
1684
|
+
if (pickOpenedTab(this._openedTabs.get(tid), startedAt) || graceMs <= 0) {
|
|
1685
|
+
return this._consumeOpenedTab(tid, keep, startedAt);
|
|
1686
|
+
}
|
|
1687
|
+
const view = this._getView(tid);
|
|
1688
|
+
// 快路径 2:本 tab 已经开始原地导航 —— 站内跳转不会同时开新标签,别等。
|
|
1689
|
+
// 这一类(点链接跳走)是最高频的点击,跳过它等于让绝大多数点击零额外成本。
|
|
1690
|
+
if (view && view.webContents.isLoading()) {
|
|
1691
|
+
return this._consumeOpenedTab(tid, keep, startedAt);
|
|
1692
|
+
}
|
|
1693
|
+
// 快路径 3:目标不是链接 → 不等。
|
|
1694
|
+
// 实测代价:等待是加在**每一个**点击上的,点段落这种不导航的点击从 7ms 变成
|
|
1695
|
+
// 261ms。而"点开新标签"在实际站点上几乎都发生在 <a> 上(window.open 的按钮
|
|
1696
|
+
// 弹层也存在,但少),所以用 tagName 做闸门:探一次开销约几毫秒,换来把
|
|
1697
|
+
// 250ms 的税从绝大多数点击上摘掉。
|
|
1698
|
+
// 非链接的弹层场景由调用方显式传 opened_tab_wait_ms 打开等待。
|
|
1699
|
+
const explicitWait = params && params.opened_tab_wait_ms !== undefined && params.opened_tab_wait_ms !== null;
|
|
1700
|
+
if (!explicitWait && !(await this._targetIsLink(view, target, tid))) {
|
|
1701
|
+
return this._consumeOpenedTab(tid, keep, startedAt);
|
|
1702
|
+
}
|
|
1703
|
+
const deadline = Date.now() + graceMs;
|
|
1704
|
+
while (Date.now() < deadline) {
|
|
1705
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1706
|
+
if (pickOpenedTab(this._openedTabs.get(tid), startedAt)) break;
|
|
1707
|
+
// 等待期间页面开始导航了,同样立刻收工。
|
|
1708
|
+
if (view && view.webContents.isLoading()) break;
|
|
1709
|
+
}
|
|
1710
|
+
return this._consumeOpenedTab(tid, keep, startedAt);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* 目标是不是 <a>。只用于决定"要不要为开新标签等一会儿"。
|
|
1715
|
+
*
|
|
1716
|
+
* 探不到(元素已被 SPA 重渲染掉、帧不可达)一律按"不是链接"处理:
|
|
1717
|
+
* 宁可少等一次(回到原来的漏报),也不要为一个不确定的目标付 250ms。
|
|
1718
|
+
*/
|
|
1719
|
+
async _targetIsLink(view, target, tid) {
|
|
1720
|
+
if (!view || !target) return false;
|
|
1721
|
+
try {
|
|
1722
|
+
const owning = await this._owningFrameFor(view, target, tid);
|
|
1723
|
+
const expr = `(function(){var el=${findRefExpr(JSON.stringify(String(target)))};` +
|
|
1724
|
+
`return el?String(el.tagName||'').toUpperCase():null;})()`;
|
|
1725
|
+
// 用 this.evaluate(与 _owningFrameFor 同一口径:直接给值),不要用
|
|
1726
|
+
// _evaluateViaCdp —— 那里回的是 {result:{value}} 包裹,比一次就永远判成
|
|
1727
|
+
// "不是链接",闸门形同虚设(第一版就踩了这个)。
|
|
1728
|
+
const tag = owning ? await owning.executeJavaScript(expr) : await this.evaluate(expr, tid);
|
|
1729
|
+
return tag === 'A';
|
|
1730
|
+
} catch (_) {
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1411
1733
|
}
|
|
1412
1734
|
|
|
1413
1735
|
// #2: 按目标 URL 定位并点击链接 —— 通用替代"猜卡片里的 <a>"。顶层在
|
|
@@ -1612,7 +1934,8 @@ class PageManager {
|
|
|
1612
1934
|
// 所以这里先看顶层,找不到再逐个帧找。返回 null 表示就在主帧里。
|
|
1613
1935
|
async _owningFrameFor(view, aiId, tabId) {
|
|
1614
1936
|
if (!aiId) return null;
|
|
1615
|
-
|
|
1937
|
+
// `frame-N` 是合并门节点,页面里没有对应元素,别浪费一次顶层查询。
|
|
1938
|
+
if (/^axf-/.test(aiId) || /^frame-\d+$/.test(aiId)) return await this._owningFrame(view, aiId);
|
|
1616
1939
|
try {
|
|
1617
1940
|
const inTop = await this.evaluate(
|
|
1618
1941
|
`!!${findRefExpr(JSON.stringify(aiId))}`,
|
|
@@ -1629,6 +1952,13 @@ class PageManager {
|
|
|
1629
1952
|
async _owningFrame(view, aiId) {
|
|
1630
1953
|
const root = view.webContents.mainFrame;
|
|
1631
1954
|
const frames = root && root.framesInSubtree ? root.framesInSubtree : [];
|
|
1955
|
+
// 树里暴露的 `frame-N` 是合并出来的门节点(axExtractor.mergeFrameTrees 的
|
|
1956
|
+
// gate),它在页面里**没有对应元素**。此前这里只会按 [data-ai-id] 找,于是
|
|
1957
|
+
// `{frame_ref:"frame-1"}` 必然报 frame_not_found —— 树里写着 frame-1,用起来
|
|
1958
|
+
// 说不存在。序号口径与 _readOopifFrames 一致(见 shared/frame_probe.pickSubframe)。
|
|
1959
|
+
const subs = frames.filter((f) => f !== root);
|
|
1960
|
+
const gateFrame = frameProbe.pickSubframe(subs, aiId);
|
|
1961
|
+
if (gateFrame || frameProbe.gateFrameIndex(aiId) != null) return gateFrame;
|
|
1632
1962
|
for (const frame of frames) {
|
|
1633
1963
|
if (frame === root) continue;
|
|
1634
1964
|
try {
|
|
@@ -2720,6 +3050,8 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
2720
3050
|
// 导航阶段发出的 POST 全部丢失 —— agent 永远拿不到它们的请求体。
|
|
2721
3051
|
// 关闭开关:AI_BROWSER_NETWORK_CAPTURE=0 或 ui.network_configure{enabled:false}。
|
|
2722
3052
|
this._ensureNetworkCapture(tabId, view);
|
|
3053
|
+
// window.open 垫片不在这里调:它返回的 Promise 要被下面的首次导航等
|
|
3054
|
+
// (见文件末尾 navWhenShimReady),在这里提前调会消耗掉首次注入。
|
|
2723
3055
|
|
|
2724
3056
|
// Mask automation fingerprint: remove Electron from UA
|
|
2725
3057
|
view.webContents.setUserAgent(config.userAgent);
|
|
@@ -2729,7 +3061,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
2729
3061
|
const childId = self.newTab(urlToOpen);
|
|
2730
3062
|
// Track which source tab opened a new tab so `executeAction` can report the
|
|
2731
3063
|
// resulting tab and auto-follow it. Generic (any target=_blank / window.open).
|
|
2732
|
-
self._openedTabs.set(tabId, { id: childId, url: urlToOpen });
|
|
3064
|
+
self._openedTabs.set(tabId, { id: childId, at: Date.now(), url: urlToOpen });
|
|
2733
3065
|
return { action: 'deny' };
|
|
2734
3066
|
});
|
|
2735
3067
|
|
|
@@ -2749,11 +3081,35 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
2749
3081
|
// (configure({reload:true}) 可以补齐)。
|
|
2750
3082
|
const install = () => { this._ensureCanvasHook(tabId, view).catch(() => false); };
|
|
2751
3083
|
view.webContents.once('did-finish-load', install);
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
3084
|
+
}
|
|
3085
|
+
// window.open 垫片注入:CDP 命令在**未导航的空 webContents** 上会挂住
|
|
3086
|
+
// (实测 Page.enable 都 1.5s 不 resolve —— renderer 没就绪,命令无人
|
|
3087
|
+
// 处理)。所以先 load about:blank 把 renderer 垫起来,did-finish-load
|
|
3088
|
+
// 后再发注入命令,最后才 loadURL 真实 URL —— 这样首屏 document 一定
|
|
3089
|
+
// 带着垫片(addScriptToEvaluateOnNewDocument 注册后跨导航持久)。
|
|
3090
|
+
// 占位 tab(window.open 的空窗口,url=null)就停在 about:blank 等
|
|
3091
|
+
// location 赋值来导航;赋值到达时注入通常早已完成(源页面 ajax 往返
|
|
3092
|
+
// 至少几十毫秒),基本不存在竞态。
|
|
3093
|
+
const wc0 = view.webContents;
|
|
3094
|
+
const targetUrl = url || null;
|
|
3095
|
+
if (wc0 && !wc0.isDestroyed()) {
|
|
3096
|
+
let proceeded = false;
|
|
3097
|
+
const proceed = () => {
|
|
3098
|
+
if (proceeded) return;
|
|
3099
|
+
proceeded = true;
|
|
3100
|
+
Promise.resolve(this._ensureWindowOpenShim(tabId, view)).finally(() => {
|
|
3101
|
+
if (targetUrl && wc0 && !wc0.isDestroyed()) {
|
|
3102
|
+
wc0.loadURL(targetUrl).catch(() => {});
|
|
3103
|
+
}
|
|
3104
|
+
});
|
|
3105
|
+
};
|
|
3106
|
+
// 兜底:about:blank 的 did-finish-load 万一来不了(renderer 异常、
|
|
3107
|
+
// 视图尚未 attach 等),也不能把真实导航永久挂住——1.2s 后照常放行,
|
|
3108
|
+
// 最坏退化成"首屏没有垫片"(与修复前一致),而不是打不开页面。
|
|
3109
|
+
const guard = setTimeout(proceed, 1200);
|
|
3110
|
+
if (guard.unref) guard.unref();
|
|
3111
|
+
wc0.loadURL('about:blank').catch(() => {});
|
|
3112
|
+
wc0.once('did-finish-load', () => { clearTimeout(guard); proceed(); });
|
|
2757
3113
|
}
|
|
2758
3114
|
|
|
2759
3115
|
// Force repaint after load — BrowserView content can be loaded but not
|
|
@@ -2976,6 +3332,12 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
2976
3332
|
view.webContents.close();
|
|
2977
3333
|
}
|
|
2978
3334
|
this.tabs.delete(tabId);
|
|
3335
|
+
// window.open 垫片记账:这个 tab 若是某次 window.open 的目标,token 已
|
|
3336
|
+
// 失去意义(nav 到达也只会在上面打一行 warn),清掉防止 Map 无限增长。
|
|
3337
|
+
for (const [token, entry] of this._windowOpenTokens) {
|
|
3338
|
+
if (entry.tabId === tabId) this._windowOpenTokens.delete(token);
|
|
3339
|
+
}
|
|
3340
|
+
this._windowOpenShimTabs.delete(tabId);
|
|
2979
3341
|
// Release per-tab network resources so a closed tab can never leak its
|
|
2980
3342
|
// debugger attachment or cached request entries / stale monitors.
|
|
2981
3343
|
this._cleanupTabResources(tabId);
|
|
@@ -3411,6 +3773,54 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3411
3773
|
} catch (e) { /* mid-navigation / already-attached elsewhere; skip this tab */ }
|
|
3412
3774
|
}
|
|
3413
3775
|
|
|
3776
|
+
// window.open 垫片注入(见 window_open_shim_source.js 顶部注释)。
|
|
3777
|
+
// contextIsolation 下 preload 改不了页面可见的 window.open,只能经
|
|
3778
|
+
// Page.addScriptToEvaluateOnNewDocument 注入主 world。
|
|
3779
|
+
//
|
|
3780
|
+
// ⚠️ 必须在 renderer 就绪后调用:未导航的空 webContents 上 CDP 命令无人
|
|
3781
|
+
// 处理会挂住(实测 Page.enable 1.5s 不 resolve)。newTab 的做法是先
|
|
3782
|
+
// load about:blank,did-finish-load 后再调这里。
|
|
3783
|
+
// 幂等:每个 tab 只注册一次(_windowOpenShimTabs 记账);跨导航自动保留
|
|
3784
|
+
// 正是 addScriptToEvaluateOnNewDocument 的语义,不需要重复注册。
|
|
3785
|
+
_ensureWindowOpenShim(tabId, view) {
|
|
3786
|
+
if (this._windowOpenShimTabs.has(tabId)) return Promise.resolve(true);
|
|
3787
|
+
this._windowOpenShimTabs.add(tabId);
|
|
3788
|
+
// async IIFE:注入要 await(Page.enable → addScript 有序),但函数本身
|
|
3789
|
+
// 仍返回 Promise(调用方 .finally 等它)。
|
|
3790
|
+
return (async () => {
|
|
3791
|
+
try {
|
|
3792
|
+
const wc = view.webContents;
|
|
3793
|
+
if (!wc || typeof wc.debugger?.attach !== 'function') return false;
|
|
3794
|
+
if (!this._cdpTabs.has(tabId)) {
|
|
3795
|
+
wc.debugger.attach('1.3');
|
|
3796
|
+
const entry = { wc, domains: new Set() };
|
|
3797
|
+
this._cdpTabs.set(tabId, entry);
|
|
3798
|
+
wc.debugger.on('message', (_event, method, params) => this._onCdpMessage(tabId, method, params));
|
|
3799
|
+
}
|
|
3800
|
+
// ⚠️ 必须先 Page.enable:Page 域 agent 未启用时,
|
|
3801
|
+
// addScriptToEvaluateOnNewDocument 发出去会**挂住不回**(实测 1.5s
|
|
3802
|
+
// 超时也不 resolve,首屏页面因此永远拿不到垫片)。enable 后再注入。
|
|
3803
|
+
await withDeadline(
|
|
3804
|
+
wc.debugger.sendCommand('Page.enable'),
|
|
3805
|
+
1500,
|
|
3806
|
+
'window_open_page_enable'
|
|
3807
|
+
);
|
|
3808
|
+
await withDeadline(
|
|
3809
|
+
wc.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
|
|
3810
|
+
source: WINDOW_OPEN_SHIM_SOURCE,
|
|
3811
|
+
runForSubFrames: true,
|
|
3812
|
+
}),
|
|
3813
|
+
1500,
|
|
3814
|
+
'window_open_shim_inject'
|
|
3815
|
+
);
|
|
3816
|
+
return true;
|
|
3817
|
+
} catch (e) {
|
|
3818
|
+
console.error('[window_open] shim inject failed:', tabId, e && e.message);
|
|
3819
|
+
return false;
|
|
3820
|
+
}
|
|
3821
|
+
})();
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3414
3824
|
// 抓包用的 Network 域:默认在 tab 创建后、首个导航前启用。
|
|
3415
3825
|
// Network.enable 必须显式带 maxPostDataSize,否则 Chromium 可能不内联
|
|
3416
3826
|
// request.postData(POST 请求体就拿不到,只能靠 getRequestPostData 补)。
|