@glittering0717/ai-browser 1.2.2 → 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.
@@ -3,6 +3,7 @@
3
3
  // Agent routes via tab ID. Tab 0 is default.
4
4
  import { ipcMain, BrowserView } from 'electron';
5
5
  import path from 'node:path';
6
+ import fs from 'node:fs';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { config } from '../shared/config.js';
8
9
  import axExtractor from './axExtractor.js';
@@ -14,12 +15,53 @@ import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
14
15
  import { DownloadMonitor } from './download_monitor.js';
15
16
  import { probeCapabilities } from './capability_probe.js';
16
17
  import valueContract from '../shared/value_contract.cjs';
18
+ // 输入成功与否的判定口径(纯函数,独立模块便于单测)。详见文件内注释:
19
+ // 富文本编辑器会把一个语义换行渲染成一串 \n,逐字比对会把成功的输入误判成失败。
20
+ import { normalizeInputText, skeletalInputText } from '../shared/input_verify.js';
21
+ // 截图参数规范化(纯函数):坏参数在入口就给可读错误,不拖到 CDP 层。
22
+ import screenshotOpts from '../shared/screenshot_opts.cjs';
23
+ // 跨进程子帧(OOPIF)判定与自动路由回执(纯函数):默认读法检测到不可读
24
+ // 子帧时自己补一次 AX 读取,调用方无需知道 ax:true 的存在。
25
+ import frameProbe from '../shared/frame_probe.cjs';
26
+ // ui.act target 形状体检(纯函数):传了可见文字而不是 data-ai-id 时,
27
+ // 入口就给可操作的错误,而不是等到选择器查空才说 "Target not found"。
28
+ import refGuard from '../shared/ref_guard.cjs';
29
+ // 错误日志:导航失败(界面会显示错误页)必须落盘 —— load_failed 只是内存里的
30
+ // 一次广播,进程一退出就再也查不到"哪个 tab、哪个网址、什么错误码"。
31
+ import { recordError } from './error_log.js';
32
+ // navigate 的完成信号(commit | domcontentloaded | load):默认不再是 load ——
33
+ // SPA 永不 fire load,把它当完成信号等于把"页面可用"报成"超时"。
34
+ import navWait from '../shared/nav_wait.cjs';
35
+ // 穿透影子根的 ref 查找(纯字符串构造,见 shared/find_ref.cjs 顶部注释)
36
+ import findRefUtil from '../shared/find_ref.cjs';
37
+ // 真正的焦点元素(穿透 shadow root)。`document.activeElement` 在 Web Component
38
+ // 里只到宿主为止,用它做输入校验会读到空串,把"已打进去"报成"没打进去"。
39
+ import deepActiveUtil from '../shared/deep_active.cjs';
40
+ // 悬停前后语义树的差异(ui.peek)。数据源是 DOM 语义树而不是 AX 树,理由与两条
41
+ // 实测证据都写在 shared/tree_diff.cjs 顶部。
42
+ import treeDiff from '../shared/tree_diff.cjs';
43
+ // 按键派发计划(纯函数):rawKeyDown 不产生 keypress,Enter 的默认动作会落空。
44
+ import keyDispatchUtil from '../shared/key_dispatch.cjs';
45
+ // 命中探测(穿透影子根)。`document.elementFromPoint` 不跨 shadow boundary,
46
+ // 对影子内部的元素恒返回宿主 —— 于是"点击前证明坐标会落在目标上"这道校验
47
+ // 恒为假,点击只能降级成非受信的 el.click()。详见 shared/hit_point.cjs 顶部。
48
+ import hitPointUtil from '../shared/hit_point.cjs';
17
49
 
18
50
  const { expandValueFetchTree } = valueContract;
51
+ const deepActiveExpr = deepActiveUtil.deepActiveExpr;
52
+ const keyDispatchPlan = keyDispatchUtil.keyDispatchPlan;
53
+ const { hitTestExpr, hitInfoExpr, deepHitExpr } = hitPointUtil;
19
54
 
20
55
  const __filename = fileURLToPath(import.meta.url);
21
56
  const __dirname = path.dirname(__filename);
22
57
 
58
+ // 穿透影子根的 ref 查找(返回自包含 JS 表达式,可原地替换 document.querySelector)。
59
+ // 实现与理由见 src/shared/find_ref.cjs —— 那里的注释解释了为什么查询必须穿 shadow
60
+ // boundary,以及为什么返回"表达式字符串"而不是"运行时函数"。
61
+ const findRefExpr = findRefUtil.findRefExpr;
62
+ const findDescExpr = findRefUtil.findDescExpr;
63
+
64
+
23
65
  // canvas 绘制调用记录:默认开。关闭开关 AI_BROWSER_CANVAS_HOOK=0。
24
66
  const CANVAS_BRIDGE_KEY = '__aiCanvasBridge';
25
67
 
