@glittering0717/ai-browser 2.1.11 → 2.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/main/index.js +5 -3
- package/src/main/page_manager.js +386 -1
- package/src/main/ws_server.js +16 -0
- package/src/shared/blank_page.cjs +148 -0
- package/src/shared/protocol.js +4 -0
- package/src/shared/version.js +1 -1
- package/src/shared/wrapper_hint.js +25 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.13",
|
|
4
4
|
"description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
|
|
5
5
|
"main": "src/main/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
"install:app": "node scripts/install_app.cjs",
|
|
83
83
|
"dialog": "node e2e/dialog_ws.cjs",
|
|
84
84
|
"verify:app": "node e2e/shipped_ws.cjs",
|
|
85
|
-
"wrapper": "node e2e/wrapper_ws.cjs"
|
|
85
|
+
"wrapper": "node e2e/wrapper_ws.cjs",
|
|
86
|
+
"paint": "node e2e/paint_ws.cjs"
|
|
86
87
|
},
|
|
87
88
|
"dependencies": {
|
|
88
89
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/src/main/index.js
CHANGED
|
@@ -454,10 +454,12 @@ function createWindow() {
|
|
|
454
454
|
};
|
|
455
455
|
});
|
|
456
456
|
ipcMain.on('nav:go', (_e, input) => {
|
|
457
|
-
const wc = activeWebContents();
|
|
458
457
|
const url = normalizeUrlInput(input);
|
|
459
|
-
if (!
|
|
460
|
-
|
|
458
|
+
if (!url) return;
|
|
459
|
+
// 走 pageManager.navigate 而不是裸 loadURL:裸的这条路上没有导航状态机
|
|
460
|
+
// (list_tabs 会一直报 ready)、没有失败错误页、也没有导航完成后的重绘。
|
|
461
|
+
// 地址栏是最常用的一条路,缺这三样的代价就是"有时白屏、说不清在干嘛"。
|
|
462
|
+
pageManager.navigate(url, pageManager.activeTab).catch(() => {});
|
|
461
463
|
});
|
|
462
464
|
ipcMain.on('nav:back', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goBack(); });
|
|
463
465
|
ipcMain.on('nav:forward', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goForward(); });
|
package/src/main/page_manager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// main/page_manager.js — Multi-tab page lifecycle manager v2
|
|
2
2
|
// One Electron process, one WS server, multiple tabs.
|
|
3
3
|
// Agent routes via tab ID. Tab 0 is default.
|
|
4
|
-
import { ipcMain, BrowserView } from 'electron';
|
|
4
|
+
import { ipcMain, BrowserView, nativeImage } from 'electron';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -29,6 +29,9 @@ import { wrapperHintExpr, wrapperHintText } from '../shared/wrapper_hint.js';
|
|
|
29
29
|
// normalizeAxTree 里已经自带,这条补的是默认路径 —— 见文件内注释。
|
|
30
30
|
import editorsProbe from '../shared/editors_probe.js';
|
|
31
31
|
import { newTracker, noteTree, diagnoseMissingRef } from '../shared/ref_epoch.js';
|
|
32
|
+
// "这一页到底画出来没有"的判定口径(纯函数)。此前没有任何检测覆盖"加载成功但
|
|
33
|
+
// 屏幕纯白"这一类 —— 见 shared/blank_page.cjs 顶部(2026-10-25 用户报百度白屏)。
|
|
34
|
+
import blankPage from '../shared/blank_page.cjs';
|
|
32
35
|
import { diffValues, previewOf, safeByteLength, DEFAULT_SNAPSHOT_JS, DIFF_MAX_ENTRIES, evictOldest } from './snapshot_diff.js';
|
|
33
36
|
import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
|
|
34
37
|
import { DownloadMonitor } from './download_monitor.js';
|
|
@@ -213,6 +216,26 @@ class PageManager {
|
|
|
213
216
|
this._openDialogs = new Map();
|
|
214
217
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
215
218
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
219
|
+
// === 白屏自检(paint audit)===
|
|
220
|
+
// tabId -> {verdict, blank, white_ratio, unique, action, at, url}:最近一次自检的结论。
|
|
221
|
+
// 为什么必须有它:白屏是唯一一种"所有事件都成功、DOM 也正常、但用户看到的是空"的
|
|
222
|
+
// 失败形态,没有观测就只能靠人截图喊"白的"。有了它,list_tabs / paint_check
|
|
223
|
+
// 能把"这一页画出来了没有"当成一个可查询的事实。
|
|
224
|
+
this._paintState = new Map();
|
|
225
|
+
// tabId -> 本轮已经执行过几步补救(0=还没救过)。一有新的导航就清零,
|
|
226
|
+
// 所以"救不动"不会变成跨页面的永久状态。
|
|
227
|
+
this._paintSteps = new Map();
|
|
228
|
+
this._paintTimers = new Map(); // tabId -> 排队中的自检(合并 dom-ready/finish-load 的连发)
|
|
229
|
+
// tabId -> 正在跑的那一轮自检的 Promise。并发调用共享同一个 Promise,
|
|
230
|
+
// 而不是"发现有人在跑就返回 null"(那会让调用方拿到一个空结论)。
|
|
231
|
+
this._paintAuditInflight = new Map();
|
|
232
|
+
// 白屏自检的总开关。默认开;关掉它的唯一场景是"我要精确测量导航时延"。
|
|
233
|
+
this._paintAuditEnabled = process.env.AI_BROWSER_PAINT_AUDIT !== '0';
|
|
234
|
+
// 自己发起的 reload(自救用)。用它区分"这次 did-finish-load 是我们的补救"
|
|
235
|
+
// 还是"用户/页面自己导航了"—— 前者必须保留补救步数,否则会无限重载。
|
|
236
|
+
this._paintSelfReload = new Set();
|
|
237
|
+
// tabId -> 上次因渲染进程崩溃而重载的时刻(见 _attachCrashRecovery 的 15s 上限)
|
|
238
|
+
this._crashReloadAt = new Map();
|
|
216
239
|
this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
|
|
217
240
|
this._snapshotSeq = 0;
|
|
218
241
|
this.networkMonitor = new NetworkMonitor({}, { captureEnabled: NETWORK_CAPTURE_DEFAULT });
|
|
@@ -650,6 +673,16 @@ class PageManager {
|
|
|
650
673
|
// {ok:false, error:"ERR_ABORTED (-3) loading 'file://.../error_page.html'"},
|
|
651
674
|
// 它会以为自己的导航请求坏了,而实际上错误页已经接管、信息全在那儿。
|
|
652
675
|
const pending = wc.loadURL(url);
|
|
676
|
+
// 导航状态机也在这里维护:此前只有 new_tab 的占位流程会写 _tabNavState,
|
|
677
|
+
// 于是 ui.navigate 期间 list_tabs 一直报 'ready' —— agent 看到 ready 就去
|
|
678
|
+
// 读树/截图,拿到的是旧页面或半张页面,然后开始"重试"。
|
|
679
|
+
this._tabNavState.set(tid, 'loading');
|
|
680
|
+
const markReady = () => {
|
|
681
|
+
if (this._tabNavState.get(tid) === 'loading') this._tabNavState.set(tid, 'ready');
|
|
682
|
+
};
|
|
683
|
+
wc.once('did-finish-load', markReady);
|
|
684
|
+
wc.once('did-fail-load', markReady);
|
|
685
|
+
setTimeout(markReady, 20000).unref?.();
|
|
653
686
|
// commit / domcontentloaded 会**提前**返回;之后这个 Promise 仍可能 reject
|
|
654
687
|
// (错误页接管、用户中止)。不接住就是 unhandledRejection,会把整个进程
|
|
655
688
|
// 的日志淹掉。错误本身在返回结果里另行汇报。
|
|
@@ -3412,6 +3445,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3412
3445
|
}
|
|
3413
3446
|
|
|
3414
3447
|
this._attachLoadFailureHooks(tabId, view);
|
|
3448
|
+
// "画出来了没有"只能主动看(没有对应事件),"渲染进程崩了"要自己救
|
|
3449
|
+
// (以前只打一行日志,用户看到的是永久白屏)。
|
|
3450
|
+
this._attachPaintHook(tabId, view);
|
|
3451
|
+
this._attachCrashRecovery(tabId, view);
|
|
3415
3452
|
return tabId;
|
|
3416
3453
|
}
|
|
3417
3454
|
|
|
@@ -3568,6 +3605,327 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3568
3605
|
return null;
|
|
3569
3606
|
}
|
|
3570
3607
|
|
|
3608
|
+
// === 白屏自检 ==============================================================
|
|
3609
|
+
//
|
|
3610
|
+
// 为什么这一层以前不存在,而它必须存在:
|
|
3611
|
+
// "网络失败"有 did-fail-load(→ 错误页),"渲染进程死了"有 render-process-gone,
|
|
3612
|
+
// 但**"页面加载成功、DOM 也有内容、屏幕上却是纯白"**这一类没有任何事件。
|
|
3613
|
+
// 它是唯一一种"所有回调都成功"的失败形态,所以只能靠主动看一眼画面。
|
|
3614
|
+
// 2026-10-25 用户报"打开了百度有时候是纯白屏"就是这一类 —— 当时工具侧
|
|
3615
|
+
// (evaluate / get_tree / screenshot)全都读得到内容,等于集体失明。
|
|
3616
|
+
//
|
|
3617
|
+
// 观测口径写在 shared/blank_page.cjs:白度 + 唯一色数 + DOM 事实,三者一起判。
|
|
3618
|
+
|
|
3619
|
+
/** 页面自述的事实:有多少字、多少个节点、到哪一步了。 */
|
|
3620
|
+
static PAINT_DOM_JS =
|
|
3621
|
+
'(function(){var b=document.body,d=document.documentElement;' +
|
|
3622
|
+
'var t=b?(b.innerText||b.textContent||""):"";' +
|
|
3623
|
+
'return JSON.stringify({' +
|
|
3624
|
+
'text_len:t.replace(/\\s+/g," ").trim().length,' +
|
|
3625
|
+
'nodes:document.querySelectorAll("*").length,' +
|
|
3626
|
+
'ready_state:document.readyState,' +
|
|
3627
|
+
'url:location.href});})()';
|
|
3628
|
+
|
|
3629
|
+
/**
|
|
3630
|
+
* 看一眼这个 tab 现在到底画出来了没有(只读,不改任何东西)。
|
|
3631
|
+
*
|
|
3632
|
+
* 两个数据源,缺一不可:
|
|
3633
|
+
* 1. 位图 —— webContents.capturePage(),走 view 自己的 surface,和用户截图同源。
|
|
3634
|
+
* 缩到 240px 宽再统计:判"是不是白"不需要细节,缩图把统计开销从 ~3.5M 像素
|
|
3635
|
+
* 降到 ~5 万,单次自检从几十毫秒降到个位数。
|
|
3636
|
+
* 2. DOM 事实 —— 有文字/有节点却纯白,才是缺陷;本来就空的页纯白是正确的。
|
|
3637
|
+
*
|
|
3638
|
+
* @returns {Promise<{ok:boolean, tab:number, blank:boolean, verdict:string,
|
|
3639
|
+
* reason:string, white_ratio?:number, unique?:number, dom?:object,
|
|
3640
|
+
* bounds?:object, layout_ok?:boolean, ms?:number, error?:string}>}
|
|
3641
|
+
*/
|
|
3642
|
+
async paintCheck(tabId) {
|
|
3643
|
+
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
3644
|
+
const view = this._getView(tid);
|
|
3645
|
+
if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
|
|
3646
|
+
const wc = view.webContents;
|
|
3647
|
+
if (!wc || wc.isDestroyed()) return { ok: false, error: 'tab-destroyed', tab: tid };
|
|
3648
|
+
const startedAt = Date.now();
|
|
3649
|
+
|
|
3650
|
+
let bounds = null;
|
|
3651
|
+
try { bounds = view.getBounds(); } catch (_) { /* 视图正在销毁 */ }
|
|
3652
|
+
|
|
3653
|
+
// 布局是否占了该占的地方。压在 0 高/0 宽上时页面"白"不是内容问题,
|
|
3654
|
+
// 是没人给它地方画 —— 这两种在截图上长得一样,必须靠几何量分开。
|
|
3655
|
+
let expected = null;
|
|
3656
|
+
let layoutOk = null;
|
|
3657
|
+
try {
|
|
3658
|
+
const cb = this.window.getContentBounds();
|
|
3659
|
+
expected = {
|
|
3660
|
+
width: cb.width,
|
|
3661
|
+
height: Math.max(0, cb.height - config.tabBarHeight - (this._bottomInset || 0)),
|
|
3662
|
+
};
|
|
3663
|
+
if (bounds) {
|
|
3664
|
+
layoutOk = bounds.width >= expected.width - 2 && Math.abs(bounds.height - expected.height) <= 2;
|
|
3665
|
+
}
|
|
3666
|
+
} catch (_) { /* 窗口已销毁 */ }
|
|
3667
|
+
|
|
3668
|
+
let dom = { text_len: 0, nodes: 0, ready_state: '' };
|
|
3669
|
+
try {
|
|
3670
|
+
const raw = await withDeadline(this.evaluate(PageManager.PAINT_DOM_JS, tid), 2000, 'paint_dom');
|
|
3671
|
+
const s = (raw && typeof raw === 'object' && 'value' in raw) ? raw.value : raw;
|
|
3672
|
+
const parsed = typeof s === 'string' ? JSON.parse(s) : null;
|
|
3673
|
+
if (parsed) dom = parsed;
|
|
3674
|
+
} catch (_) {
|
|
3675
|
+
// 读不到 DOM 事实(渲染器卡住/正在崩溃)。继续往下走:宁可只用位图判,
|
|
3676
|
+
// 也不要因为"诊断都失败了"就跳过诊断本身。
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
let stats = null;
|
|
3680
|
+
let frameError = null;
|
|
3681
|
+
try {
|
|
3682
|
+
// 位图来源优先走 CDP(和 ui.screenshot 同一条路),而不是
|
|
3683
|
+
// webContents.capturePage()。实测(2026-10-25,e2e/paint 与真机):
|
|
3684
|
+
// capturePage 对**被其它窗口盖住**的窗口返回空图(isEmpty()===true),
|
|
3685
|
+
// 而"窗口被盖住"恰恰是本产品最常见的状态(不抢焦点原则)。用 capturePage
|
|
3686
|
+
// 就等于在最需要观测的场景下失明 —— 判出来是 no-frame,什么都没看见。
|
|
3687
|
+
// CDP 的 Page.captureScreenshot 走渲染器自己的 surface,被遮挡也拿得到。
|
|
3688
|
+
//
|
|
3689
|
+
// clip.scale=0.25:判"是不是白的"不需要细节,缩到 1/4 后像素数从 350 万
|
|
3690
|
+
// 降到 22 万,统计从几十毫秒降到几毫秒,而且和用户看到的是同一帧内容。
|
|
3691
|
+
const w = bounds && bounds.width ? Math.max(1, Math.round(bounds.width)) : 0;
|
|
3692
|
+
const h = bounds && bounds.height ? Math.max(1, Math.round(bounds.height)) : 0;
|
|
3693
|
+
let shot = null;
|
|
3694
|
+
if (w && h) {
|
|
3695
|
+
try {
|
|
3696
|
+
this._ensureCdp(tid, view, 'Page');
|
|
3697
|
+
shot = await withDeadline(
|
|
3698
|
+
view.webContents.debugger.sendCommand('Page.captureScreenshot', {
|
|
3699
|
+
format: 'png',
|
|
3700
|
+
fromSurface: true,
|
|
3701
|
+
clip: { x: 0, y: 0, width: w, height: h, scale: 0.25 },
|
|
3702
|
+
}),
|
|
3703
|
+
4000, 'paint_shot'
|
|
3704
|
+
);
|
|
3705
|
+
} catch (_) { /* 落到 capturePage 兜底 */ }
|
|
3706
|
+
}
|
|
3707
|
+
if (shot && shot.data) {
|
|
3708
|
+
const buf = Buffer.from(shot.data, 'base64');
|
|
3709
|
+
const img = nativeImage.createFromBuffer(buf);
|
|
3710
|
+
if (img && !img.isEmpty()) stats = blankPage.statsFromBitmap(img.toBitmap());
|
|
3711
|
+
}
|
|
3712
|
+
if (!stats) {
|
|
3713
|
+
const img = await withDeadline(wc.capturePage(), 4000, 'capturePage');
|
|
3714
|
+
if (img && typeof img.isEmpty === 'function' && !img.isEmpty()) {
|
|
3715
|
+
const small = img.resize({ width: 240 });
|
|
3716
|
+
stats = blankPage.statsFromBitmap(small.toBitmap());
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
} catch (e) {
|
|
3720
|
+
frameError = e && e.message ? e.message : String(e);
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
const cls = blankPage.classifyPaint({ stats, dom, bounds });
|
|
3724
|
+
// 还在"正在打开…"占位页(file:///…/loading.html?url=…)上:真实导航排在建
|
|
3725
|
+
// 垫片之后,还没开始。这时既不是白屏也不是就绪 —— 报 not-ready 才会让调用方
|
|
3726
|
+
// "等一会儿"而不是"这页坏了"。这个状态在 new_tab 回执后约 100–200ms 内可见。
|
|
3727
|
+
const onPlaceholder = dom && dom.url && /\/loading\.html(\?|$)/.test(String(dom.url));
|
|
3728
|
+
const verdict = onPlaceholder ? 'not-ready' : cls.verdict;
|
|
3729
|
+
const reason = onPlaceholder
|
|
3730
|
+
? 'still on the loading placeholder — the real navigation has not started yet'
|
|
3731
|
+
: cls.reason;
|
|
3732
|
+
const blank = cls.blank;
|
|
3733
|
+
// 观测到的就是最新结论,落进 _paintState —— 否则 list_tabs 报的是"上一轮自动
|
|
3734
|
+
// 自检"的旧结论,和调用方刚刚 paint_check 到的结果自相矛盾。
|
|
3735
|
+
// no-frame 不落(那不是对页面的判断,而是我们没看见)。
|
|
3736
|
+
if (verdict !== 'no-frame') {
|
|
3737
|
+
this._paintState.set(tid, { tab: tid, blank, verdict, reason, dom, at: Date.now(), action: 'none' });
|
|
3738
|
+
}
|
|
3739
|
+
return {
|
|
3740
|
+
ok: true,
|
|
3741
|
+
tab: tid,
|
|
3742
|
+
blank,
|
|
3743
|
+
verdict,
|
|
3744
|
+
reason,
|
|
3745
|
+
...(stats ? {
|
|
3746
|
+
white_ratio: Math.round(stats.white_ratio * 1000) / 1000,
|
|
3747
|
+
unique: stats.unique,
|
|
3748
|
+
...(stats.unique_pinned ? { unique_pinned: true } : {}),
|
|
3749
|
+
} : {}),
|
|
3750
|
+
dom,
|
|
3751
|
+
...(bounds ? { bounds: { width: bounds.width, height: bounds.height } } : {}),
|
|
3752
|
+
...(expected ? { expected } : {}),
|
|
3753
|
+
...(layoutOk !== null ? { layout_ok: layoutOk } : {}),
|
|
3754
|
+
...(frameError ? { frame_error: frameError } : {}),
|
|
3755
|
+
ms: Date.now() - startedAt,
|
|
3756
|
+
};
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
/**
|
|
3760
|
+
* 排一次自检。合并连发的导航事件;只对**活动** tab 做 —— 后台 tab 不在屏幕上,
|
|
3761
|
+
* 它"白不白"没有用户可见的后果,为它反复 capturePage 只是白烧 CPU。
|
|
3762
|
+
*/
|
|
3763
|
+
_schedulePaintAudit(tabId, delay = 450) {
|
|
3764
|
+
if (!this._paintAuditEnabled) return;
|
|
3765
|
+
if (tabId !== this.activeTab) return;
|
|
3766
|
+
const prev = this._paintTimers.get(tabId);
|
|
3767
|
+
if (prev) clearTimeout(prev);
|
|
3768
|
+
const t = setTimeout(() => {
|
|
3769
|
+
this._paintTimers.delete(tabId);
|
|
3770
|
+
this._auditPaint(tabId).catch(() => {});
|
|
3771
|
+
}, delay);
|
|
3772
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
3773
|
+
this._paintTimers.set(tabId, t);
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
/**
|
|
3777
|
+
* 自检入口。合并并发调用:**同一 tab 同时只跑一轮**,后来的调用者拿到正在跑的那一轮。
|
|
3778
|
+
*
|
|
3779
|
+
* 为什么不是"发现已在跑就返回 null":自动自检(did-finish-load 后 450ms)和
|
|
3780
|
+
* 调用方的 ui.paint_check{heal:true} 很容易撞在一起,返回 null 会让调用方
|
|
3781
|
+
* 拿到一个"什么都没有"的结论,然后自己猜。共享同一个 Promise 才是对的。
|
|
3782
|
+
*/
|
|
3783
|
+
async _auditPaint(tabId) {
|
|
3784
|
+
if (!this._paintAuditEnabled) return null;
|
|
3785
|
+
const inflight = this._paintAuditInflight.get(tabId);
|
|
3786
|
+
if (inflight) return inflight;
|
|
3787
|
+
let p;
|
|
3788
|
+
p = this._auditPaintNow(tabId).finally(() => {
|
|
3789
|
+
if (this._paintAuditInflight.get(tabId) === p) this._paintAuditInflight.delete(tabId);
|
|
3790
|
+
});
|
|
3791
|
+
this._paintAuditInflight.set(tabId, p);
|
|
3792
|
+
return p;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
/**
|
|
3796
|
+
* 自检 + 按阶梯自救(真正的执行体,递归时直接调它,避免绕回合并逻辑死等自己)。
|
|
3797
|
+
*
|
|
3798
|
+
* 阶梯(见 blank_page.decidePaintAction):repaint → reload → give-up。
|
|
3799
|
+
* 有上限是硬要求:CSS 自己把文字刷成白色的页面,reload 一万次也还是白,
|
|
3800
|
+
* 而无限重试会变成一个看不见的死循环(CPU 烧着、页面反复重载、用户看到的仍是白屏,
|
|
3801
|
+
* 而且没有任何地方说过"我试过了、没用")。
|
|
3802
|
+
*/
|
|
3803
|
+
async _auditPaintNow(tabId) {
|
|
3804
|
+
{
|
|
3805
|
+
const res = await this.paintCheck(tabId);
|
|
3806
|
+
if (!res.ok) return res;
|
|
3807
|
+
const steps = this._paintSteps.get(tabId) || 0;
|
|
3808
|
+
const action = blankPage.decidePaintAction({ verdict: res.verdict, steps });
|
|
3809
|
+
const rec = { ...res, action, at: Date.now() };
|
|
3810
|
+
this._paintState.set(tabId, rec);
|
|
3811
|
+
|
|
3812
|
+
if (action === 'none') return rec;
|
|
3813
|
+
|
|
3814
|
+
const view = this._getView(tabId);
|
|
3815
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return rec;
|
|
3816
|
+
if (action === 'repaint') {
|
|
3817
|
+
this._paintSteps.set(tabId, steps + 1);
|
|
3818
|
+
this._forceRepaint(view);
|
|
3819
|
+
await new Promise((r) => { const t = setTimeout(r, 320); if (t.unref) t.unref(); });
|
|
3820
|
+
return this._auditPaintNow(tabId);
|
|
3821
|
+
}
|
|
3822
|
+
if (action === 'reload') {
|
|
3823
|
+
this._paintSteps.set(tabId, steps + 1);
|
|
3824
|
+
this._paintSelfReload.add(tabId);
|
|
3825
|
+
try {
|
|
3826
|
+
view.webContents.reload();
|
|
3827
|
+
} catch (e) {
|
|
3828
|
+
this._paintSelfReload.delete(tabId);
|
|
3829
|
+
return rec;
|
|
3830
|
+
}
|
|
3831
|
+
await this._waitFinishLoad(tabId, 10000);
|
|
3832
|
+
await new Promise((r) => { const t = setTimeout(r, 450); if (t.unref) t.unref(); });
|
|
3833
|
+
return this._auditPaintNow(tabId);
|
|
3834
|
+
}
|
|
3835
|
+
// give-up:救不动就如实上报。这是唯一能让人知道"白屏是真的、重试没用"的地方。
|
|
3836
|
+
const info = {
|
|
3837
|
+
tab: tabId,
|
|
3838
|
+
url: res.dom && res.dom.url,
|
|
3839
|
+
verdict: res.verdict,
|
|
3840
|
+
reason: res.reason,
|
|
3841
|
+
white_ratio: res.white_ratio,
|
|
3842
|
+
unique: res.unique,
|
|
3843
|
+
attempts: steps,
|
|
3844
|
+
};
|
|
3845
|
+
recordError('paint_blank', info);
|
|
3846
|
+
this._broadcast('paint_blank', info);
|
|
3847
|
+
return rec;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
/** 等到 did-finish-load(或超时)。只用于自救后的确认,不影响导航本身。 */
|
|
3852
|
+
_waitFinishLoad(tabId, timeoutMs) {
|
|
3853
|
+
const view = this._getView(tabId);
|
|
3854
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return Promise.resolve(false);
|
|
3855
|
+
const wc = view.webContents;
|
|
3856
|
+
return new Promise((resolve) => {
|
|
3857
|
+
let done = false;
|
|
3858
|
+
const finish = (ok) => {
|
|
3859
|
+
if (done) return;
|
|
3860
|
+
done = true;
|
|
3861
|
+
try { wc.removeListener('did-finish-load', onLoad); } catch (_) {}
|
|
3862
|
+
clearTimeout(timer);
|
|
3863
|
+
resolve(ok);
|
|
3864
|
+
};
|
|
3865
|
+
const onLoad = () => finish(true);
|
|
3866
|
+
wc.once('did-finish-load', onLoad);
|
|
3867
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
3868
|
+
if (timer.unref) timer.unref();
|
|
3869
|
+
});
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
/**
|
|
3873
|
+
* 给一个 tab 挂上自检钩子(幂等)。
|
|
3874
|
+
*
|
|
3875
|
+
* 为什么是 did-finish-load + dom-ready 两条:did-finish-load 在长轮询/SPA 上可能
|
|
3876
|
+
* 迟迟不来(那正是首屏白最久的时候),dom-ready 是更早的第二落点。两次都被
|
|
3877
|
+
* _schedulePaintAudit 按 tab 合并,不会真的跑两遍。
|
|
3878
|
+
*/
|
|
3879
|
+
_attachPaintHook(tabId, view) {
|
|
3880
|
+
if (!this._paintAuditEnabled) return;
|
|
3881
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return;
|
|
3882
|
+
if (!this._paintAuditHooked) this._paintAuditHooked = new WeakSet();
|
|
3883
|
+
if (this._paintAuditHooked.has(view)) return;
|
|
3884
|
+
this._paintAuditHooked.add(view);
|
|
3885
|
+
const onLoad = () => {
|
|
3886
|
+
// 自己触发的 reload 不清零补救步数 —— 否则 "reload 后还是白" 会重新从
|
|
3887
|
+
// repaint 开始,变成"重载 → 再重载"的无限循环。
|
|
3888
|
+
if (this._paintSelfReload.has(tabId)) {
|
|
3889
|
+
this._paintSelfReload.delete(tabId);
|
|
3890
|
+
} else {
|
|
3891
|
+
this._paintSteps.set(tabId, 0);
|
|
3892
|
+
this._paintState.delete(tabId);
|
|
3893
|
+
}
|
|
3894
|
+
this._schedulePaintAudit(tabId, 450);
|
|
3895
|
+
};
|
|
3896
|
+
view.webContents.on('did-finish-load', onLoad);
|
|
3897
|
+
view.webContents.on('dom-ready', () => this._schedulePaintAudit(tabId, 900));
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
/**
|
|
3901
|
+
* 渲染进程崩了 → 自己重载一次。
|
|
3902
|
+
*
|
|
3903
|
+
* 以前这里只有 console.error:进程崩溃后 Chromium 给窗口留一块**白**的
|
|
3904
|
+
* surface,事件不再来,DOM 读不到,用户看到的是永久白屏,而日志里只有一行。
|
|
3905
|
+
* reload 之后 Chromium 会重新拉一个渲染进程,页面照常回来。
|
|
3906
|
+
* 15 秒内只救一次:崩了又崩说明是死循环,重载只会让人反复看着它闪。
|
|
3907
|
+
*/
|
|
3908
|
+
_attachCrashRecovery(tabId, view) {
|
|
3909
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return;
|
|
3910
|
+
if (!this._crashHooked) this._crashHooked = new WeakSet();
|
|
3911
|
+
if (this._crashHooked.has(view)) return;
|
|
3912
|
+
this._crashHooked.add(view);
|
|
3913
|
+
view.webContents.on('render-process-gone', (_e, details) => {
|
|
3914
|
+
const reason = (details && details.reason) || 'unknown';
|
|
3915
|
+
let url = null;
|
|
3916
|
+
try { url = view.webContents.getURL(); } catch (_) {}
|
|
3917
|
+
recordError('render_process_gone', { tab: tabId, reason, exit_code: details && details.exitCode, url });
|
|
3918
|
+
if (reason === 'clean-exit') return;
|
|
3919
|
+
const last = this._crashReloadAt.get(tabId) || 0;
|
|
3920
|
+
if (Date.now() - last < 15000) return;
|
|
3921
|
+
this._crashReloadAt.set(tabId, Date.now());
|
|
3922
|
+
try {
|
|
3923
|
+
view.webContents.reload();
|
|
3924
|
+
this._broadcast('tab_recovered', { tab: tabId, reason, action: 'reload', url });
|
|
3925
|
+
} catch (_) { /* 视图已销毁 */ }
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3571
3929
|
/**
|
|
3572
3930
|
* Repaint on demand (ui.repaint). The "page shows stale content" case has
|
|
3573
3931
|
* no observable state to detect it from — the DOM and evaluate already report
|
|
@@ -3657,6 +4015,30 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3657
4015
|
// 同理,下载记录只对"这个 tab 还在"有意义。注意**只清记录,不删文件** ——
|
|
3658
4016
|
// 文件是用户要的东西,删文件是另一个决定,不能藏在关 tab 里。
|
|
3659
4017
|
this.downloadMonitor.clearTab(tabId);
|
|
4018
|
+
// 白屏自检 / 崩溃自救的 per-tab 状态。不清的话 tabId 会被新 tab 复用,
|
|
4019
|
+
// 新 tab 一开就继承上一任的"已经救过两步",白屏时直接被判定成 give-up。
|
|
4020
|
+
this._paintState.delete(tabId);
|
|
4021
|
+
this._paintSteps.delete(tabId);
|
|
4022
|
+
this._paintSelfReload.delete(tabId);
|
|
4023
|
+
this._crashReloadAt.delete(tabId);
|
|
4024
|
+
const pt = this._paintTimers.get(tabId);
|
|
4025
|
+
if (pt) { clearTimeout(pt); this._paintTimers.delete(tabId); }
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
/** 白屏相关的"值得报出去的结论";正常页面返回 null(listTabs 不带这个字段)。 */
|
|
4029
|
+
_paintProblem(tabId) {
|
|
4030
|
+
const p = this._paintState.get(tabId);
|
|
4031
|
+
if (!p) return null;
|
|
4032
|
+
// 这四种都不是"用户可见的白屏",不该出现在 list_tabs 里:
|
|
4033
|
+
// ok / empty-legit 是正常,not-ready 是还没加载完,no-frame 是**我们自己
|
|
4034
|
+
// 没看到画面**(视图被销毁等)—— 把诊断失败说成页面故障,是最坏的一种谎。
|
|
4035
|
+
if (p.verdict === 'ok' || p.verdict === 'empty-legit' || p.verdict === 'not-ready' || p.verdict === 'no-frame') return null;
|
|
4036
|
+
return {
|
|
4037
|
+
blank: !!p.blank,
|
|
4038
|
+
verdict: p.verdict,
|
|
4039
|
+
reason: p.reason,
|
|
4040
|
+
action: p.action,
|
|
4041
|
+
};
|
|
3660
4042
|
}
|
|
3661
4043
|
|
|
3662
4044
|
listTabs() {
|
|
@@ -3676,6 +4058,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3676
4058
|
// 只有 manual 策略下才会长期存在。auto 模式下对话框在打开的瞬间就被
|
|
3677
4059
|
// 应答了,所以正常情况这个字段不出现 —— 出现即意味着"有人需要点它"。
|
|
3678
4060
|
...(this._openDialogs.has(id) ? { dialog: this._openDialogs.get(id) } : {}),
|
|
4061
|
+
// 白屏只在与众不同时出现:正常页面不带这个字段(省 token,也不给
|
|
4062
|
+
// 调用方制造"每次都要判"的噪音)。出现即"这一页没画出来"。
|
|
4063
|
+
...(this._paintProblem(id) ? { paint: this._paintProblem(id) } : {}),
|
|
3679
4064
|
});
|
|
3680
4065
|
}
|
|
3681
4066
|
return list;
|
package/src/main/ws_server.js
CHANGED
|
@@ -185,6 +185,22 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
|
|
|
185
185
|
send({ jsonrpc: '2.0', id, result: r });
|
|
186
186
|
break;
|
|
187
187
|
}
|
|
188
|
+
case 'ui.paint_check': {
|
|
189
|
+
// "这一页到底画出来了没有"—— 只读,不改任何东西。
|
|
190
|
+
//
|
|
191
|
+
// 为什么必须有这个入口:白屏是唯一一种"did-finish-load 到了、
|
|
192
|
+
// readyState=complete、evaluate 读得到文字、CDP 截图也有内容,
|
|
193
|
+
// 但屏幕上就是纯白"的失败形态。所有既有观测口在这里集体失明,
|
|
194
|
+
// 于是用户说"白的"、工具说"好的",谁也拿不出证据。
|
|
195
|
+
// 判定口径见 shared/blank_page.cjs。
|
|
196
|
+
//
|
|
197
|
+
// heal:true 时才走自救阶梯(repaint → reload → 给结论并上报),
|
|
198
|
+
// 默认只观测 —— 调用方问"是不是白的"时,不该顺手把页面重载掉。
|
|
199
|
+
const tid = params.tab !== undefined ? params.tab : pageManager.activeTab;
|
|
200
|
+
const r = params.heal ? await pageManager._auditPaint(tid) : await pageManager.paintCheck(tid);
|
|
201
|
+
send({ jsonrpc: '2.0', id, result: r });
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
188
204
|
case 'ui.snapshot': {
|
|
189
205
|
const r = await pageManager.snapshot({ js: params.js, label: params.label, tab: tabId });
|
|
190
206
|
send({ jsonrpc: '2.0', id, result: r });
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// shared/blank_page.cjs — "这一页到底画出来没有"的判定口径(纯函数)。
|
|
2
|
+
//
|
|
3
|
+
// 事故(2026-10-25,用户报"打开了百度有时候是纯白屏"):
|
|
4
|
+
// 页面加载事件全都到了(did-finish-load / readyState=complete)、DOM 里有内容、
|
|
5
|
+
// agent 通过 ui.evaluate 也能读到文字,但**用户看到的那块区域是纯白的**。
|
|
6
|
+
// 这类白屏此前没有任何检测手段:
|
|
7
|
+
// - did-fail-load 只覆盖"网络失败",不覆盖"画布是空的";
|
|
8
|
+
// - chrome-error:// 的错误页覆盖"连接失败",也不覆盖这个;
|
|
9
|
+
// - capturePage / CDP 截图**不测**它 —— 它们走 view 自己的 surface,
|
|
10
|
+
// 视图内部画得好好的,白的是窗口那一层。
|
|
11
|
+
// 结果就是"用户说白屏,工具说一切正常",双方都拿不出证据。
|
|
12
|
+
//
|
|
13
|
+
// 这份模块只做一件事:给一张**位图**和一份**DOM 事实**,回答
|
|
14
|
+
// "它是不是白的?白是因为这页本来就空,还是因为它该有内容却没画出来?"
|
|
15
|
+
// 判定与"怎么修"分开:修有副作用(reload 会丢掉页面里的临时状态),
|
|
16
|
+
// 所以由 decidePaintAction 单独决定,且**有上限**(见下)。
|
|
17
|
+
//
|
|
18
|
+
// 为什么白度 + 唯一色两个指标都要:
|
|
19
|
+
// - 只用白度:百度首页本身 90%+ 是白的,会天天误报;
|
|
20
|
+
// - 只用唯一色:深色加载页只有 400 多种颜色,也会误报成"空白"。
|
|
21
|
+
// 两个一起看才分得清"内容页(白底但有正文/图标 → 唯一色几千)"和
|
|
22
|
+
// "真的什么都没画(纯白 → 唯一色个位数)"。
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
// 位图格式:Electron 的 nativeImage.toBitmap() 给的是 BGRA(4 字节/像素,
|
|
27
|
+
// 从上到下、无 padding)。这里不假设调用方用什么解码,只要求同一约定。
|
|
28
|
+
const BPP = 4;
|
|
29
|
+
|
|
30
|
+
// 阈值定了两个数,都有实测依据(2026-10-25,1280x800 窗口、2x 屏):
|
|
31
|
+
// - 真实页面:百度首页 90–93% 白 / 3.9k–6.1k 唯一色;
|
|
32
|
+
// 纯白故障帧:99–100% 白 / 3–25 唯一色(抗锯齿边缘也会贡献几个色阶);
|
|
33
|
+
// - 深色页面(我们的 loading.html):0% 白 / 400+ 唯一色。
|
|
34
|
+
// 取 white ≥ 99% 且 unique ≤ 32:两侧都留了余量,既不会把"白底文章页"算进来
|
|
35
|
+
// (它的唯一色必然上千),也不会因为一个圆角边框的四个渐变色就漏掉真白屏。
|
|
36
|
+
const WHITE_MIN_RATIO = 0.99;
|
|
37
|
+
const WHITE_PIXEL = 250; // 单通道 ≥ 250 算"白"(250 而不是 255:留点压缩/缩放余量)
|
|
38
|
+
const UNIQUE_MAX = 32;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 统计位图的白度与唯一色数。
|
|
42
|
+
* @param {Buffer|Uint8Array} bitmap BGRA 位图
|
|
43
|
+
* @returns {{total:number, white:number, white_ratio:number, unique:number}}
|
|
44
|
+
*/
|
|
45
|
+
function statsFromBitmap(bitmap) {
|
|
46
|
+
if (!bitmap || !bitmap.length) {
|
|
47
|
+
return { total: 0, white: 0, white_ratio: 0, unique: 0 };
|
|
48
|
+
}
|
|
49
|
+
const total = Math.floor(bitmap.length / BPP);
|
|
50
|
+
let white = 0;
|
|
51
|
+
// 唯一色用 Set 存打包后的 24 位整数。纯白图只有 1–3 个元素,正常页面几千个。
|
|
52
|
+
// 数过 UNIQUE_MAX 就不再往 Set 里塞(内存有界),但白色像素要继续数到最后一个 ——
|
|
53
|
+
// 提前 break 会让 white_ratio 变成"扫过的部分"的比例,回执里的数字就成了假话。
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
let overflow = false;
|
|
56
|
+
for (let i = 0; i + 3 < bitmap.length; i += BPP) {
|
|
57
|
+
const b = bitmap[i], g = bitmap[i + 1], r = bitmap[i + 2];
|
|
58
|
+
if (r >= WHITE_PIXEL && g >= WHITE_PIXEL && b >= WHITE_PIXEL) white++;
|
|
59
|
+
if (!overflow) {
|
|
60
|
+
seen.add((r << 16) | (g << 8) | b);
|
|
61
|
+
if (seen.size > UNIQUE_MAX) overflow = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
total,
|
|
66
|
+
white,
|
|
67
|
+
white_ratio: total ? white / total : 0,
|
|
68
|
+
// pinned = "颜色数已超过上限,这里只是下限"。回执里要如实标注,
|
|
69
|
+
// 否则一个信息丰富的页面会被显示成"33 种颜色"。
|
|
70
|
+
unique: seen.size,
|
|
71
|
+
unique_pinned: overflow,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 判定一帧画面。
|
|
77
|
+
*
|
|
78
|
+
* @param {object} p
|
|
79
|
+
* @param {{total:number,white:number,white_ratio:number,unique:number}} p.stats
|
|
80
|
+
* @param {{text_len:number, nodes:number, ready_state:string}} p.dom 页面自述的事实
|
|
81
|
+
* @param {{width:number,height:number}} [p.bounds] 视图当前尺寸
|
|
82
|
+
* @returns {{verdict:string, blank:boolean, reason:string}}
|
|
83
|
+
*
|
|
84
|
+
* verdict:
|
|
85
|
+
* ok 有内容(正常)
|
|
86
|
+
* empty-legit 这页本来就是空的(about:blank / 空文档),白是正确结果
|
|
87
|
+
* not-ready 页面还没到 interactive/complete,白是暂时的,等它
|
|
88
|
+
* collapsed 视图本身被压成 0 高/0 宽 —— 不是没画,是没地方画
|
|
89
|
+
* blank-with-text DOM 里有文字,但屏幕上纯白 —— **真白屏**
|
|
90
|
+
* blank-with-dom DOM 有几十个节点却纯白 —— **真白屏**(内容层失败)
|
|
91
|
+
* no-frame 拿不到位图(视图不可见/被销毁),无法判定
|
|
92
|
+
*/
|
|
93
|
+
function classifyPaint({ stats, dom, bounds } = {}) {
|
|
94
|
+
const d = dom || {};
|
|
95
|
+
const textLen = Number(d.text_len) || 0;
|
|
96
|
+
const nodes = Number(d.nodes) || 0;
|
|
97
|
+
const ready = String(d.ready_state || '');
|
|
98
|
+
|
|
99
|
+
if (bounds && (Number(bounds.width) <= 0 || Number(bounds.height) <= 0)) {
|
|
100
|
+
return { verdict: 'collapsed', blank: true, reason: `view is ${bounds.width}x${bounds.height} — nothing to paint into` };
|
|
101
|
+
}
|
|
102
|
+
if (!stats || !stats.total) {
|
|
103
|
+
return { verdict: 'no-frame', blank: false, reason: 'no bitmap (view not visible or destroyed)' };
|
|
104
|
+
}
|
|
105
|
+
const isWhite = stats.white_ratio >= WHITE_MIN_RATIO && stats.unique <= UNIQUE_MAX;
|
|
106
|
+
const colourDesc = `${stats.unique}${stats.unique_pinned ? '+' : ''} colours`;
|
|
107
|
+
if (ready && ready !== 'interactive' && ready !== 'complete') {
|
|
108
|
+
// 还没就绪时"白"是暂时的,不是缺陷 —— blank 字段仍要如实反映画面。
|
|
109
|
+
return { verdict: 'not-ready', blank: isWhite, reason: `document.readyState = ${ready}` };
|
|
110
|
+
}
|
|
111
|
+
if (!isWhite) {
|
|
112
|
+
return { verdict: 'ok', blank: false, reason: `${Math.round(stats.white_ratio * 100)}% white, ${colourDesc}` };
|
|
113
|
+
}
|
|
114
|
+
// 到这里:屏幕上确实是纯白。白得对不对,只能问 DOM。
|
|
115
|
+
if (textLen === 0 && nodes <= 2) {
|
|
116
|
+
return { verdict: 'empty-legit', blank: false, reason: 'empty document — white is the correct result' };
|
|
117
|
+
}
|
|
118
|
+
if (textLen > 0) {
|
|
119
|
+
return { verdict: 'blank-with-text', blank: true, reason: `${textLen} chars of text in the DOM but the screen is white` };
|
|
120
|
+
}
|
|
121
|
+
return { verdict: 'blank-with-dom', blank: true, reason: `${nodes} DOM nodes but the screen is white` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 白屏了该怎么救 —— 阶梯,且有上限。
|
|
126
|
+
*
|
|
127
|
+
* 为什么要有上限:救不动的时候(比如页面 CSS 自己把文字刷成白色),reload 一百次
|
|
128
|
+
* 也还是白。无上限重试会变成一个看不见的循环:CPU 烧着、页面反复重载、
|
|
129
|
+
* 用户看到的仍是白屏,而且没有任何地方说过"我试过了、没用"。
|
|
130
|
+
*
|
|
131
|
+
* @param {{verdict:string, steps:number}} p steps = 已执行过的补救次数
|
|
132
|
+
* @returns {'none'|'repaint'|'reload'|'give-up'}
|
|
133
|
+
*/
|
|
134
|
+
function decidePaintAction({ verdict, steps = 0 } = {}) {
|
|
135
|
+
// ok / empty-legit / not-ready / no-frame 都不该动。not-ready 尤其重要:
|
|
136
|
+
// 首屏那一刻本来就可能是白的,这时候 reload 等于把正在下载的页面打断。
|
|
137
|
+
if (verdict === 'ok' || verdict === 'empty-legit' || verdict === 'not-ready' || verdict === 'no-frame') {
|
|
138
|
+
return 'none';
|
|
139
|
+
}
|
|
140
|
+
if (steps <= 0) return 'repaint'; // 第一步:remove+add 逼合成器重画(代价最小、不丢状态)
|
|
141
|
+
if (steps === 1) return 'reload'; // 第二步:重载(会丢页面临时状态,所以排在重画之后)
|
|
142
|
+
return 'give-up'; // 第三步:如实上报,不再折腾
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 位图统计的阈值(供测试与回执引用,避免两处写死)。 */
|
|
146
|
+
const THRESHOLDS = { WHITE_MIN_RATIO, WHITE_PIXEL, UNIQUE_MAX };
|
|
147
|
+
|
|
148
|
+
module.exports = { statsFromBitmap, classifyPaint, decidePaintAction, THRESHOLDS };
|
package/src/shared/protocol.js
CHANGED
|
@@ -65,6 +65,10 @@ export const VALID_METHODS = [
|
|
|
65
65
|
'ui.screenshot',
|
|
66
66
|
'ui.navigate',
|
|
67
67
|
'ui.repaint',
|
|
68
|
+
// "这一页到底画出来了没有"—— 只读观测,heal:true 才走自救阶梯。
|
|
69
|
+
// 与 ui.repaint 的区别:repaint 是"外面的人说窗口不对,重画一次";
|
|
70
|
+
// paint_check 是"工具自己看一眼,白的话按阶梯救,救不动就如实上报"。
|
|
71
|
+
'ui.paint_check',
|
|
68
72
|
'ui.snapshot',
|
|
69
73
|
'ui.diff',
|
|
70
74
|
'ui.capabilities',
|
package/src/shared/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// Kept in JS (not imported from package.json) to avoid Node import-attribute
|
|
3
3
|
// (JSON module) compatibility issues inside Electron. package.json "version"
|
|
4
4
|
// is manually synced to this value; tests assert they match.
|
|
5
|
-
export const VERSION = '2.1.
|
|
5
|
+
export const VERSION = '2.1.13';
|
|
@@ -54,20 +54,22 @@ export function wrapperHintExpr(aiId) {
|
|
|
54
54
|
// 而这恰恰就是要找的那个链接。退一步用"8 字窗口命中":只要候选文字里有连续
|
|
55
55
|
// 8 个字出现在容器文字里,就算相关 —— 中文 8 个字已经足够特异。
|
|
56
56
|
function related(aggText, t) {
|
|
57
|
-
if (aggText.indexOf(t) >= 0) return
|
|
58
|
-
if (t.length < 8) return
|
|
57
|
+
if (aggText.indexOf(t) >= 0) return 2; // 整串命中:最可信
|
|
58
|
+
if (t.length < 8) return 0;
|
|
59
59
|
for (var k = 0; k + 8 <= t.length; k++) {
|
|
60
|
-
if (aggText.indexOf(t.slice(k, k + 8)) >= 0) return
|
|
60
|
+
if (aggText.indexOf(t.slice(k, k + 8)) >= 0) return 1;
|
|
61
61
|
}
|
|
62
|
-
return
|
|
62
|
+
return 0;
|
|
63
63
|
}
|
|
64
|
+
var viewH = window.innerHeight || 800;
|
|
64
65
|
for (var i = 0; i < limit; i++) {
|
|
65
66
|
var c = cands[i];
|
|
66
67
|
if (c === el) continue;
|
|
67
68
|
if (c.contains(el)) continue; // 目标的祖先:点它等于又点一次外层
|
|
68
69
|
var t = ownText(c);
|
|
69
70
|
if (t.length < 6) continue;
|
|
70
|
-
|
|
71
|
+
var match = related(agg, t);
|
|
72
|
+
if (!match) continue;
|
|
71
73
|
var r = c.getBoundingClientRect();
|
|
72
74
|
if (r.width < 4 || r.height < 4) continue;
|
|
73
75
|
var cs = getComputedStyle(c);
|
|
@@ -76,8 +78,23 @@ export function wrapperHintExpr(aiId) {
|
|
|
76
78
|
var role = String((c.getAttribute && c.getAttribute('role')) || '');
|
|
77
79
|
var clickable = (tag === 'A' || tag === 'BUTTON' || role === 'link' || role === 'button' || cs.cursor === 'pointer');
|
|
78
80
|
var area = Math.max(1, Math.round(r.width) * Math.round(r.height));
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
+
// 排序必须按"相关性"而不是"谁小":
|
|
82
|
+
// ① 整串命中优先于 8 字窗口命中 —— 知乎那边同一条私信里躺着第一期到第五十四期
|
|
83
|
+
// 五个链接,它们的 8 字窗口全都命中"创作打卡挑战赛",只按面积排会挑中
|
|
84
|
+
// 面积最小的那一期(实测挑成了第五十期,还滚在视口外 4500px);
|
|
85
|
+
// ② 视口内优先 —— 用户看到并想点的是屏幕上那个;
|
|
86
|
+
// ③ 能点的优先;
|
|
87
|
+
// ④ 最后才用面积(越贴近那段文字越精确)。
|
|
88
|
+
var inView = (r.y + r.height > 0 && r.y < viewH) ? 0 : 1;
|
|
89
|
+
// 更长的那段文字更具体:同一段预览里"创作打卡挑战赛"和"创作打卡挑战赛第五十四期"
|
|
90
|
+
// 都是整串命中,但后者才是用户想点的那一期。
|
|
91
|
+
var specificity = 200 - Math.min(199, t.length);
|
|
92
|
+
// 排序:整串命中 > 能点 > 更具体(文字更长)> 视口内 > 面积小。
|
|
93
|
+
// 为什么"更具体"排在"视口内"前面:同一段预览里的第一期…第五十四期五个链接,
|
|
94
|
+
// 整串命中的只有对的那一期,即使它在视口外也值得点(点击前会 scrollIntoView);
|
|
95
|
+
// 反过来点一个"看得见但期数错了"的链接,是把人带到错误页面。
|
|
96
|
+
var score = (2 - match) * 1e15 + (clickable ? 0 : 1e13)
|
|
97
|
+
+ specificity * 1e9 + inView * 1e8 + Math.min(area, 99999);
|
|
81
98
|
if (!best || score < best.score) {
|
|
82
99
|
best = {
|
|
83
100
|
score: score,
|
|
@@ -87,6 +104,7 @@ export function wrapperHintExpr(aiId) {
|
|
|
87
104
|
text: t.slice(0, 60),
|
|
88
105
|
box: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
|
|
89
106
|
clickable: clickable,
|
|
107
|
+
exact: match === 2,
|
|
90
108
|
};
|
|
91
109
|
}
|
|
92
110
|
}
|