feat: 内置受管浏览器(CDP + AX 快照 ref 体系)+ 本地浏览器配置导入

This commit is contained in:
Rick Ye
2026-09-07 11:20:51 +08:00
parent 5dd645b745
commit eafbe8c15d
16 changed files with 2490 additions and 37 deletions
@@ -0,0 +1,120 @@
/**
* 受管浏览器的 URL 与观察策略。
* 独立成模块便于单测与复用(渲染层不需要,主进程专用)。
*/
const SEARCH_URL = 'https://www.google.com/search?q='
/** 导航白名单:普通 web、本地开发服务与 about:blank;file:/javascript:/data: 一律拒绝 */
export function assertSafeBrowserUrl(input: string): string {
const value = input.trim()
if (!value) throw new Error('URL 不能为空。')
if (value === 'about:blank') return value
let parsed: URL
try {
parsed = new URL(value)
} catch {
throw new Error(`无法解析的 URL: ${value}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`不支持的地址协议(仅允许 http/https 与 about:blank): ${parsed.protocol}`)
}
return parsed.toString()
}
function isLikelyUrl(value: string): boolean {
const candidate = `http://${value}`
try {
const url = new URL(candidate)
const host = url.hostname.toLowerCase()
// 域名 / IP(v4/v6)/ localhost 与 *.localhost 视为直达地址
const isHost =
host === 'localhost' ||
host.endsWith('.localhost') ||
host.includes('.') ||
host.includes(':')
// 无空格且形如域名/IP/localhost
return isHost && !/\s/.test(value)
} catch {
return false
}
}
/** 输入归一化:URL/域名/IP/localhost 直达,普通文本走搜索 */
export function resolveBrowserDestination(input: string): string {
const value = input.trim()
if (!value) throw new Error('地址不能为空。')
if (value === 'about:blank') return value
if (/^https?:\/\//i.test(value)) return assertSafeBrowserUrl(value)
if (isLikelyUrl(value)) return assertSafeBrowserUrl(`https://${value}`)
return SEARCH_URL + encodeURIComponent(value)
}
// ---- Observe 观察策略 ----
export const DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS = 240
export const MAX_BROWSER_OBSERVE_MAX_ELEMENTS = 400
export const MIN_BROWSER_OBSERVE_MAX_ELEMENTS = 20
/** 可交互 AX role 白名单(占观察结果前 2/3 配额) */
export const INTERACTIVE_AX_ROLES = new Set([
'button',
'link',
'textfield',
'textbox',
'searchbox',
'checkbox',
'radiobutton',
'combobox',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'tab',
'option',
'switch',
'slider'
])
/** 无名称也值得返回的 role(依赖 role 本身可定位) */
export const NAMELESS_ALLOWED_ROLES = new Set(['button', 'textfield', 'textbox', 'link', 'checkbox', 'combobox'])
export function resolveBrowserObserveMaxElements(requested?: number): number {
if (typeof requested !== 'number' || !Number.isFinite(requested)) return DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS
return Math.min(MAX_BROWSER_OBSERVE_MAX_ELEMENTS, Math.max(MIN_BROWSER_OBSERVE_MAX_ELEMENTS, Math.round(requested)))
}
export function isInteractiveAxRole(role: string): boolean {
return INTERACTIVE_AX_ROLES.has(role.toLowerCase())
}
export interface BrowserObservationCandidate {
role: string
name: string
editable?: boolean
}
/** 可交互元素优先(约 2/3 配额),其余语义上下文补足 */
export function prioritizeBrowserObservationCandidates<T extends BrowserObservationCandidate>(
candidates: readonly T[],
maxElements: number
): T[] {
const interactiveLimit = Math.ceil(maxElements * (2 / 3))
const interactive: T[] = []
const context: T[] = []
for (const candidate of candidates) {
if (interactive.length < interactiveLimit && (isInteractiveAxRole(candidate.role) || candidate.editable)) {
interactive.push(candidate)
} else {
context.push(candidate)
}
}
const contextLimit = maxElements - interactive.length
return [...interactive, ...context.slice(0, Math.max(0, contextLimit))]
}
/** 不同 role 的可访问名称截断上限,避免长文本撑爆快照 */
export function browserObservationNameLimit(role: string): number {
if (role === 'link' || role === 'button' || role === 'menuitem') return 80
if (role === 'heading') return 120
return 160
}