@@ -119,7 +161,6 @@ class PageManager {
119
161
  this._wsClients = new Map();
120
162
  this._openedTabs = new Map(); // sourceTabId -> { id, url } (new tab opened by window.open)
121
163
  this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
122
- this._revertTimers = new Map(); // tabId -> 挂起的 ui.peek 自动回撤定时器(新 peek 会取消它)
123
164
  this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
124
165
  this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
125
166
  this._snapshotSeq = 0;
@@ -268,12 +309,16 @@ class PageManager {
268
309
  }
269
310
 
270
311
  _layoutAllViews(bounds) {
312
+ // `_bottomInset`:窗口底部被别的东西占掉的高度(目前只有主进程异常横幅)。
313
+ // 由 index.js 设置、这里统一扣减 —— 让每个"给视图定尺寸"的地方各自记住
314
+ // 这 34px 必然会漏掉一处(新建 tab 走的是 _layoutView,不是这里)。
315
+ const inset = this._bottomInset || 0;
271
316
  for (const [id, view] of this.tabs) {
272
317
  view.setBounds({
273
318
  x: 0,
274
319
  y: config.tabBarHeight,
275
320
  width: bounds.width,
276
- height: bounds.height - config.tabBarHeight,
321
+ height: Math.max(0, bounds.height - config.tabBarHeight - inset),
277
322
  });
278
323
  }
279
324
  }
@@ -451,11 +496,15 @@ class PageManager {
451
496
  * (挂起的连接、不结束的加载)。这与 ui.act 是同一类故障:await 一个可能不返回的
452
497
  * 东西。所以同样套 deadline。
453
498
  */
454
- async navigate(url, tabId) {
499
+ async navigate(url, tabId, opts = {}) {
455
500
  const tid = tabId !== undefined ? tabId : this.activeTab;
456
501
  const view = this._getView(tid);
457
502
  if (!view) return { ok: false, error: 'Tab not found' };
503
+ const w = navWait.normalizeWaitUntil(opts.waitUntil);
504
+ if (w.error) return { ok: false, error: w.error, wait_until_valid: navWait.LEVELS };
505
+ const waitUntil = w.value;
458
506
  this._axDiffCache.delete(tid); // fresh page → drop stale incremental snapshot
507
+ const wc = view.webContents;
459
508
  // loadURL resolves at did-finish-load, which is exactly when the old
460
509
  // pixels may still be on screen — repaint here as well as from the event
461
510
  // hooks, so a programmatic navigate never depends on hook timing.
@@ -466,30 +515,102 @@ class PageManager {
466
515
  // 打断当前这次 loadURL 的 Promise —— 如果不区分,agent 收到的就是
467
516
  // {ok:false, error:"ERR_ABORTED (-3) loading 'file://.../error_page.html'"},
468
517
  // 它会以为自己的导航请求坏了,而实际上错误页已经接管、信息全在那儿。
518
+ const pending = wc.loadURL(url);
519
+ // commit / domcontentloaded 会**提前**返回;之后这个 Promise 仍可能 reject
520
+ // (错误页接管、用户中止)。不接住就是 unhandledRejection,会把整个进程
521
+ // 的日志淹掉。错误本身在返回结果里另行汇报。
522
+ pending.catch(() => {});
523
+
469
524
  try {
470
- await withDeadline(view.webContents.loadURL(url), 30000, 'navigate');
471
- } catch (e) {
472
- const msg = String((e && e.message) || e);
473
- // 错误页已接管:这不是调用方的错,是目标站点不可达。
474
- // 两个判据(都不可省):_failureGuard 里还有这个 tab(错误页刚加载、
475
- // did-finish-load 还没来清标记),或者当前 URL 已经是我们的错误页。
476
- const onErrorPage = String(view.webContents.getURL() || '').indexOf('error_page.html') >= 0;
477
- if ((this._failureGuard && this._failureGuard.has(tid)) || onErrorPage) {
478
- return {
479
- ok: false,
480
- error: msg,
481
- error_page: true,
482
- hint: '目标站点加载失败,已显示错误页(可重试 / 改网址)。'
483
- + ' 失败详情在页面里,用 ui.get_tree 或 ui.evaluate 读取。',
484
- };
485
- }
486
- if (e && e.__deadline) {
487
- return { ok: false, error: `${e.message} — the page never finished loading; it may still be loading in the background` };
525
+ if (waitUntil === 'load') {
526
+ await withDeadline(pending, 30000, 'navigate');
527
+ } else {
528
+ // 事件驱动,不等 did-finish-load。与 loadURL 自身的结果赛跑:同文档
529
+ // 导航(#hash、history.pushState)不会 fire did-navigate,但那类导航
530
+ // 的 loadURL 会立刻 resolve —— 不赛跑就会白等一整个 deadline。
531
+ await withDeadline(Promise.race([
532
+ this._waitForNavStep(wc, waitUntil, url),
533
+ pending.then(() => ({ at: 'resolved' }), () => ({ at: 'resolved' })),
534
+ ]), waitUntil === 'commit' ? 15000 : 20000, 'navigate');
488
535
  }
489
- return { ok: false, error: msg.slice(0, 300) };
536
+ } catch (e) {
537
+ return this._navigateFailure(e, view, tid, waitUntil);
490
538
  }
491
539
  this.repaint(tid);
492
- return { ok: true };
540
+ if (waitUntil === 'load') return { ok: true, waited_for: 'load' };
541
+ // load 还没来是**正常**的(SPA 本来就不 fire load)。如实说明,并给出
542
+ // 下一步:调用方需要等某个具体内容时该用 ui.wait,而不是等 load。
543
+ const stillLoading = typeof wc.isLoading === 'function' ? !!wc.isLoading() : false;
544
+ return {
545
+ ok: true,
546
+ waited_for: waitUntil,
547
+ url: wc.getURL(),
548
+ load_pending: stillLoading,
549
+ hint: stillLoading
550
+ ? 'returned at ' + waitUntil + ' — the load event has not fired (normal for SPAs); the page is usable now. Wait for specific content with ui.wait, not for load.'
551
+ : null,
552
+ };
553
+ }
554
+
555
+ // commit / domcontentloaded 的事件等待。只认主帧:子帧导航不改变"这个 tab 到了
556
+ // 没有"。任一事件先到就 resolve;did-fail-load 立刻 reject(错误页逻辑在外层)。
557
+ //
558
+ // 两个必须忽略的迟到事件(都实测踩到过):
559
+ // -3 ERR_ABORTED:上一次导航被这次导航打断,这是**正常**的取代,不是失败。
560
+ // 不忽略的话,连续两次 navigate 的第二跳稳定被报成 "did-fail-load -3"。
561
+ // failedUrl ≠ 本次目标:上一轮导航的迟到事件。它描述的不是我们等的东西。
562
+ _waitForNavStep(wc, step, expectedUrl) {
563
+ const norm = (u) => String(u || '').split('#')[0];
564
+ return new Promise((resolve, reject) => {
565
+ const cleanup = () => {
566
+ wc.removeListener('did-navigate', onCommit);
567
+ wc.removeListener('dom-ready', onDom);
568
+ wc.removeListener('did-fail-load', onFail);
569
+ };
570
+ const onCommit = () => {
571
+ if (step !== 'commit') return; // domcontentloaded 还要再等一步
572
+ cleanup();
573
+ resolve({ at: 'commit' });
574
+ };
575
+ const onDom = () => { cleanup(); resolve({ at: 'domcontentloaded' }); };
576
+ const onFail = (_e, code, desc, failedUrl, isMainFrame) => {
577
+ if (isMainFrame === false) return;
578
+ if (code === -3) return; // 被新的导航取代(见上)
579
+ if (expectedUrl && failedUrl && norm(failedUrl) !== norm(expectedUrl)) return;
580
+ cleanup();
581
+ reject(Object.assign(new Error('did-fail-load ' + code + ' ' + desc), { __navFail: true }));
582
+ };
583
+ wc.once('did-navigate', onCommit);
584
+ wc.once('dom-ready', onDom);
585
+ wc.once('did-fail-load', onFail);
586
+ });
587
+ }
588
+
589
+ _navigateFailure(e, view, tid, waitUntil) {
590
+ const msg = String((e && e.message) || e);
591
+ // 错误页已接管:这不是调用方的错,是目标站点不可达。
592
+ // 两个判据(都不可省):_failureGuard 里还有这个 tab(错误页刚加载、
593
+ // did-finish-load 还没来清标记),或者当前 URL 已经是我们的错误页。
594
+ const onErrorPage = String(view.webContents.getURL() || '').indexOf('error_page.html') >= 0;
595
+ if ((this._failureGuard && this._failureGuard.has(tid)) || onErrorPage) {
596
+ return {
597
+ ok: false,
598
+ error: msg,
599
+ error_page: true,
600
+ hint: '目标站点加载失败,已显示错误页(可重试 / 改网址)。'
601
+ + ' 失败详情在页面里,用 ui.get_tree 或 ui.evaluate 读取。',
602
+ };
603
+ }
604
+ if (e && e.__deadline) {
605
+ return {
606
+ ok: false,
607
+ error: `${e.message} — navigation did not reach "${waitUntil}" in time; the page may still be loading in the background`,
608
+ waited_for: waitUntil,
609
+ hint: 'If the page is a SPA that never fires load, use wait_until:"domcontentloaded" or "commit". '
610
+ + 'If the tab is stuck altogether, rebuild it: ui.close_tab + ui.new_tab.',
611
+ };
612
+ }
613
+ return { ok: false, error: msg.slice(0, 300), waited_for: waitUntil };
493
614
  }
494
615
 
495
616
  async getTree(focusedOnly = false, tabId) {
@@ -511,9 +632,213 @@ class PageManager {
511
632
  // 截断的 node.value 带内部字段 value_fetch_ref —— 展开成含实际 tab 的
512
633
  // value_fetch(mcp/ws 两种可直接执行的调用提示),再删掉内部字段。
513
634
  if (res && res.tree) expandValueFetchTree(res.tree, tid);
635
+ // P1-1:页面有可见的跨进程子帧时,自动把子帧内容合并进默认树。
636
+ // 此前这条通路只在 ax:true 下存在,而**没有任何地方告诉调用方 ax:true
637
+ // 的存在** —— B站图文编辑器(正文在 OOPIF 里)因此隐形了两小时,agent
638
+ // 得出"编辑器没加载"的错误结论。现在默认读法自己补上这一步。
639
+ if (!focusedOnly) await this._autoMergeOpaqueFrames(res, tid);
514
640
  return res;
515
641
  }
516
642
 
643
+ // 把跨进程子帧的内容读出来、合并进默认(DOM)树。轻量:只读子帧,
644
+ // 不重建主树,所以默认树的形状/id 全部保持原样。
645
+ // 三种结局都写进 context.frame_hints(见 shared/frame_probe.describeAutoAx):
646
+ // merged / no-new-content / failed+reason。调用方必须能区分"已尽力"和
647
+ // "还有一条没走过的路"。
648
+ async _autoMergeOpaqueFrames(res, tid) {
649
+ try {
650
+ if (!res || !res.tree || !res.context) return;
651
+ if (res.tree.__oopif_merged) return; // 幂等:合并过的树不重复做
652
+ const summary = res.context.cross_process_frames;
653
+ if (!frameProbe.shouldAutoAx(summary)) return;
654
+ const view = this._getView(tid);
655
+ if (!view) return;
656
+ const frames = await withDeadline(this._readOopifFrames(view, tid), 15000, 'get_tree(auto-oopif)');
657
+ if (frames && frames.length) {
658
+ res.tree = axExtractor.mergeFrameTrees(res.tree, frames);
659
+ res.tree.__oopif_merged = true;
660
+ // 子帧里的必填提示 / 校验错误也一并合进 context(否则最该有这些字段的
661
+ // 页面恰好是空的,见 frame_probe.mergeFrameContext 顶部注释)。
662
+ const mergedCtx = frameProbe.mergeFrameContext(res.context, frames);
663
+ if (mergedCtx) Object.assign(res.context, mergedCtx);
664
+ }
665
+ res.context.frame_hints = frameProbe.describeAutoAx({
666
+ merged: !!(frames && frames.length),
667
+ framesRead: (frames || []).length,
668
+ });
669
+ } catch (e) {
670
+ // 自动合并失败绝不拖垮主读法:默认树照常返回,只在 hints 里说明原因。
671
+ // 调用方看到 failed 可以手动切 ax:true 重试。
672
+ if (res && res.context) {
673
+ res.context.frame_hints = frameProbe.describeAutoAx({
674
+ error: (e && e.__deadline ? e.message + ' — the page main thread is likely blocked' : e && e.message) || String(e),
675
+ });
676
+ }
677
+ }
678
+ }
679
+
680
+ // 登录态 / 登录墙自查。长任务里每个关键步骤前都可以问一次,成本只是走一次
681
+ // extractPageContext(不建树)。
682
+ //
683
+ // 为什么单独开一个原语而不是让调用方自己去 getTree().context 里翻:CSDN 那次
684
+ // 事故里,登录态信息其实一直在 context 里,但它是个不显眼的小字段,且当时的
685
+ // 判定还是错的(csrfToken 子串命中 "token" → 谎报已登录)。显式暴露成
686
+ // "问一句就答一句"的原语,才能让"先验登录态"变成低成本的第一步。
687
+ // 一次性 DOM 侧上下文(不建树)。authState 与 AX 读法共用:AX 自身的
688
+ // context 只有 modals/session/stats,登录墙、校验错误、必填提示全都来自
689
+ // 这条 DOM 扫描 —— 两条读法给出不一致的 context 只会让调用方怀疑读数。
690
+ async _readContext(view) {
691
+ const res = await new Promise((resolve, reject) => {
692
+ const id = ++this._requestId;
693
+ this._pendingRequests.set(id, { resolve, reject });
694
+ view.webContents.send('ai:context', { id });
695
+ setTimeout(() => {
696
+ if (this._pendingRequests.has(id)) {
697
+ this._pendingRequests.delete(id);
698
+ reject(new Error('context read timeout'));
699
+ }
700
+ }, 5000);
701
+ });
702
+ return (res && res.context) || {};
703
+ }
704
+
705
+ async authState(tabId) {
706
+ const tid = tabId !== undefined ? tabId : this.activeTab;
707
+ const view = this._getView(tid);
708
+ if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
709
+ try {
710
+ const ctx = (await this._readContext(view)) || {};
711
+ const wall = ctx.auth_wall || null;
712
+ return {
713
+ ok: true,
714
+ tab: tid,
715
+ url: ctx.url || null,
716
+ auth_wall: wall,
717
+ // 兼容旧形状:session.logged_in 仍从 context 取,但语义已收紧(见
718
+ // shared/auth_wall.cjs)。auth_wall.detected 是硬事实,优先级高于它。
719
+ logged_in: ctx.session ? !!ctx.session.logged_in : null,
720
+ evidence: (ctx.session && ctx.session.evidence) || [],
721
+ // 一句话结论,方便直接读:调用方不必自己组合字段。
722
+ verdict: wall && wall.detected
723
+ ? 'AUTH_WALL — this tab is on a login page, not the site you meant to operate'
724
+ : (ctx.session && ctx.session.logged_in
725
+ ? 'looks-logged-in (heuristic)'
726
+ : 'not-logged-in (heuristic) — verify before continuing'),
727
+ };
728
+ } catch (e) {
729
+ return { ok: false, error: e.message, tab: tid };
730
+ }
731
+ }
732
+
733
+ // 视口截图(P0-1)。此前 ai-browser 只有元素树这一只"眼睛":树里没有的
734
+ // 东西(浮层、canvas、图片、CSS 遮挡、渲染错位)就完全不可见,而这类情况
735
+ // 恰恰是操作失败的主因。调用方失败时第一反应是"看截图",工具面必须支持。
736
+ //
737
+ // 走 webContents.debugger 的 Page.captureScreenshot —— 与 AX/Network 域
738
+ // 共用同一个 per-tab attach(_ensureCdp 幂等),不额外占用调试器。
739
+ // 截图是同步渲染快照,主线程卡住时它不返回,所以带 deadline。
740
+ async screenshot(tabId, opts = {}) {
741
+ const tid = tabId !== undefined ? tabId : this.activeTab;
742
+ const view = this._getView(tid);
743
+ if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
744
+
745
+ const norm = screenshotOpts.normalizeScreenshotOpts(opts);
746
+ if (!norm.ok) return { ok: false, error: norm.error, tab: tid };
747
+ const o = norm.opts;
748
+
749
+ let meta = {};
750
+ try {
751
+ this._ensureCdp(tid, view, 'Page');
752
+ } catch (e) {
753
+ return { ok: false, error: 'cdp-unavailable: ' + e.message, tab: tid };
754
+ }
755
+ const dbg = view.webContents.debugger;
756
+ if (!dbg) return { ok: false, error: 'cdp-unavailable', tab: tid };
757
+
758
+ try {
759
+ // 视口尺寸:clip 缺省时用于回执,也让调用方知道拍的到底是哪块。
760
+ // 失败不致命(老页面没布局完也会返回 0),降级为 null。
761
+ try {
762
+ const lm = await withDeadline(dbg.sendCommand('Page.getLayoutMetrics'), 10000, 'getLayoutMetrics');
763
+ const vp = lm && lm.cssLayoutViewport ? lm.cssLayoutViewport : (lm && lm.layoutViewport) || null;
764
+ if (vp) meta = { width: Math.round(vp.clientWidth || 0), height: Math.round(vp.clientHeight || 0) };
765
+ } catch (_) { /* 布局指标拿不到就算了,截图本身才是主目标 */ }
766
+
767
+ const params = {
768
+ format: o.format,
769
+ fromSurface: true,
770
+ captureBeyondViewport: o.fullPage,
771
+ };
772
+ if (o.quality !== undefined) params.quality = o.quality;
773
+ if (o.clip) params.clip = o.clip;
774
+ const shot = await withDeadline(
775
+ dbg.sendCommand('Page.captureScreenshot', params),
776
+ 20000, 'captureScreenshot'
777
+ );
778
+ if (!shot || typeof shot.data !== 'string') {
779
+ return { ok: false, error: 'empty screenshot result', tab: tid };
780
+ }
781
+
782
+ const mime = 'image/' + (o.format === 'jpeg' ? 'jpeg' : o.format);
783
+ // 一次 evaluate 拿两样东西:可见密码框(截图不脱敏,泄密了得让调用方
784
+ // 知道)和 devicePixelRatio —— 后者因为 CDP 的 clip 是 CSS 像素而输出
785
+ // 是设备像素:调用方按 getBoundingClientRect 传 500 宽,Retina 上会
786
+ // 拿到 1000 像素的图。没有这个比值,"zoom 拍元素"的调用方无法把图上的
787
+ // 坐标换算回页面坐标。
788
+ let hasPwd = false;
789
+ let dpr = null;
790
+ try {
791
+ const p = await this.evaluate(
792
+ `(function(){var els=document.querySelectorAll('input[type=password]');` +
793
+ `var pwd=false;for(var i=0;i<els.length;i++){var r=els[i].getBoundingClientRect();` +
794
+ `if(r.width>0&&r.height>0)pwd=true;}return JSON.stringify({pwd:pwd,dpr:window.devicePixelRatio||1});})()`, tid);
795
+ let parsed = null;
796
+ try { parsed = typeof p === 'string' ? JSON.parse(p) : p; } catch (_) { /* evaluate 可能返回 undefined */ }
797
+ if (parsed) { hasPwd = parsed.pwd === true; dpr = Number(parsed.dpr) || null; }
798
+ } catch (_) { /* 探测失败不影响截图交付 */ }
799
+
800
+ const summary = screenshotOpts.describeShot({
801
+ format: o.format,
802
+ mime,
803
+ bytes: Buffer.byteLength(shot.data, 'base64'),
804
+ width: meta.width,
805
+ height: meta.height,
806
+ url: (this._getView(tid) && this._getView(tid).webContents.getURL()) || undefined,
807
+ hasVisiblePasswordInput: hasPwd,
808
+ devicePixelRatio: dpr,
809
+ });
810
+ // path:直接把图写到磁盘。写失败要如实报错(不是"截图成功但没文件")。
811
+ let filePath = null;
812
+ if (o.path) {
813
+ try {
814
+ fs.mkdirSync(path.dirname(o.path), { recursive: true });
815
+ fs.writeFileSync(o.path, Buffer.from(shot.data, 'base64'));
816
+ filePath = o.path;
817
+ } catch (e) {
818
+ return { ok: false, tab: tid, error: 'screenshot captured but writing to ' + o.path + ' failed: ' + ((e && e.message) || e), ...summary };
819
+ }
820
+ }
821
+ // 未知参数不再静默丢弃:调用方传了什么没被用上,回执里点名。
822
+ const known = { format: 1, quality: 1, fullPage: 1, clip: 1, path: 1 };
823
+ const ignored = Object.keys(opts || {}).filter((k) => !known[k]);
824
+ return {
825
+ ok: true,
826
+ tab: tid,
827
+ // data 是 base64 裸数据(不带 data: 前缀)—— 调用方按需自己拼。
828
+ data: shot.data,
829
+ mime,
830
+ ...(filePath ? { path: filePath } : {}),
831
+ ...(ignored.length ? { ignored_params: ignored } : {}),
832
+ ...summary,
833
+ };
834
+ } catch (e) {
835
+ if (e && e.__deadline) {
836
+ return { ok: false, error: e.message + ' — the page main thread is likely blocked', tab: tid };
837
+ }
838
+ return { ok: false, error: e.message, tab: tid };
839
+ }
840
+ }
841
+
517
842
  // AX probe read layer (P0). Additive — the default getTree path is untouched.
518
843
  // Reuses the per-tab shared debugger: enables the Accessibility + DOM domains
519
844
  // into the same reference-counted _cdpTabs entry as Network/Runtime, so teardown
@@ -544,7 +869,22 @@ class PageManager {
544
869
  15000, 'get_tree(ax).attachHrefs'
545
870
  );
546
871
  }
547
- return await this._finishAxTree(main, view, tid, opts);
872
+ const out = await this._finishAxTree(main, view, tid, opts);
873
+ // AX 树的 context 只有 modals/session/stats —— 登录墙、校验错误、必填提示
874
+ // 都在 DOM 扫描里。此前 ax:true 的调用方拿到的是一份"更薄"的上下文:
875
+ // 同一个页面,默认读法说"这是登录页",ax 读法什么都不说。这里把 DOM 侧
876
+ // 缺的字段补齐(不覆盖 AX 已有的),两条读法的 context 才可比。
877
+ if (out && out.context) {
878
+ try {
879
+ const ctx = await this._readContext(view);
880
+ for (const k of ['auth_wall', 'validation_errors', 'field_errors', 'required_hints', 'errors', 'hints', 'forms', 'cross_process_frames']) {
881
+ if (out.context[k] == null && ctx[k] != null) out.context[k] = ctx[k];
882
+ }
883
+ 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 });
885
+ } catch (e) { /* context 补全失败不影响树 */ }
886
+ }
887
+ return out;
548
888
  } catch (e) {
549
889
  if (e && e.__deadline) {
550
890
  return { tree: null, context: null, error: e.message + ' — the page main thread is likely blocked' };
@@ -562,6 +902,10 @@ class PageManager {
562
902
  const frames = await this._readOopifFrames(view, tid);
563
903
  if (frames.length && main && main.tree) {
564
904
  main.tree = axExtractor.mergeFrameTrees(main.tree, frames);
905
+ if (main.context) {
906
+ const mergedCtx = frameProbe.mergeFrameContext(main.context, frames);
907
+ if (mergedCtx) Object.assign(main.context, mergedCtx);
908
+ }
565
909
  }
566
910
  // #4: incremental diff — return only newly appeared / hidden interactive
567
911
  // nodes since the last ax read on this tab (feeds: small payload per scroll).
@@ -597,6 +941,34 @@ class PageManager {
597
941
  return out;
598
942
  }
599
943
 
944
+ // 子帧侧的上下文探测(在子帧自己的 JS 上下文里执行)。只做两件读操作:
945
+ // 收集"请输入/必填"类占位符,以及 aria-invalid 字段对应的错误文案。
946
+ // 规则与顶层 extractor 的 scanRequiredHints / validation_errors 同源 ——
947
+ // 两边给出不同口径的提示,等于让调用方自己判断信谁。
948
+ _frameCtxJs() {
949
+ return `(function(){
950
+ var hints=[], ves=[];
951
+ try{
952
+ var els=document.querySelectorAll('input[placeholder],textarea[placeholder],[contenteditable][data-placeholder]');
953
+ for(var i=0;i<els.length&&hints.length<10;i++){
954
+ var p=els[i].getAttribute('placeholder')||els[i].getAttribute('data-placeholder');
955
+ if(p&&/请\\s*(输入|填写|选择)|必填|必选|不能为空/.test(p)&&hints.indexOf(p)<0)hints.push(p.slice(0,50));
956
+ }
957
+ }catch(e){}
958
+ try{
959
+ var inv=document.querySelectorAll('[aria-invalid="true"]');
960
+ for(var j=0;j<inv.length&&ves.length<10;j++){
961
+ var el=inv[j],t='';
962
+ var ids=(el.getAttribute('aria-describedby')||'').split(/\\s+/);
963
+ for(var k=0;k<ids.length;k++){var d=ids[k]&&document.getElementById(ids[k]);if(d){t=(d.textContent||'').trim();if(t)break;}}
964
+ if(!t){var par=el.parentElement;if(par){var e=par.querySelector('[class*=error],[class*=err],[role=alert]');if(e)t=(e.textContent||'').trim();}}
965
+ ves.push({text:t.slice(0,120),near:el.id||el.name||el.getAttribute('aria-label')||el.getAttribute('placeholder')||''});
966
+ }
967
+ }catch(e){}
968
+ return JSON.stringify({required_hints:hints,validation_errors:ves});
969
+ })()`;
970
+ }
971
+
600
972
  // Read editable/interactive fields out of each out-of-process sub-frame via
601
973
  // Frame.executeJavaScript (runs in that frame's own context). Returns
602
974
  // [{ key, label, tree }] — pure mergeFrameTrees input. Stamps idempotent
@@ -654,6 +1026,16 @@ class PageManager {
654
1026
  return out.length?out:null;
655
1027
  })(${JSON.stringify(prefix)})`);
656
1028
  if (!dto || !dto.length) continue;
1029
+ // 子帧里的"该填什么 / 错在哪"。顶层 extractPageContext 只能看顶层文档,
1030
+ // 而 B站图文的标题框、掘金编辑器都住在 OOPIF 里 —— 上下文扫描不跟着
1031
+ // 走一遍子帧,required_hints / validation_errors 就会在**真正需要它们
1032
+ // 的地方**恰好是空的(实测:B站标题框 placeholder="请输入标题",顶层扫描
1033
+ // 结果为空)。失败不影响建树。
1034
+ let frameCtx = null;
1035
+ try {
1036
+ const rawCtx = await frame.executeJavaScript(this._frameCtxJs());
1037
+ frameCtx = typeof rawCtx === 'string' ? JSON.parse(rawCtx) : rawCtx;
1038
+ } catch (e) { /* 子帧上下文探测失败:树照常返回 */ }
657
1039
  const tree = {
658
1040
  id: key,
659
1041
  role: 'generic',
@@ -672,7 +1054,7 @@ class PageManager {
672
1054
  bounds: n.bounds,
673
1055
  })),
674
1056
  };
675
- out.push({ key, label: frame.name || ('frame-' + frameSeq), tree });
1057
+ out.push({ key, label: frame.name || ('frame-' + frameSeq), tree, context: frameCtx });
676
1058
  } catch (e) { /* skip unreachable frame */ }
677
1059
  }
678
1060
  return out;
@@ -701,23 +1083,14 @@ class PageManager {
701
1083
  const view = this._getView(tid);
702
1084
  if (!view) return { revealed: [], hidden: [], mode: 'hover', error: 'Tab not found' };
703
1085
  if (!target) return { revealed: [], hidden: [], mode: 'hover', error: 'No target' };
704
- // 取消上一次 peek 挂起的自动回撤。回撤是"把鼠标挪走"的延迟动作,若前一次的
705
- // 定时器还在,它会在本次 peek 已经把鼠标放到新目标上之后才触发,把刚刚探出来的
706
- // 菜单又关掉 —— 表现为 revertMs=0(语义:别回撤)依然失效。谁后动手谁说了算。
707
- const staleRevert = this._revertTimers.get(tid);
708
- if (staleRevert) { clearTimeout(staleRevert); this._revertTimers.delete(tid); }
709
1086
  const out = { revealed: [], hidden: [], mode: 'hover' };
710
1087
  try {
711
- this._ensureCdp(tid, view, 'Accessibility');
712
- this._ensureCdp(tid, view, 'DOM');
1088
+ this._ensureCdp(tid, view, 'Page'); // 只为挂上 debugger(Input 域没有 enable)
713
1089
  } catch (e) { return { ...out, error: 'cdp-unavailable' }; }
714
1090
  const dbg = view.webContents.debugger;
715
1091
  try {
716
- const beforeRaw = await axExtractor.axRaw(dbg);
717
- if (!beforeRaw.length) return out;
718
- await axExtractor.ensureHandles(dbg); // give the target a stable append id
719
1092
  const center = await this.evaluate(
720
- `(function(){var el=document.querySelector('[data-ai-id="${target}"]');if(!el)return null;` +
1093
+ `(function(){var el=${findRefExpr(JSON.stringify(target))};if(!el)return null;` +
721
1094
  `el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
722
1095
  `var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`,
723
1096
  tid);
@@ -727,31 +1100,71 @@ class PageManager {
727
1100
  if (!center) {
728
1101
  return { ...out, error: 'target_not_found: no element with data-ai-id=' + String(target) };
729
1102
  }
1103
+ const send = (method, params) => dbg.sendCommand(method, params);
1104
+ // 区分"没传"与"显式传 0":0 是合法语义(hoverMs=0 不等、revertMs=0 不回撤)。
1105
+ // 原来的 `Number(x) || 450` 把 0 当 falsy 顶成默认值,revertMs=0 永远失效。
1106
+ const msOr = (v, dflt) => (v === undefined || v === null || v === '') ? dflt : Math.max(0, Number(v) || 0);
730
1107
  try {
731
1108
  // 抢焦点会打断人正在做的事(agent 干活时人几乎不能用电脑)。
732
1109
  // 策略见 _focusWindow:默认只保证窗口可见,不把它拽到最前、不抢焦点。
733
1110
  this._focusWindow(view);
734
1111
  } catch (e) {}
735
- const send = (method, params) => dbg.sendCommand(method, params);
1112
+ //
1113
+ // 顺序是「先悬停拍一张,再挪开拍一张」,不是反过来。
1114
+ //
1115
+ // 反过来(先挪开落基线、再悬停)要求基线是"干净的",也就是鼠标必须真的已经
1116
+ // 离开目标、且菜单已经收完。这条做不到:菜单是带动画收起的,等多久取决于
1117
+ // 站点 —— 实测 B站 头部「收藏」的下拉,鼠标挪开后 3 轮分别要 522 / 496 /
1118
+ // 491ms 才从 DOM 里消失。写死等待时长不是 150ms 只有 2/4 命中,就是 550ms
1119
+ // 也照样会漏(都试过)。
1120
+ //
1121
+ // 更麻烦的是"判断鼠标是否还在目标上"这一步本身也不可靠(el.matches(':hover')
1122
+ // 在连续调用时给出的结果会跳),于是连"要不要等"都判断错。
1123
+ //
1124
+ // 换成先悬停再挪开,两张快照都是自己亲手造成的,与调用前的鼠标位置无关:
1125
+ // A = 悬停着(菜单在) B = 挪开后(菜单不在)
1126
+ // revealed = A \ B → "悬停带来的东西",无论鼠标一开始在哪。
736
1127
  await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: center.x, y: center.y });
737
- // 区分"没传"与"显式传 0":0 是合法语义(hoverMs=0 不等、revertMs=0 不回撤)。
738
- // 原来的 `Number(x) || 450` 把 0 当 falsy 顶成默认值,revertMs=0 永远失效。
739
- const msOr = (v, dflt) => (v === undefined || v === null || v === '') ? dflt : Math.max(0, Number(v) || 0);
740
1128
  const hoverMs = msOr(opts.hoverMs, 450);
741
1129
  await new Promise((r) => setTimeout(r, hoverMs));
742
- const afterRaw = await axExtractor.axRaw(dbg);
743
- const diff = axExtractor.diffAx(beforeRaw, afterRaw);
1130
+ const snapWith = await this.getTree(false, tid);
1131
+ // 取不到快照就必须报错,不能拿空数组当基线 —— 那样另一张里的每一个节点都会
1132
+ // 变成"新出现的",一屏假结果比报错危险得多。
1133
+ if (!snapWith || !snapWith.tree) {
1134
+ return { ...out, error: 'snapshot_failed: 拿不到悬停时的语义树' };
1135
+ }
1136
+ const nodesWith = treeDiff.flattenTree(snapWith.tree);
1137
+ // 挪开鼠标后要等菜单真的收完,再拍第二张。
1138
+ //
1139
+ // 试过"轮询元素总数直到不再减少"来适配不同站点,行不通:页面自己一直在抖
1140
+ // (B站 首页有轮播与懒加载,元素总数常态波动),"稳定"永远达不到,每回都
1141
+ // 跑满 12 次轮询(1080ms)。所以回到固定时长 —— 但 600 这个数是实测出来的:
1142
+ // 轮询 B站 头部「收藏」下拉的收起耗时,3 轮分别是 491 / 496 / 522ms。
1143
+ // 想换更短的等待可以传 opts.settleMs。
1144
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 4, y: 4 });
1145
+ await new Promise((r) => setTimeout(r, msOr(opts.settleMs, 600)));
1146
+ const snapWithout = await this.getTree(false, tid);
1147
+ if (!snapWithout || !snapWithout.tree) {
1148
+ return { ...out, error: 'snapshot_failed: 拿不到挪开鼠标后的语义树' };
1149
+ }
1150
+ const nodesWithout = treeDiff.flattenTree(snapWithout.tree);
1151
+ const diff = treeDiff.diffTree(nodesWithout, nodesWith);
744
1152
  out.revealed = diff.revealed;
745
1153
  out.hidden = diff.hidden;
746
- // revertMs=0 表示"别回撤,我要接着点" —— 只有显式传 0 才不该回撤,
747
- // 未传则沿用 1500ms 默认(同上,0 不再是 falsy)。
1154
+ // revertMs=0 表示"别回撤,我要接着点" —— 这时把鼠标放回目标上,让菜单重新
1155
+ // 展开,调用方下一步可以直接点 revealed 里的东西。其余情况鼠标已经挪开了,
1156
+ // 天然就是"回撤",不需要延迟定时器(原先那套 per-tab 定时器也因此删掉了)。
1157
+ //
1158
+ // 移回之后必须重新拍一张并把 id 重绑:菜单重开时 DOM 是新造的节点,id 全变
1159
+ // 了,直接返回刚才那批 id 的话,调用方点下去只会拿到 target_not_found。
748
1160
  const revertMs = msOr(opts.revertMs, 1500);
749
- if (revertMs > 0) {
750
- const h = setTimeout(() => {
751
- this._revertTimers.delete(tid);
752
- try { send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 4, y: 4 }); } catch (e) {}
753
- }, revertMs);
754
- this._revertTimers.set(tid, h);
1161
+ if (revertMs === 0) {
1162
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: center.x, y: center.y });
1163
+ await new Promise((r) => setTimeout(r, msOr(opts.reopenMs, 350)));
1164
+ const snapBack = await this.getTree(false, tid);
1165
+ if (snapBack && snapBack.tree) {
1166
+ out.revealed = treeDiff.remapIds(out.revealed, treeDiff.flattenTree(snapBack.tree));
1167
+ }
755
1168
  }
756
1169
  } catch (e) {
757
1170
  out.error = String((e && e.message) || e).slice(0, 200);
@@ -811,6 +1224,17 @@ class PageManager {
811
1224
  if (!target && !targetOptional) return { success: false, error: 'No target' };
812
1225
  target = target || '';
813
1226
 
1227
+ // target 形状体检:传了可见文字("发布")而不是 data-ai-id 时,这里就明说。
1228
+ // 否则要等到 `[data-ai-id="发布"]` 查空,回一句不带方向的
1229
+ // "Target not found",调用方很难想到真正原因是形状错了。
1230
+ if (target) {
1231
+ // upload 额外接受 CSS 选择器:上传控件经常没有 data-ai-id(纯 div 的
1232
+ // 「本地上传」),只能靠选择器定位。其它动作仍只认 ref —— 放行一个
1233
+ // 它们解析不了的选择器,只会把失败推到更远、信息更少的地方。
1234
+ const shapeErr = refGuard.refShapeError(target, { allowSelector: action === 'upload' });
1235
+ if (shapeErr) return { success: false, error: shapeErr, method: 'bad-target-shape' };
1236
+ }
1237
+
814
1238
  // 只读分支:取 node.value 的完整内容(树里只给前 200 code point)。
815
1239
  // 不聚焦、不触发 input/change、不改页面,也不走输入动作的 fallback。
816
1240
  if (action === 'get_value') return await this._getValue(view, target, params, tid);
@@ -860,14 +1284,29 @@ class PageManager {
860
1284
  );
861
1285
  if (r) return r;
862
1286
  } catch (e) { /* fall through to preload */ }
1287
+ } else if (action === 'paste') {
1288
+ // 富文本粘贴:把 text/html 经合成 ClipboardEvent 送进编辑器,Draft.js /
1289
+ // ProseMirror / Quill 的粘贴处理器会完成格式转换(与真人粘贴等效)。
1290
+ // 支持 OOPIF 子帧(B站 read-editor 那种 ui.evaluate 到不了的上下文)。
1291
+ try {
1292
+ await this._ensureAxHandles(view, tid);
1293
+ const r = await this._pasteViaCdp(view, target, params, tid);
1294
+ if (r) return r;
1295
+ } catch (e) { /* fall through to preload */ }
863
1296
  } else if (action === 'upload') {
864
1297
  try {
865
1298
  await this._ensureAxHandles(view, tid);
866
1299
  const r = await this._uploadViaCdp(view, target, params.file || '', tid);
1300
+ if (r && r.success !== false) return r;
1301
+ } catch (e) { /* fall through */ }
1302
+ // CDP 的 DOM.setFileInputFiles 只能写顶层文档里的 input;跨进程 iframe 里的
1303
+ // 上传控件(B站封面、编辑器插图等都属于这一类)必须走帧内注入。
1304
+ try {
1305
+ const r = await this._uploadViaFrameJs(view, target, params.file || '', tid);
867
1306
  if (r) return r;
868
- } catch (e) { /* fall through to preload */ }
1307
+ } catch (e) { /* fall through */ }
869
1308
  // 无 CDP 时 preload 无法真正赋值文件(受安全限制),返回失败让 agent 感知。
