124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
/**
|
||
* 受管浏览器的 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<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
|
||
}
|