/** * 受管浏览器的 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']) /** browser_find_text 单次返回的候选上限(DOM 直查不受快照配额约束,巨页防爆量) */ export const MAX_BROWSER_FIND_TEXT_RESULTS = 40 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( 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 }