870
- return { success: false, error: 'Upload requires CDP (input file set) — no file input found' };
1309
+ return { success: false, error: 'Upload requires CDP (input file set) — no file input found for target ' + JSON.stringify(String(target || '')) };
871
1310
  } else if (action === 'drag') {
872
1311
  try {
873
1312
  const r = await this._cdpDrag(view, target, params, tid);
@@ -920,7 +1359,7 @@ class PageManager {
920
1359
  // 通过 target 内容跳出字符串。
921
1360
  const js =
922
1361
  "(function(){"
923
- + "var el=document.querySelector('[data-ai-id=' + " + JSON.stringify(String(target)) + " + ']');"
1362
+ + "var el=" + findRefExpr(JSON.stringify(String(target))) + ";"
924
1363
  + "if(!el)return {__miss:true};"
925
1364
  + "var tag=(el.tagName||'').toUpperCase();"
926
1365
  + "if(tag==='INPUT'){var ty=String(el.type||'').toLowerCase();"
@@ -1026,21 +1465,40 @@ class PageManager {
1026
1465
  return result?.tree?.focused_element_id || null;
1027
1466
  }
1028
1467
 
1029
- async evaluate(js, tabId) {
1468
+ async evaluate(js, tabId, opts = {}) {
1030
1469
  const tid = tabId !== undefined ? tabId : this.activeTab;
1031
1470
  const view = this._getView(tid);
1032
1471
  if (!view) throw new Error('Tab not found');
1033
1472
 
1034
- // OOPIF routing: if the JS targets a sub-frame `data-ai-id` (`axf-...`), run
1035
- // it inside that frame's own context via Frame.executeJavaScript — the
1036
- // top-frame CDP Runtime.evaluate cannot see OOPIF (cross-process iframe)
1037
- // DOM, so probes/`ui.evaluate` would otherwise read `null` back.
1038
- const m = /data-ai-id="(axf-\d+-\d+)"/.exec(js);
1039
- if (m) {
1040
- const frame = await this._owningFrame(view, m[1]);
1473
+ // OOPIF routing. Two ways in, explicit beats implicit:
1474
+ // opts.frameRef — caller passes the axf-... ref directly. Reliable:
1475
+ // works no matter how the JS string was built.
1476
+ // literal match — the old behavior: /data-ai-id="(axf-...)/ inside the
1477
+ // JS source. Kept for compatibility, but it is a trap: a caller that
1478
+ // builds the selector by concatenation ('[data-ai-id="' + ref + '"]')
1479
+ // produces no literal, routing silently does not happen, and the
1480
+ // top-frame evaluate returns null for an element that exists.
1481
+ // Hit for real during the P1-2 rollout.
1482
+ const frameRef = opts.frameRef || (/data-ai-id="(axf-\d+-\d+)"/.exec(js) || [])[1];
1483
+ if (frameRef) {
1484
+ const frame = await this._owningFrame(view, frameRef);
1041
1485
  if (frame) {
1042
1486
  try { return await frame.executeJavaScript(js.startsWith('(') ? js : `(function(){return ${js};})()`); }
1043
- catch (e) { /* fall through to top-frame path */ }
1487
+ catch (e) {
1488
+ // 帧找到了、但脚本自己抛了异常。不能继续走下面的 frame_not_found ——
1489
+ // 那会把"脚本报错"误报成"引用失效",把调用方引到"反复重读树"的死路上。
1490
+ // 实测踩过:上传脚本里一个 TypeError 被包装成 frame_not_found,白查了半天。
1491
+ if (opts.frameRef) {
1492
+ throw new Error('frame_eval_failed: ' + String((e && e.message) || e).slice(0, 300));
1493
+ }
1494
+ /* 自动推导出的 ref:允许回落到顶层路径 */
1495
+ }
1496
+ }
1497
+ // 显式给了 frameRef 却找不到帧:不能静默落到顶层 —— 调用方会拿到 null
1498
+ // 并以为"元素不存在"。明说是路由失败。
1499
+ if (opts.frameRef) {
1500
+ throw new Error('frame_not_found: no cross-process frame owns ' + String(opts.frameRef)
1501
+ + ' — the ref may be stale (page re-rendered). Re-read the tree and retry.');
1044
1502
  }
1045
1503
  }
1046
1504
  // Preferred path: CDP Runtime.evaluate. It runs on the DevTools protocol
@@ -1071,7 +1529,13 @@ class PageManager {
1071
1529
  const cmd = dbg.sendCommand('Runtime.evaluate', {
1072
1530
  expression: js,
1073
1531
  returnByValue: true,
1074
- awaitPromise: false,
1532
+ // awaitPromise:true —— 调用方在页面里写 async/await 是常态(等一个请求、
1533
+ // 等一帧渲染、等编辑器提交),此前是 false,于是 async IIFE 原样返回
1534
+ // 一个 Promise 对象,序列化后调用方看到 **{}**,会以为"页面什么都没
1535
+ // 返回"。实测症状:往掘金标签框写"已选中"的探测脚本明明 await 了网络
1536
+ // 响应,回执却是空对象,据此得出"标签没选上"的错误结论。
1537
+ // 对非 thenable 表达式此参数无副作用(CDP 直接返回值本身)。
1538
+ awaitPromise: true,
1075
1539
  });
1076
1540
  const timeout = new Promise((_, rej) =>
1077
1541
  setTimeout(() => rej(new Error('cdp evaluate timeout')), 5000));
@@ -1113,7 +1577,13 @@ class PageManager {
1113
1577
  } catch(e) { return false; }
1114
1578
  }
1115
1579
 
1116
- async _cdpKey(inputCmd, p) {
1580
+ async _cdpKey(inputCmd, p, opts) {
1581
+ // 「按键的默认动作」要的是 keypress,不是 keydown。决策本身抽在
1582
+ // shared/key_dispatch.cjs(纯函数,可单测),这里只负责按计划派发。
1583
+ // 两种语义:
1584
+ // opts.activate = true (ui.act press 用)→ 带 text 的 keyDown,产生默认动作
1585
+ // 否则(type 里的段间换行/删除)→ 保持 rawKeyDown,行为不变
1586
+ const plan = keyDispatchPlan(p, opts);
1117
1587
  const base = {
1118
1588
  windowsVirtualKeyCode: p.vk,
1119
1589
  nativeVirtualKeyCode: p.vk,
@@ -1122,11 +1592,16 @@ class PageManager {
1122
1592
  text: p.text || '',
1123
1593
  modifiers: p.mod || 0,
1124
1594
  };
1125
- await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'rawKeyDown' });
1126
- // CDP 的 rawKeyDown 会忽略 text —— 带字符的键(换行)必须再发一个带
1127
- // text 的 char 事件才会真的把字符插进去,否则 Enter 在 textarea /
1128
- // 原生 contenteditable 里不产生任何换行("a\nb\nc" 静默变成 "abc")。
1129
- if (base.text) await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'char' });
1595
+ if (plan.type === 'keyDown') {
1596
+ // keyDown 带 text:一次派发同时产出 keydown 与 char,等价真实按键。
1597
+ await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'keyDown', text: plan.text });
1598
+ } else {
1599
+ await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'rawKeyDown' });
1600
+ // CDP 的 rawKeyDown 会忽略 text —— 带字符的键(换行)必须再发一个带
1601
+ // text 的 char 事件才会真的把字符插进去,否则 Enter 在 textarea /
1602
+ // 原生 contenteditable 里不产生任何换行("a\nb\nc" 静默变成 "abc")。
1603
+ if (plan.charText) await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'char' });
1604
+ }
1130
1605
  await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
1131
1606
  }
1132
1607
 
@@ -1140,7 +1615,7 @@ class PageManager {
1140
1615
  if (/^axf-/.test(aiId)) return await this._owningFrame(view, aiId);
1141
1616
  try {
1142
1617
  const inTop = await this.evaluate(
1143
- `!!document.querySelector('[data-ai-id="${aiId}"]')`,
1618
+ `!!${findRefExpr(JSON.stringify(aiId))}`,
1144
1619
  tabId
1145
1620
  );
1146
1621
  if (inTop) return null;
@@ -1158,7 +1633,7 @@ class PageManager {
1158
1633
  if (frame === root) continue;
1159
1634
  try {
1160
1635
  const has = await frame.executeJavaScript(
1161
- `!!document.querySelector('[data-ai-id="${aiId}"]')`
1636
+ `!!${findRefExpr(JSON.stringify(aiId))}`
1162
1637
  );
1163
1638
  if (has) return frame;
1164
1639
  } catch (e) { /* skip */ }
@@ -1227,7 +1702,7 @@ class PageManager {
1227
1702
  } else {
1228
1703
  try {
1229
1704
  const ok = await owning.executeJavaScript(
1230
- `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return false;` +
1705
+ `(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
1231
1706
  `el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
1232
1707
  `el.focus();el.click();return true;})()`);
1233
1708
  return ok ? 'oopif-click' : null;
@@ -1237,11 +1712,11 @@ class PageManager {
1237
1712
  // 定位 → 滚入视口居中 → 返回滚动后的中心点(frame 内局部坐标)。
1238
1713
  const center = inFrame
1239
1714
  ? await owning.executeJavaScript(
1240
- `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return null;` +
1715
+ `(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
1241
1716
  `el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
1242
1717
  `var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`)
1243
1718
  : await this.evaluate(
1244
- `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return null;` +
1719
+ `(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
1245
1720
  `el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
1246
1721
  `var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`,
1247
1722
  tabId);
@@ -1262,18 +1737,19 @@ class PageManager {
1262
1737
  // 命中校验必须在派发【之前】做,且派发成功就不再回退 —— 否则一次 act 会点两次。
1263
1738
  // 事后校验的致命漏洞:mousePressed/mouseReleased 一发出,点击就已生效;而"点了
1264
1739
  // 之后自身消失/收起"的目标(话题候选收起列表、提交后置灰、菜单项、@提及、点赞)
1265
- // 此刻已经被自己点没了,elementFromPoint 必然命中不到 → 返回 null → executeAction
1740
+ // 此刻已经被自己点没了,命中探测必然拿不到 → 返回 null → executeAction
1266
1741
  // 回退 preload 再点一次 → DOM 上出现两份插入/两次提交。派发前的坐标命中才是
1267
1742
  // "这一下会不会落在目标上"的唯一可信判断;校验失败(被遮挡/屏外/已隐藏)才回退。
1743
+ //
1744
+ // 命中探测必须穿透 shadow:不穿透的话,影子内部的元素(B站 整块评论区装在
1745
+ // <bili-comments> 里)永远返回宿主 → contains 判定 false → 每次点击都静默
1746
+ // 降级成非受信的 el.click()。降级本身不报错,所以表现为"能点但对某些编辑器
1747
+ // 没反应"。hitTestExpr 同时把 contains 换成含 shadow 的祖先判定。
1268
1748
  if (action === 'click') {
1749
+ const hitJs = hitTestExpr(JSON.stringify(aiId), String(center.x), String(center.y));
1269
1750
  const hit = inFrame
1270
- ? await owning.executeJavaScript(
1271
- `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return false;` +
1272
- `var h=document.elementFromPoint(${center.x},${center.y});return !!(h&&(h===el||el.contains(h)));})()`)
1273
- : await this.evaluate(
1274
- `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return false;` +
1275
- `var h=document.elementFromPoint(${center.x},${center.y});return !!(h&&(h===el||el.contains(h)));})()`,
1276
- tabId);
1751
+ ? await owning.executeJavaScript(hitJs)
1752
+ : await this.evaluate(hitJs, tabId);
1277
1753
  if (!hit) return null; // 坐标落不到目标上 —— 交给 preload 的 focus()+click 兜底
1278
1754
  }
1279
1755
  await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: vp.x, y: vp.y });
@@ -1310,8 +1786,10 @@ class PageManager {
1310
1786
  if (params.at && Number.isFinite(Number(params.at.x))) {
1311
1787
  at = { x: Math.round(Number(params.at.x)), y: Math.round(Number(params.at.y)) };
1312
1788
  } else if (aiId) {
1789
+ // 穿透 shadow:目标在 Web Component 里时 document.querySelector 够不着,
1790
+ // 落点会静默退化成视口中心 —— 滚轮打到完全无关的地方,还报成功。
1313
1791
  const c = await run(
1314
- `var el=document.querySelector(${JSON.stringify(`[data-ai-id="${aiId}"]`)});if(!el)return null;` +
1792
+ `var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
1315
1793
  `el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
1316
1794
  `var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};`
1317
1795
  );
@@ -1385,9 +1863,12 @@ class PageManager {
1385
1863
  // 连接点识别用 classList 逐 token 词边界匹配 —— 上一版用
1386
1864
  // `[class*="anchor"]` 会把 `is-node-anchored` 这类无关类名当成连接点
1387
1865
  // (实测抢到了"标签"按钮),拖拽起点完全错位。
1388
- const pointFor = async (sel, anchor, offset) => {
1866
+ // 传 aiId 而不是选择器:目标可能在 shadow 里,`document.querySelector` 够不着,
1867
+ // 报出来是 "drag: start element not found",读起来像元素不存在。定位统一走
1868
+ // findRefExpr(穿透影子根),与点击路径口径一致。
1869
+ const pointFor = async (aiId, anchor, offset) => {
1389
1870
  const body =
1390
- `var el=document.querySelector(${JSON.stringify(sel)});if(!el)return null;` +
1871
+ `var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
1391
1872
  `var r=el.getBoundingClientRect();var side=${JSON.stringify(anchor)};` +
1392
1873
  `var cx=r.x+r.width/2,cy=r.y+r.height/2;` +
1393
1874
  `function isHandle(h){var cl=h.classList;for(var i=0;i<cl.length;i++){var t=cl[i];` +
@@ -1417,8 +1898,13 @@ class PageManager {
1417
1898
  // 判定必须是"命中即是连接点,或连接点的子孙"。**不能**接受
1418
1899
  // `hit.contains(handle)`:节点本体当然包含自己的连接点,那样会把这个
1419
1900
  // 失败情形原样放行(第一版就是这么写的,实测仍然报 handle)。
1420
- `if(best){var hit=document.elementFromPoint(best.x,best.y);` +
1421
- `if(!(hit&&(hit===best.el||best.el.contains(hit))))best=null;}` +
1901
+ // 命中探测同样要穿透 shadow(连接点在 Web Component 里时,不穿透就
1902
+ // 恒判"没命中",于是永远回退到 via:'edge',把"按在连接点上"降级成
1903
+ // "按在节点边沿")。跨 shadow 后用含 shadow 的祖先判定。
1904
+ `if(best){var hit=${deepHitExpr('best.x', 'best.y')};` +
1905
+ `var ok=false,c=hit,g=0;` +
1906
+ `while(c&&g++<128){if(c===best.el){ok=true;break;}c=c.parentNode||c.host||null;}` +
1907
+ `if(!ok)best=null;}` +
1422
1908
  `if(best){delete best.el;return best;}` +
1423
1909
  `if(side==='right')return {x:r.right,y:r.y+r.height/2,via:'edge'};` +
1424
1910
  `if(side==='left')return {x:r.left,y:r.y+r.height/2,via:'edge'};` +
@@ -1436,19 +1922,21 @@ class PageManager {
1436
1922
  // 同时向上找最近的 data-ai-id:如果它**不是**本次目标,说明抓错了节点
1437
1923
  // (实测踩过:两个节点视觉重叠时,拖 A 实际按在叠在上面的 B 的浮层上,
1438
1924
  // 事件照常派发、success 照常返回,但什么都没动)。
1925
+ //
1926
+ // 穿透 shadow + 含 shadow 的上溯,理由同上。原来那版用 e.closest('[data-ai-id]'):
1927
+ // closest 走到影子树顶就停,影子内部的元素永远得到 id:null,于是 `is_target`
1928
+ // 恒为 true —— "抓错节点"的检测被静默跳过,正是它存在的意义所在。
1439
1929
  const atPointJs = (p, selfId) =>
1440
- `var e=document.elementFromPoint(${p.x},${p.y});if(!e)return null;` +
1441
- `var anc=e.closest?e.closest('[data-ai-id]'):null;` +
1442
- `var id=anc?anc.getAttribute('data-ai-id'):null;` +
1443
- `return {el:(e.tagName||'')+' '+(typeof e.className==='string'?e.className:'').slice(0,60),` +
1444
- `id:id,is_target:(id===null||id===${JSON.stringify(String(selfId))})};`;
1930
+ `var info=${hitInfoExpr(String(p.x), String(p.y))};if(!info)return null;` +
1931
+ `return {el:info.el,id:info.id,` +
1932
+ `is_target:(info.id===null||info.id===${JSON.stringify(String(selfId))})};`;
1445
1933
 
1446
1934
  // 起点
1447
1935
  let from = null;
1448
1936
  if (params.from && Number.isFinite(Number(params.from.x))) {
1449
1937
  from = { x: Math.round(Number(params.from.x)), y: Math.round(Number(params.from.y)), via: 'explicit' };
1450
1938
  } else if (aiId) {
1451
- from = await pointFor(`[data-ai-id="${aiId}"]`, anchorOf(params.from_anchor), params.from_offset);
1939
+ from = await pointFor(aiId, anchorOf(params.from_anchor), params.from_offset);
1452
1940
  }
1453
1941
  if (!from) return { success: false, error: 'drag: start element not found: ' + aiId };
1454
1942
 
@@ -1466,7 +1954,7 @@ class PageManager {
1466
1954
  if (explicitTo && Number.isFinite(Number(explicitTo.x)) && Number.isFinite(Number(explicitTo.y))) {
1467
1955
  to = { x: Math.round(Number(explicitTo.x)), y: Math.round(Number(explicitTo.y)), via: 'explicit' };
1468
1956
  } else if (params.to_target) {
1469
- to = await pointFor(`[data-ai-id="${params.to_target}"]`, anchorOf(params.to_anchor), params.to_offset);
1957
+ to = await pointFor(params.to_target, anchorOf(params.to_anchor), params.to_offset);
1470
1958
  if (!to) return { success: false, error: 'drag: to_target not found: ' + params.to_target };
1471
1959
  } else if (Number.isFinite(Number(params.dx)) || Number.isFinite(Number(params.dy))) {
1472
1960
  to = { x: Math.round(from.x + Number(params.dx || 0)), y: Math.round(from.y + Number(params.dy || 0)), via: 'delta' };
@@ -1573,7 +2061,10 @@ class PageManager {
1573
2061
  this._focusWindow(view);
1574
2062
  } catch (e) {}
1575
2063
  if (aiId) {
1576
- await run(`var el=document.querySelector(${JSON.stringify('[data-ai-id="' + String(aiId) + '"]')});if(el&&el.focus)el.focus();return true;`);
2064
+ // 穿透 shadow:目标在 Web Component 里时 querySelector 拿不到 el,
2065
+ // focus 这一步被静默跳过 —— 按键发给"当前焦点"(多半是 body),
2066
+ // 现象是快捷键完全没生效,但回执照常 success。
2067
+ await run(`var el=${findRefExpr(JSON.stringify(String(aiId)))};if(el&&el.focus)el.focus();return true;`);
1577
2068
  }
1578
2069
  const list = Array.isArray(params.keys) && params.keys.length
1579
2070
  ? params.keys
@@ -1584,13 +2075,113 @@ class PageManager {
1584
2075
  for (const name of list) {
1585
2076
  const spec = keySpec(name, params.modifiers);
1586
2077
  if (!spec) return { success: false, error: 'press: unsupported key: ' + String(name), pressed: done };
1587
- await this._cdpKey(send, spec);
2078
+ await this._cdpKey(send, spec, { activate: true });
1588
2079
  done.push(spec.key);
1589
2080
  await new Promise((r) => setTimeout(r, Math.max(20, Number(params.interval_ms) || 60)));
1590
2081
  }
1591
2082
  return { success: true, pressed_via: 'cdp-key', pressed: done };
1592
2083
  }
1593
2084
 
2085
+ // 富文本粘贴(ui.act {action:'paste'})—— 把带格式内容(HTML)送进编辑器的
2086
+ // 唯一可靠通路。
2087
+ //
2088
+ // 为什么需要这个动作而不是让调用方自己合成事件:
2089
+ // 1) setContent/type 走 CDP 受信键盘,只能送**纯文本** —— 标题层级、代码块、
2090
+ // 加粗、列表全丢失。B站图文那篇只能发纯文本版,就是这个原因。
2091
+ // 2) 在 OOPIF 子帧里(B站 read-editor),ui.evaluate 到不了那个执行上下文,
2092
+ // 调用方连"自己搓一个 paste 事件"都做不到 —— 这是最疼的一条。
2093
+ // 3) 粘贴是编辑器的**一等公民路径**:Draft.js 的 DraftPasteProcessor、
2094
+ // ProseMirror 的 transformPastedHTML、Quill 的 clipboard 模块都会把
2095
+ // text/html 转成各自的内部文档模型。合成一个带 clipboardData 的
2096
+ // ClipboardEvent('paste') 正是触发这条路径的正规方式,与真人粘贴等效。
2097
+ //
2098
+ // 参数契约:params.html(优先)或 params.text。两者都空 → 明确报错,
2099
+ // 不静默成功(同 cdp-input-empty-text 的教训)。
2100
+ async _pasteViaCdp(view, aiId, params, tabId) {
2101
+ if (!this._canCdp(view)) return null;
2102
+ const html = typeof params.html === 'string' ? params.html : '';
2103
+ const text = typeof params.text === 'string' ? params.text : '';
2104
+ if (!html && !text) {
2105
+ return {
2106
+ success: false,
2107
+ error: 'paste called with neither html nor text — nothing to paste. '
2108
+ + 'Pass params: {html: "<h2>...</h2>"} for rich text, or {text: "..."} for plain.',
2109
+ method: 'paste-empty',
2110
+ };
2111
+ }
2112
+ const owning = await this._owningFrameFor(view, aiId, tabId);
2113
+ const inFrame = !!owning;
2114
+ const run = async (jsBody) => {
2115
+ const fn = `(function(){${jsBody}})()`;
2116
+ return inFrame ? owning.executeJavaScript(fn) : this.evaluate(fn, tabId);
2117
+ };
2118
+ try { this._focusWindow(view); } catch (e) {}
2119
+
2120
+ // 定位 + 聚焦 + 派发**必须在同一次帧内执行里完成**。拆成两步(先 prep 聚焦、
2121
+ // 再回来派发)会丢目标:OOPIF 里 focus() 之后 document.activeElement 的
2122
+ // 读回可能仍是旧值,且 Draft.js 这类编辑器把 paste 监听挂在**最内层的
2123
+ // contenteditable 面子节点**上(.public-DraftEditor-content)——事件从容器
2124
+ // 派发不会向下传播到子节点,监听器收不到,defaultPrevented 恒为 false。
2125
+ // 实测:B站 read-editor 拆两步派发 → 编辑器完全无反应;合并 + 选定最深层
2126
+ // 可编辑元素后成功。
2127
+ const payload = JSON.stringify({ html, text });
2128
+ const res = await run(
2129
+ `var p=${payload};` +
2130
+ `var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return {ok:false,err:'no-ref'};` +
2131
+ // 选定真正的编辑面:el 自己可编辑就用 el;否则取内部第一个可编辑后代
2132
+ // (Draft.js/ProseMirror 的编辑面都是容器里的一个子 div)。
2133
+ `var ed=el;` +
2134
+ `var elEditable=(ed.contentEditable==='true')||(ed.tagName==='TEXTAREA')||(ed.tagName==='INPUT');` +
2135
+ `if(!elEditable&&ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
2136
+ `var editable=(ed.contentEditable==='true')||(ed.tagName==='TEXTAREA')||(ed.tagName==='INPUT');` +
2137
+ `if(!editable)return {ok:false,err:'not-editable'};` +
2138
+ `ed.focus();` +
2139
+ `var dt=new DataTransfer();` +
2140
+ `if(p.html)dt.setData('text/html',p.html);` +
2141
+ `dt.setData('text/plain',p.text||p.html.replace(/<[^>]+>/g,' ').replace(/&nbsp;/g,' ').replace(/\\s+/g,' ').trim());` +
2142
+ `var ev=new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true,composed:true});` +
2143
+ `var notPrevented=ed.dispatchEvent(ev);` +
2144
+ `return {ok:true,prevented:!notPrevented,tag:ed.tagName};`
2145
+ );
2146
+ if (!res || !res.ok) {
2147
+ return { success: false, error: (res && res.err) || 'paste failed', method: 'synthetic-clipboard-event' };
2148
+ }
2149
+ // 编辑器没接管粘贴(prevented=false)= 页面对合成粘贴不买账。如实报告,
2150
+ // 并给出可操作建议,而不是报 success 让调用方以为内容进去了。
2151
+ if (!res.prevented) {
2152
+ return {
2153
+ success: false,
2154
+ error: 'paste event was not consumed by the page (defaultPrevented=false) — this editor likely listens on a different surface (e.g. a child element or shadow root). Try pasting into a child node ref, or fall back to setContent for plain text.',
2155
+ method: 'synthetic-clipboard-event',
2156
+ consumed: false,
2157
+ };
2158
+ }
2159
+ // 落盘校验:读回编辑器内容。归一化沿用 P0-2 修过的口径(换行会被编辑器
2160
+ // 膨胀,逐字比对必然失配)。只给 html 时无法文本比对,退而检查"有内容"。
2161
+ await new Promise((r) => setTimeout(r, 350)); // 编辑器处理粘贴多为异步(React 提交)
2162
+ const landed = await run(`var e=${deepActiveExpr()};return {text:(e&&(e.innerText||e.value||''))||'',html:(e&&e.innerHTML||'')};`);
2163
+ const landedText = (landed && landed.text) || '';
2164
+ const landedLen = landedText.replace(/\s+/g, '').length;
2165
+ if (!landedLen) {
2166
+ return {
2167
+ success: false,
2168
+ error: 'paste was consumed but nothing landed — the editor may have rejected the payload (unsanitized HTML?). Read the editor content back to confirm.',
2169
+ method: 'synthetic-clipboard-event',
2170
+ consumed: true,
2171
+ landed_chars: 0,
2172
+ };
2173
+ }
2174
+ return {
2175
+ success: true,
2176
+ method: 'synthetic-clipboard-event',
2177
+ consumed: true,
2178
+ in_frame: inFrame,
2179
+ landed_chars: landedLen,
2180
+ // 只给 html 时调用方无法预知文本形态,把读回内容捎上,省一轮查询。
2181
+ ...(html && !text ? { landed_preview: landedText.slice(0, 200) } : {}),
2182
+ };
2183
+ }
2184
+
1594
2185
  // 文件上传(封面/附件等,跨框架通用):HTMLInputElement[type=file] 只能由
1595
2186
  // DevTools 的 DOM.setFileInputFiles 写入真实文件路径(网页脚本无法伪造
1596
2187
  // FileList,preload 同样受限),对任何站点/上传组件一视同仁。找到目标 input
@@ -1604,7 +2195,7 @@ class PageManager {
1604
2195
  // 用户在系统选择器选中);若再对它 click 会重新唤起系统文件框并清空 files,
1605
2196
  // 导致刚写入的状态被覆盖。由调用方负责先让上传组件就绪。
1606
2197
  const idRes = await dbg.sendCommand('Runtime.evaluate', {
1607
- expression: `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');return el&&el.__proto__&&el.matches?el.matches('input[type=file],input')?el:null:null;})()`,
2198
+ expression: `(function(){var el=${findRefExpr(JSON.stringify(aiId))};return el&&el.__proto__&&el.matches?el.matches('input[type=file],input')?el:null:null;})()`,
1608
2199
  returnByValue: false,
1609
2200
  });
1610
2201
  const objId = idRes && idRes.result && idRes.result.objectId;
@@ -1612,7 +2203,7 @@ class PageManager {
1612
2203
  await dbg.sendCommand('DOM.setFileInputFiles', { files: [filePath], objectId: objId });
1613
2204
  // 兜底校验:确认 files 已写入
1614
2205
  const ver = await dbg.sendCommand('Runtime.evaluate', {
1615
- expression: `(function(){var f=document.querySelector('[data-ai-id="${aiId}"]').files;return f&&f.length>0?{name:f[0].name,size:f[0].size}:null;})()`,
2206
+ expression: `(function(){var f=${findRefExpr(JSON.stringify(aiId))}.files;return f&&f.length>0?{name:f[0].name,size:f[0].size}:null;})()`,
1616
2207
  returnByValue: true,
1617
2208
  });
1618
2209
  const v = ver && ver.result && ver.result.value;
@@ -1620,6 +2211,189 @@ class PageManager {
1620
2211
  return { success: true, uploaded: v.name, size: v.size };
1621
2212
  }
1622
2213
 
2214
+ // 跨帧文件上传(含 OOPIF)。CDP 的 DOM.setFileInputFiles 需要顶层文档里的
2215
+ // nodeId/objectId,够不着 iframe 内部的 input —— B站封面上传、多数编辑器的
2216
+ // "插入图片"都在 iframe 里,实测只得出一条 "no file input found"。
2217
+ // 走到帧内:文件读成字节 → 在目标帧构造 File → DataTransfer 赋给 input.files
2218
+ // → 派发 change,与"用户在系统选择器里选中文件"等价。
2219
+ //
2220
+ // 三种状态的控件都要能对付(实测各站点轮流出现):
2221
+ // 1. 目标本身就是 input[type=file] —— 直接写;
2222
+ // 2. 目标是容器,input 预挂在里面 —— 取容器里的第一个 file input;
2223
+ // 3. 容器里**一个 input 都没有**(组件在"已上传"状态下把上传区整个卸载了,
2224
+ // CSDN 的封面就是:有封面时 .container-coverimage-box 里连
2225
+ // .el-upload__input 都不存在)—— 临时接管 HTMLInputElement.prototype.click
2226
+ // 再把容器点开,捕获"点击时才创建"的那个 input。
2227
+ // 第 3 种是 target 写成 `.box .el-upload__input` 这类"指向 input 本身的选择器"
2228
+ // 时最容易踩的:整条选择器查不到元素,但它的祖先还在。所以 target 先展开成
2229
+ // 由长到短的回退候选(selectorAncestors),用第一个命中的祖先当锚点。
2230
+ async _uploadViaFrameJs(view, aiId, filePath, tabId) {
2231
+ if (!aiId) return null;
2232
+ if (!filePath) throw new Error('upload requires a file path');
2233
+ // 上传控件经常没有语义节点(B站的「本地上传」就是个纯 div 菜单项,AX 树里
2234
+ // 根本没有它),此时调用方给不出 ref,只能给 CSS 选择器。所以 target 先按
2235
+ // data-ai-id 找,找不到再当作选择器:先找帧、再找顶层文档。
2236
+ const target = String(aiId);
2237
+ // 由长到短的候选:`.a .b` → ['.a .b', '.a']。data-ai-id 形状的 target
2238
+ // 走 selectorAncestors 会原样返回单元素,行为不变。
2239
+ const cands = refGuard.selectorAncestors(target);
2240
+ let owning = await this._owningFrameFor(view, aiId, tabId);
2241
+ let inTop = false;
2242
+ if (!owning) {
2243
+ const probe = `(function(){var C=${JSON.stringify(cands)};for(var i=0;i<C.length;i++){try{if(document.querySelector(C[i]))return true;}catch(e){}}return false;})()`;
2244
+ for (const frame of (view.webContents.mainFrame.framesInSubtree || [])) {
2245
+ if (frame === view.webContents.mainFrame) continue;
2246
+ try { if (await frame.executeJavaScript(probe)) { owning = frame; break; } }
2247
+ catch (e) { /* skip */ }
2248
+ }
2249
+ if (!owning) {
2250
+ try { inTop = !!(await this.evaluate(probe, tabId)); } catch (e) { inTop = false; }
2251
+ }
2252
+ }
2253
+ if (!owning && !inTop) {
2254
+ // 连锚点都找不到:明确说"选择器没命中任何元素",而不是笼统的
2255
+ // "no file input found"(后者会让人以为选择器命中了但里面没 input)。
2256
+ return {
2257
+ success: false,
2258
+ method: 'frame-file-inject',
2259
+ error: 'upload target not found: no element matches '
2260
+ + JSON.stringify(cands[0] || target) + (cands.length > 1 ? ' (nor its ancestors ' + JSON.stringify(cands.slice(1)) + ')' : '')
2261
+ + ' — pass the control that OPENS the file picker, or a CSS selector for it',
2262
+ candidates: cands,
2263
+ };
2264
+ }
2265
+ let buf;
2266
+ try { buf = fs.readFileSync(filePath); } catch (e) {
2267
+ return { success: false, method: 'frame-file-inject', error: 'cannot read file: ' + String((e && e.message) || e).slice(0, 160) };
2268
+ }
2269
+ const name = path.basename(filePath);
2270
+ const ext = name.toLowerCase().slice(name.lastIndexOf('.'));
2271
+ const mime = ext === '.png' ? 'image/png'
2272
+ : (ext === '.jpg' || ext === '.jpeg') ? 'image/jpeg'
2273
+ : ext === '.webp' ? 'image/webp'
2274
+ : ext === '.gif' ? 'image/gif' : 'application/octet-stream';
2275
+ // 内联 base64 直接走 frame.executeJavaScript —— 不受 WS 侧 evaluate 的
2276
+ // 5000 字符守卫约束(那个守卫只拦 ui.evaluate 的 js 参数)。
2277
+ const b64 = buf.toString('base64');
2278
+ const refSel = JSON.stringify('[data-ai-id="' + target + '"]');
2279
+ const js = `(function(){
2280
+ var C=${JSON.stringify(cands)};
2281
+ var REF=${refSel};
2282
+ var b64=${JSON.stringify(b64)};
2283
+ var name=${JSON.stringify(name)};
2284
+ var mime=${JSON.stringify(mime)};
2285
+ function isFileInput(e){return !!e&&e.tagName==='INPUT'&&e.type==='file';}
2286
+ function q(sel,root){try{return (root||document).querySelector(sel);}catch(e){return null;}}
2287
+ // 1) 先按 data-ai-id 找(ref 才是权威),再按选择器候选由长到短找。
2288
+ // 按 ref 找必须穿透 shadow:上传控件装在 Web Component 里时,顶层 querySelector
2289
+ // 拿不到,报出来是 "no-el" —— 看起来像没有上传入口,实际是查询路径够不着。
2290
+ var host=null,hostSel=null;
2291
+ var byRef=${findRefExpr('REF')};
2292
+ if(byRef){host=byRef;hostSel=REF;}
2293
+ for(var i=0;i<C.length&&!host;i++){var el=q(C[i]);if(el){host=el;hostSel=C[i];break;}}
2294
+ if(!host) return {ok:false,err:'no-el'};
2295
+ // <label for=...> 的点击会由浏览器直接激活目标 input、绕过 JS 层拦截 → 系统文件框
2296
+ // 会真的弹出来。所以遇到 label 先顺着 control 拿 input,拿不到就不点它。
2297
+ // 找后代同样要穿透 shadow(findDescExpr 会下钻 el.shadowRoot):宿主可能是
2298
+ // <bili-*> 之类,真正的 input[type=file] 在它自己的影子树里。
2299
+ function labelInput(el){try{var c=el.control;if(isFileInput(c))return c;var n=el.querySelector&&${findDescExpr('el', JSON.stringify('input[type=file]'))};if(n)return n;}catch(e){}return null;}
2300
+ function find(root){
2301
+ if(!root)return null;
2302
+ if(isFileInput(root))return root;
2303
+ if(root.tagName==='LABEL'){var li=labelInput(root);if(li)return li;}
2304
+ var r=null;try{r=${findDescExpr('root', JSON.stringify('input[type=file]'))};}catch(e){r=null;}
2305
+ return r;
2306
+ }
2307
+ var inp=find(host),via='direct';
2308
+ // 2) 点击捕获:接管原型 click(元素的 .click() 会走到这里),挡住系统框,
2309
+ // 同时暂时让 showOpenFilePicker 挂起,避免 File System Access API 那条路漏网。
2310
+ function captureOnce(t){
2311
+ if(!t)return null;
2312
+ var cap=null,proto=HTMLInputElement.prototype,orig=proto.click,origPicker=window.showOpenFilePicker;
2313
+ try{window.showOpenFilePicker=function(){return new Promise(function(){});};}catch(e){}
2314
+ proto.click=function(){if(this.type==='file'){cap=this;return;}try{return orig.apply(this,arguments);}catch(e){}};
2315
+ try{t.click();}catch(e){}
2316
+ try{proto.click=orig;}catch(e){}
2317
+ try{window.showOpenFilePicker=origPicker;}catch(e){}
2318
+ return cap;
2319
+ }
2320
+ var clicked=[];
2321
+ if(!inp){
2322
+ inp=captureOnce(host);
2323
+ if(inp)via='click';
2324
+ else{
2325
+ clicked.push((host.className||host.tagName||'').toString().slice(0,40));
2326
+ // 3) 锚点自己点不出东西就往下探一层:只挑"长得像触发器"的、且真正可见的
2327
+ // 后代,最多 8 次就收手(每次点击都可能改变页面状态,不能无限试)。
2328
+ // 负向词表必须有:封面区里常驻一个「删除封面」按钮,类名带 cover,
2329
+ // 正向匹配会命中它 —— 一旦可见就会被点掉用户的封面。
2330
+ var RX=/upload|file|add|plus|replace|change|select|image|cover/i;
2331
+ var BAD=/remove|delete|del-|trash|clear|cancel|close|drop/i;
2332
+ var nodes=[],all=[];
2333
+ try{all=host.querySelectorAll('button,label,[role=button],a,div,span,i');}catch(e){all=[];}
2334
+ for(var k=0;k<all.length&&nodes.length<8;k++){
2335
+ var e2=all[k],cls=String(e2.className||''),tag=e2.tagName,role=e2.getAttribute&&e2.getAttribute('role');
2336
+ if(BAD.test(cls))continue;
2337
+ if(!(RX.test(cls)||tag==='BUTTON'||tag==='LABEL'||tag==='A'||role==='button'))continue;
2338
+ var rc=e2.getBoundingClientRect();
2339
+ if(rc.width<2||rc.height<2)continue; // 不可见的不点(0×0 的多是布局占位)
2340
+ nodes.push(e2);
2341
+ }
2342
+ for(var j=0;j<nodes.length&&!inp;j++){
2343
+ var nd=nodes[j];
2344
+ if(nd.tagName==='LABEL'&&!labelInput(nd))continue;
2345
+ inp=captureOnce(nd);
2346
+ clicked.push((nd.className||nd.tagName||'').toString().slice(0,40));
2347
+ if(inp)via='click-descendant';
2348
+ }
2349
+ }
2350
+ }
2351
+ if(!inp)inp=find(host);
2352
+ if(!inp)return {ok:false,err:'no-file-input',host_sel:hostSel,clicked:clicked};
2353
+ if(!inp.isConnected){
2354
+ // 捕获到的 input 往往是"创建了但还没插进文档"(组件打算点完就丢),
2355
+ // 挂在锚点下、藏起来:change 才能沿组件自己的 DOM 树冒泡到它的委托处理器。
2356
+ try{inp.style.display='none';host.appendChild(inp);}catch(e){}
2357
+ }
2358
+ var bin=atob(b64),n=bin.length,arr=new Uint8Array(n);
2359
+ for(var m=0;m<n;m++)arr[m]=bin.charCodeAt(m);
2360
+ var file=new File([arr],name,{type:mime});
2361
+ var dt=new DataTransfer();dt.items.add(file);
2362
+ inp.files=dt.files;
2363
+ inp.dispatchEvent(new Event('change',{bubbles:true}));
2364
+ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:clicked};
2365
+ })()`;
2366
+ const res = owning ? await owning.executeJavaScript(js) : await this.evaluate(js, tabId);
2367
+ if (!res || !res.ok) {
2368
+ const err = (res && res.err) || 'upload-failed';
2369
+ if (err === 'no-file-input') {
2370
+ return {
2371
+ success: false,
2372
+ method: 'frame-file-inject',
2373
+ error: 'found ' + JSON.stringify((res && res.host_sel) || target)
2374
+ + ' but no <input type=file> appeared after clicking it'
2375
+ + ' (clicked: ' + JSON.stringify((res && res.clicked) || []) + ')'
2376
+ + ' — pass the element that OPENS the file picker (e.g. the "upload local file" menu item) as target',
2377
+ candidates: cands,
2378
+ };
2379
+ }
2380
+ return { success: false, method: 'frame-file-inject', error: err, candidates: cands };
2381
+ }
2382
+ // in_frame 如实反映实际执行位置:顶层文档上传(CSDN 那种)不应报 true。
2383
+ return {
2384
+ success: true,
2385
+ method: 'frame-file-inject',
2386
+ uploaded: res.name,
2387
+ size: res.size,
2388
+ in_frame: !!owning,
2389
+ // 上传组件可能有三种入口状态,回执如实说明用了哪条路:
2390
+ // direct = input 本来就在;click = 点了锚点才创建;
2391
+ // click-descendant = 锚点里再往下探了一层才点出 input。
2392
+ resolved_by: res.via || 'direct',
2393
+ host_sel: res.host_sel === refSel ? '[data-ai-id]' : (res.host_sel || null),
2394
+ };
2395
+ }
2396
+
1623
2397
  // 通用文本输入(type/setContent 共用):先让窗口/视图拿到焦点,再在页面里
1624
2398
  // DOM 原生聚焦(一次 evaluate 同步完成)→ CDP 受信 Cmd+A 全选 + Delete 清空
1625
2399
  // → 逐字符受信键入 + Enter。清空必须走编辑器自身的受信按键链路:Draft 等
@@ -1649,8 +2423,8 @@ class PageManager {
1649
2423
  } catch(e) {}
1650
2424
  // 定位可编辑区并原生聚焦(不做 DOM 全选——交给 Cmd+A 受信按键)
1651
2425
  const prep = await run(
1652
- `var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return {ok:false,err:'no-ref'};` +
1653
- `var ed=el;if(ed.querySelector){var inner=ed.querySelector('[contenteditable=true],input,textarea');if(inner)ed=inner;}` +
2426
+ `var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return {ok:false,err:'no-ref'};` +
2427
+ `var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
1654
2428
  `var editable=(ed.contentEditable==='true')||(ed.tagName==='TEXTAREA')||(ed.tagName==='INPUT');` +
1655
2429
  `if(!editable)return {ok:false,err:'not-editable'};` +
1656
2430
  `ed.focus();` +
@@ -1663,8 +2437,11 @@ class PageManager {
1663
2437
  // h2>blockquote>pre>ul>li 的套娃),editor_blocks 的 role 也随之失真。
1664
2438
  // 只在"确实已经没有文本内容"时拆,有内容的块一个都不动。
1665
2439
  const stripEmptyShells = () => run(
1666
- `var el=document.querySelector(${JSON.stringify('[data-ai-id="' + String(aiId) + '"]')});if(!el)return false;` +
1667
- `var ed=el;if(ed.querySelector){var inner=ed.querySelector('[contenteditable=true],input,textarea');if(inner)ed=inner;}` +
2440
+ // 与上面 prep 同一口径(穿透 shadow 的 findRefExpr)。两处必须一致:
2441
+ // prep 认得的元素、这里也要认得,否则"清空校验"读到空、把已经清干净的
2442
+ // 编辑器判成没清掉,接着又拆一遍。
2443
+ `var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
2444
+ `var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
1668
2445
  `if(ed.tagName==='INPUT'||ed.tagName==='TEXTAREA')return false;` +
1669
2446
  `if(ed.contentEditable!=='true')return false;` +
1670
2447
  `if((ed.innerText||'').trim()!=='')return false;` +
@@ -1681,15 +2458,23 @@ class PageManager {
1681
2458
  // clear 时先拆一遍:编辑器可能已经是"空但带外壳"的状态,不拆的话下面的
1682
2459
  // 幂等短路会把它当成"已经是空的"直接返回,外壳就永远留在那儿。
1683
2460
  if (!text) await stripEmptyShells();
1684
- // 校验用的归一化。\r\n 统一成 \n,再压掉"排版空白"(空格/tab),但换行
1685
- // 本身必须参与比对:把 \s 全剥掉会让 "a\nb\nc" 与 "abc" 等价,段落结构
1686
- // 丢失就被判成成功 —— 那是对调用方的谎报,出了问题无从察觉。
1687
- const norm = (s) => String(s || '').replace(/\r\n?/g, '\n').replace(/[^\S\n]+/g, '');
2461
+ // 校验用的归一化,依次做三件事:
2462
+ // 1. \r\n 统一成 \n(Windows 换行与受信 Enter 产出的 \r 不该算差异);
2463
+ // 2. 压掉"排版空白"(空格/tab),但换行本身必须参与比对 —— 把 \s 全剥掉
2464
+ // 会让 "a\nb\nc" 与 "abc" 等价,段落结构丢了还判成功,那是谎报;
2465
+ // 3. 连续换行压成一个。
2466
+ // 第 3 条来自实测:contenteditable 编辑器(Quill/ProseMirror/Slate…)普遍把
2467
+ // 一个语义换行渲染成一串 \n —— 往 B站 read-editor 输入 "AA\nBB",读回是
2468
+ // "AA\n\nBB"。逐字比对必然失配,于是**内容明明写进去了却回 success:false /
2469
+ // cdp-input-failed**,调用方据此重试或放弃,而页面其实已经是想要的样子。
2470
+ // 压缩换行仍然保留"有没有分段"这一层信息,只是不再纠结分了几层。
2471
+ const norm = normalizeInputText;
2472
+ const skeletal = skeletalInputText;
1688
2473
  // 幂等短路:若可编辑区当前内容已恰好等于目标文本,直接返回而不做清空+重打。
1689
2474
  // 每次全量重写都会把草稿标脏,重新触发编辑器自身的"草稿备份"/版本气泡——对
1690
2475
  // 相同文本的重复调用(重试/校正)不应让它反复闪烁。
1691
2476
  const curText = await run(
1692
- `var e=document.activeElement;return (e&&(e.innerText||e.value||''))||'';`
2477
+ `var e=${deepActiveExpr()};return (e&&(e.innerText||e.value||''))||'';`
1693
2478
  );
1694
2479
  if (norm(curText) === norm(text)) {
1695
2480
  // "目标就是当前内容" 与 "目标本来就是空的" 必须区分开。
@@ -1726,8 +2511,8 @@ class PageManager {
1726
2511
  const at = opts.at === 'start' || opts.at === 'cursor' ? opts.at : 'end';
1727
2512
  if (at !== 'cursor') {
1728
2513
  await run(
1729
- `var el=document.querySelector(${JSON.stringify('[data-ai-id="' + String(aiId) + '"]')});if(!el)return false;` +
1730
- `var ed=el;if(ed.querySelector){var inner=ed.querySelector('[contenteditable=true],input,textarea');if(inner)ed=inner;}` +
2514
+ `var el=${findRefExpr(JSON.stringify(String(aiId)))};if(!el)return false;` +
2515
+ `var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
1731
2516
  `if(ed.tagName==='INPUT'||ed.tagName==='TEXTAREA'){var L=(ed.value||'').length;` +
1732
2517
  `var p=(${JSON.stringify(at)}==='start')?0:L;try{ed.setSelectionRange(p,p);}catch(e){}return true;}` +
1733
2518
  `if(ed.contentEditable!=='true')return false;` +
@@ -1757,14 +2542,14 @@ class PageManager {
1757
2542
  // 实测残留 "第一段<div>第二段</div><h2>第三段</h2>"。因此清完校验一次,
1758
2543
  // 仍有文本就换编辑器原生 selectAll+delete 再清一遍。
1759
2544
  const left = await run(
1760
- `var el=document.querySelector(${JSON.stringify('[data-ai-id="' + String(aiId) + '"]')});if(!el)return 0;` +
1761
- `var ed=el;if(ed.querySelector){var inner=ed.querySelector('[contenteditable=true],input,textarea');if(inner)ed=inner;}` +
2545
+ `var el=${findRefExpr(JSON.stringify(String(aiId)))};if(!el)return 0;` +
2546
+ `var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
1762
2547
  `return String((ed.innerText!=null?ed.innerText:ed.value)||'').trim().length;`
1763
2548
  );
1764
2549
  if (left > 0) {
1765
2550
  await run(
1766
- `var el=document.querySelector(${JSON.stringify('[data-ai-id="' + String(aiId) + '"]')});if(!el)return false;` +
1767
- `var ed=el;if(ed.querySelector){var inner=ed.querySelector('[contenteditable=true],input,textarea');if(inner)ed=inner;}` +
2551
+ `var el=${findRefExpr(JSON.stringify(String(aiId)))};if(!el)return false;` +
2552
+ `var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
1768
2553
  `try{ed.focus();document.execCommand('selectAll');document.execCommand('delete');}catch(e){}return true;`
1769
2554
  );
1770
2555
  await new Promise((r) => setTimeout(r, 150)); // 等编辑器状态提交
@@ -1775,7 +2560,7 @@ class PageManager {
1775
2560
  const paras = String(text == null ? '' : text).split('\n');
1776
2561
  // 段落内容快照:用来判断"这一下 Enter 到底有没有生效"。
1777
2562
  const snap = () => run(
1778
- `var e=document.activeElement;var t=String((e&&(e.innerText||e.value))||'');` +
2563
+ `var e=${deepActiveExpr()};var t=String((e&&(e.innerText||e.value))||'');` +
1779
2564
  `var b=(e&&e.querySelectorAll)?e.querySelectorAll('p,div,br').length:0;` +
1780
2565
  `return {len:t.length,nl:(t.match(/\\n/g)||[]).length,blocks:b};`
1781
2566
  );
@@ -1801,16 +2586,29 @@ class PageManager {
1801
2586
  // 通用校验:非空文本要求内容包含目标文本;空文本要求已清空(只允许空块残留)。
1802
2587
  // 任一框架均可,无占位符/块数特判。
1803
2588
  const after = await run(
1804
- `var e=document.activeElement;return {text:(e.innerText||e.value||'')};`
2589
+ `var e=${deepActiveExpr()};return {text:(e.innerText||e.value||'')};`
1805
2590
  );
1806
2591
  const want = norm(text);
1807
2592
  const got = norm(after && after.text);
1808
- const ok = want
1809
- ? got.includes(want)
1810
- : got === '';
1811
- return ok
1812
- ? { success: true, method: 'cdp-input', chars: (after && after.text) ? after.text.length : 0 }
1813
- : { success: false, error: 'Input not verified — please re-read the tree and retry', method: 'cdp-input-failed' };
2593
+ // clear(空目标):允许残留空块外壳 —— 编辑器永远乐于留下一个空 <p>。
2594
+ const ok = want ? got.includes(want) : skeletal(got) === '';
2595
+ if (ok) {
2596
+ return { success: true, method: 'cdp-input', chars: (after && after.text) ? after.text.length : 0 };
2597
+ }
2598
+ // 诊断:忽略全部空白后再比一次。相等说明东西其实打进去了、只是被编辑器
2599
+ // 重新排了版;不等说明大概率压根没落上去。两者的处置方式完全相反。
2600
+ const landed = want ? skeletal(after && after.text) === skeletal(text) : false;
2601
+ return {
2602
+ success: false,
2603
+ error: 'Input not verified — please re-read the tree and retry',
2604
+ method: 'cdp-input-failed',
2605
+ expected_chars: want.length,
2606
+ actual_chars: got.length,
2607
+ content_landed_ignoring_whitespace: landed,
2608
+ hint: landed
2609
+ ? 'the text matches once all whitespace is ignored — the editor most likely re-formatted line breaks/padding. Treat as applied and re-read the tree to confirm.'
2610
+ : 'the text does not match even after stripping whitespace — the input probably did not land. Check focus/target before retrying.',
2611
+ };
1814
2612
  }
1815
2613
 
1816
2614
  // OOPIF 内整段清空:在所属子帧上下文里聚焦 contenteditable,用 execCommand
@@ -1819,8 +2617,8 @@ class PageManager {
1819
2617
  async _clearInFrame(frame, aiId) {
1820
2618
  try {
1821
2619
  const r = await frame.executeJavaScript(`(function(){
1822
- var ref=document.querySelector('[data-ai-id="${aiId}"]');if(!ref)return {ok:false,reason:'no-ref'};
1823
- var e=ref;if(ref.querySelector){var inner=ref.querySelector('[contenteditable="true"],textarea,input[type="text"],input[type="search"],input[type="title"],input:not([type])');if(inner)e=inner;}
2620
+ var ref=${findRefExpr(JSON.stringify(aiId))};if(!ref)return {ok:false,reason:'no-ref'};
2621
+ var e=ref;if(ref.querySelector){var inner=${findDescExpr('ref', JSON.stringify('[contenteditable="true"],textarea,input[type="text"],input[type="search"],input[type="title"],input:not([type])'))};if(inner)e=inner;}
1824
2622
  var isInput=(e.tagName==='INPUT'||e.tagName==='TEXTAREA');
1825
2623
  var isCE=e.contentEditable==='true';
1826
2624
  if(!(isInput||isCE))return {ok:false,reason:'not-editable'};
@@ -1844,8 +2642,8 @@ class PageManager {
1844
2642
  async _selectAllFallback(view, aiId, tabId, frame) {
1845
2643
  const r = frame
1846
2644
  ? await frame.executeJavaScript(
1847
- `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return false;` +
1848
- `var ed=el;if(ed.querySelector){var inner=ed.querySelector('[contenteditable=true],input,textarea');if(inner)ed=inner;}` +
2645
+ `(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
2646
+ `var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
1849
2647
  `var s=window.getSelection();` +
1850
2648
  `if(s&&!s.isCollapsed&&s.toString().length>0)return true;` +
1851
2649
  `if(ed.tagName==='TEXTAREA'||ed.tagName==='INPUT'){var v=ed.value||'';if(v){ed.focus();ed.setSelectionRange(0,v.length);}return v.length>0;}` +
@@ -1857,8 +2655,8 @@ class PageManager {
1857
2655
  `s.removeAllRanges();s.addRange(rng);` +
1858
2656
  `return rng.toString().length>0;})()`)
1859
2657
  : await this.evaluate(
1860
- `(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return false;` +
1861
- `var ed=el;if(ed.querySelector){var inner=ed.querySelector('[contenteditable=true],input,textarea');if(inner)ed=inner;}` +
2658
+ `(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
2659
+ `var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
1862
2660
  `var s=window.getSelection();` +
1863
2661
  `if(s&&!s.isCollapsed&&s.toString().length>0)return true;` +
1864
2662
  `if(ed.tagName==='TEXTAREA'||ed.tagName==='INPUT'){var v=ed.value||'';if(v){ed.focus();ed.setSelectionRange(0,v.length);}return v.length>0;}` +
@@ -2052,6 +2850,15 @@ class PageManager {
2052
2850
  error_code: errorCode,
2053
2851
  error: String(errorDescription || ''),
2054
2852
  });
2853
+ // 同一件事落盘。错误页在界面上看得见,但"哪个 tab、哪个网址、错误码多少"
2854
+ // 一旦进程退出就查不回来了(load_failed 只是一次内存广播)。
2855
+ recordError('nav_fail', {
2856
+ tab: tabId,
2857
+ url: String(validatedURL || ''),
2858
+ error_code: errorCode,
2859
+ error: String(errorDescription || ''),
2860
+ error_page: 'shown',
2861
+ });
2055
2862
  // 错误页本身加载成功后清掉防重入标记,否则同 tab 后续失败都不显示。
2056
2863
  view.webContents.once('did-finish-load', () => {
2057
2864
  if (this._failureGuard) this._failureGuard.delete(tabId);
@@ -2292,11 +3099,12 @@ class PageManager {
2292
3099
 
2293
3100
  _layoutView(view) {
2294
3101
  const bounds = this.window.getContentBounds();
3102
+ const inset = this._bottomInset || 0;
2295
3103
  view.setBounds({
2296
3104
  x: 0,
2297
3105
  y: config.tabBarHeight,
2298
3106
  width: bounds.width,
2299
- height: bounds.height - config.tabBarHeight,
3107
+ height: Math.max(0, bounds.height - config.tabBarHeight - inset),
2300
3108
  });
2301
3109
  }
2302
3110