Files
goodbuddy/src/main/browser/cdp-browser-driver.ts
T

919 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, randomBytes } from 'node:crypto'
import type {
BrowserDebugger,
BrowserEventListener,
BrowserWebContents
} from './electron-browser-session'
import {
BROWSER_JPEG_QUALITIES,
isValidBrowserJpeg,
MAX_BROWSER_SCREENSHOT_BYTES,
type BrowserScreenshot
} from './browser-screenshot'
import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
} from './browser-limits'
const DEFAULT_TIMEOUT_MS = 15_000
const MAX_AX_NODES = 500
const MAX_AX_DEPTH = 20
const MAX_SNAPSHOT_BYTES = 128 * 1024
const SELECT_OPTION_FUNCTION = `function (expectedValue) {
const options = Array.from(this.options);
const option = options.find((candidate) => candidate.value === expectedValue);
if (!option) {
return { selected: false, value: this.value };
}
this.value = expectedValue;
const selected = this.value === expectedValue;
if (selected) {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}
return { selected, value: this.value };
}`
type CdpAxValue = {
type?: string
value?: unknown
}
type CdpAxProperty = {
name?: string
value?: CdpAxValue
}
type CdpAxNode = {
nodeId?: string
backendDOMNodeId?: number
parentId?: string
ignored?: boolean
role?: CdpAxValue
name?: CdpAxValue
value?: CdpAxValue
properties?: CdpAxProperty[]
}
export type BrowserSnapshotNode = {
ref: string
role: string
name: string
value?: string
disabled?: boolean
focused?: boolean
editable?: boolean
}
export type BrowserSnapshot = {
url: string
title: string
nodes: BrowserSnapshotNode[]
truncated: boolean
}
export class BrowserStaleReferenceError extends Error {
constructor(message = '浏览器元素引用已失效,请重新获取快照') {
super(message)
this.name = 'BrowserStaleReferenceError'
}
}
export type BrowserHistoryTarget = {
entryId: number
url: string
}
type RefBinding = {
backendNodeId: number
generation: number
role: string
}
export type CdpBrowserDriverOptions = {
timeoutMs?: number
maximumAxNodes?: number
maximumAxDepth?: number
maximumSnapshotBytes?: number
maximumScreenshotBytes?: number
}
type ResolvedTarget = {
backendNodeId: number
bounds: { x: number; y: number; width: number; height: number }
}
type NavigationWait = {
promise: Promise<void>
cancel(error: unknown): void
}
function stringValue(value: CdpAxValue | undefined): string {
return typeof value?.value === 'string'
? value.value.slice(0, 2_000)
: value?.value === undefined
? ''
: String(value.value).slice(0, 2_000)
}
function propertyBoolean(
node: CdpAxNode,
name: string
): boolean | undefined {
const value = node.properties?.find((property) => property.name === name)?.value
?.value
return typeof value === 'boolean' ? value : undefined
}
function isProtectedAxNode(node: CdpAxNode): boolean {
const role = stringValue(node.role).toLowerCase()
const properties = new Map(
node.properties?.map((property) => [
property.name,
property.value?.value
])
)
return (
role === 'password' ||
properties.get('hidden') === true ||
properties.get('protected') === true ||
properties.get('valuetext') === '••••••••'
)
}
function delayAbortable(
milliseconds: number,
signal: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(finish, milliseconds)
function finish(): void {
signal.removeEventListener('abort', abort)
resolve()
}
function abort(): void {
clearTimeout(timer)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
}
})
}
function isTransientNavigationError(error: unknown): boolean {
let current = error
for (let depth = 0; depth < 4; depth += 1) {
if (!(current instanceof Error)) {
return false
}
if (
/Inspected target navigated|Execution context was destroyed|Cannot find context/iu.test(
current.message
)
) {
return true
}
current = current.cause
}
return false
}
function isPlaceholderSnapshot(snapshot: BrowserSnapshot): boolean {
return (
snapshot.title.length === 0 &&
snapshot.nodes.length <= 1 &&
snapshot.nodes.every(
(node) =>
node.role.toLowerCase() === 'rootwebarea' &&
node.name.length === 0
)
)
}
export class CdpBrowserDriver {
private readonly debugger: BrowserDebugger
private readonly timeoutMs: number
private readonly maximumAxNodes: number
private readonly maximumAxDepth: number
private readonly maximumSnapshotBytes: number
private readonly maximumScreenshotBytes: number
private readonly refSecret = randomBytes(16)
private readonly refs = new Map<string, RefBinding>()
private readonly listeners: Array<{
target: { off(event: string, listener: BrowserEventListener): unknown }
event: string
listener: BrowserEventListener
}> = []
private readonly navigationCancels = new Set<
(error: unknown) => void
>()
private generation = 0
private disposed = false
constructor(
private readonly webContents: BrowserWebContents,
options: CdpBrowserDriverOptions = {}
) {
this.debugger = webContents.debugger
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
this.maximumAxNodes = options.maximumAxNodes ?? MAX_AX_NODES
this.maximumAxDepth = options.maximumAxDepth ?? MAX_AX_DEPTH
this.maximumSnapshotBytes =
options.maximumSnapshotBytes ?? MAX_SNAPSHOT_BYTES
this.maximumScreenshotBytes =
options.maximumScreenshotBytes ?? MAX_BROWSER_SCREENSHOT_BYTES
this.listen(
webContents,
'did-start-navigation',
(
_event: unknown,
_url: string,
_isInPlace: boolean,
isMainFrame: boolean | undefined
) => {
if (isMainFrame !== false) {
this.invalidate()
}
}
)
this.listen(
webContents,
'will-redirect',
(
_event: unknown,
_url: string,
_isInPlace: boolean,
isMainFrame: boolean | undefined
) => {
if (isMainFrame !== false) {
this.invalidate()
}
}
)
this.listen(webContents, 'render-process-gone', () => this.invalidate())
this.listen(this.debugger, 'detach', () => this.invalidate())
}
private listen(
target: {
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
},
event: string,
listener: BrowserEventListener
): void {
target.on(event, listener)
this.listeners.push({ target, event, listener })
}
private invalidate(): void {
this.generation += 1
this.refs.clear()
}
private command<T>(
method: string,
parameters: Record<string, unknown> | undefined,
signal: AbortSignal
): Promise<T> {
if (this.disposed || !this.debugger.isAttached()) {
return Promise.reject(new Error('浏览器调试连接不可用'))
}
signal.throwIfAborted()
const timeout = AbortSignal.timeout(this.timeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器操作超时(${this.timeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void this.debugger
.sendCommand(method, parameters)
.then((result) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
abort()
} else {
resolve(result as T)
}
})
.catch((error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(new Error(`浏览器命令失败:${method}`, { cause: error }))
})
})
}
async navigate(url: string, signal: AbortSignal): Promise<{ url: string }> {
this.invalidate()
const navigation = this.waitForMainFrameCommit(url, signal)
let result: {
errorText?: string
isDownload?: boolean
}
try {
result = await this.command<{
errorText?: string
isDownload?: boolean
}>('Page.navigate', { url }, signal)
} catch (error) {
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
if (result.errorText) {
const error = new Error(
`浏览器导航失败:${result.errorText.slice(0, 200)}`
)
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
if (result.isDownload) {
const error = new Error('浏览器导航目标是下载文件,未打开页面')
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
await navigation.promise
await this.waitForDocument(signal)
return { url: this.webContents.getURL() || url }
}
private waitForMainFrameCommit(
targetUrl: string,
signal: AbortSignal
): NavigationWait {
let settle:
| { resolve(): void; reject(error: unknown): void }
| undefined
const promise = new Promise<void>((resolve, reject) => {
settle = { resolve, reject }
})
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
signal.removeEventListener('abort', onAbort)
this.webContents.off('did-navigate', onNavigate)
this.webContents.off('did-navigate-in-page', onNavigateInPage)
this.webContents.off('did-fail-load', onFailLoad)
this.webContents.off('render-process-gone', onRenderGone)
this.navigationCancels.delete(reject)
}
const resolve = (): void => {
if (settled) {
return
}
settled = true
cleanup()
settle?.resolve()
}
const reject = (error: unknown): void => {
if (settled) {
return
}
settled = true
cleanup()
settle?.reject(error)
}
const onNavigate = (_event: unknown, committedUrl: string): void => {
if (
targetUrl !== 'about:blank' &&
committedUrl === 'about:blank'
) {
return
}
resolve()
}
const onNavigateInPage = (
_event: unknown,
committedUrl: string,
isMainFrame: boolean | undefined
): void => {
if (
isMainFrame === false ||
(targetUrl !== 'about:blank' &&
committedUrl === 'about:blank')
) {
return
}
resolve()
}
const onFailLoad = (
_event: unknown,
errorCode: number,
errorDescription: string,
failedUrl: string,
isMainFrame: boolean | undefined
): void => {
if (isMainFrame === false) {
return
}
reject(
new Error(
`浏览器导航失败:${String(errorDescription || errorCode).slice(0, 160)}${failedUrl ? `${failedUrl.slice(0, 500)}` : ''}`
)
)
}
const onRenderGone = (): void =>
reject(new Error('浏览器渲染进程在页面提交前退出'))
const onAbort = (): void => reject(signal.reason)
const timer = setTimeout(
() =>
reject(
new Error(`浏览器页面未在安全期限内提交(${this.timeoutMs}ms`)
),
this.timeoutMs
)
this.webContents.on('did-navigate', onNavigate)
this.webContents.on('did-navigate-in-page', onNavigateInPage)
this.webContents.on('did-fail-load', onFailLoad)
this.webContents.on('render-process-gone', onRenderGone)
this.navigationCancels.add(reject)
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
}
return { promise, cancel: reject }
}
private async waitForDocument(signal: AbortSignal): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const result = await this.command<{
result?: { value?: string }
}>(
'Runtime.evaluate',
{
expression: 'document.readyState',
returnByValue: true,
awaitPromise: false
},
signal
)
if (
result.result?.value === 'interactive' ||
result.result?.value === 'complete'
) {
return
}
await delayAbortable(50, signal)
}
throw new Error('浏览器页面未在安全期限内就绪')
}
private refFor(backendNodeId: number): string {
return `b_${createHash('sha256')
.update(this.refSecret)
.update(String(this.generation))
.update(':')
.update(String(backendNodeId))
.digest('base64url')
.slice(0, 18)}`
}
async snapshot(signal: AbortSignal): Promise<BrowserSnapshot> {
let lastError: unknown
for (let attempt = 0; attempt < 5; attempt += 1) {
const expectedGeneration = this.generation + 1
try {
const snapshot = await this.snapshotOnce(signal)
if (attempt < 4 && isPlaceholderSnapshot(snapshot)) {
await delayAbortable(500, signal)
continue
}
return snapshot
} catch (error) {
lastError = error
if (
attempt === 4 ||
(this.generation === expectedGeneration &&
!isTransientNavigationError(error))
) {
throw error
}
await delayAbortable(100, signal)
}
}
throw lastError
}
private async snapshotOnce(
signal: AbortSignal
): Promise<BrowserSnapshot> {
this.invalidate()
const snapshotGeneration = this.generation
const response = await this.command<{ nodes?: CdpAxNode[] }>(
'Accessibility.getFullAXTree',
{ depth: this.maximumAxDepth },
signal
)
const document = await this.command<{
result?: { value?: { title?: unknown; url?: unknown } }
}>(
'Runtime.evaluate',
{
expression: '({title: document.title, url: location.href})',
returnByValue: true,
awaitPromise: false
},
signal
)
if (this.generation !== snapshotGeneration) {
throw new Error('浏览器页面在生成快照时发生变化,请重试')
}
const title =
typeof document.result?.value?.title === 'string'
? document.result.value.title.slice(0, 500)
: ''
const url =
typeof document.result?.value?.url === 'string'
? document.result.value.url.slice(0, 8_192)
: this.webContents.getURL()
const allNodes = response.nodes ?? []
const limited = allNodes.slice(0, this.maximumAxNodes)
const knownDepth = new Map<string, number>()
const output: BrowserSnapshotNode[] = []
let outputBytes = Buffer.byteLength(
JSON.stringify({ url, title, nodes: [], truncated: false })
)
let truncated = allNodes.length > limited.length
for (const node of limited) {
const parentDepth = node.parentId
? knownDepth.get(node.parentId)
: -1
const depth = (parentDepth ?? this.maximumAxDepth) + 1
if (node.nodeId) {
knownDepth.set(node.nodeId, depth)
}
if (
depth > this.maximumAxDepth ||
node.ignored ||
!node.backendDOMNodeId
) {
continue
}
const role = stringValue(node.role) || 'unknown'
const ref = this.refFor(node.backendDOMNodeId)
const protectedNode = isProtectedAxNode(node)
const item: BrowserSnapshotNode = {
ref,
role,
name: stringValue(node.name),
disabled: propertyBoolean(node, 'disabled'),
focused: propertyBoolean(node, 'focused'),
editable: propertyBoolean(node, 'editable')
}
const value = stringValue(node.value)
const redactedValue =
protectedNode ||
item.editable === true ||
['combobox', 'searchbox', 'spinbutton', 'textbox'].includes(
role.toLowerCase()
)
if (value && !redactedValue) {
item.value = value
}
const itemBytes =
Buffer.byteLength(JSON.stringify(item)) +
(output.length > 0 ? 1 : 0)
if (outputBytes + itemBytes > this.maximumSnapshotBytes) {
truncated = true
continue
}
outputBytes += itemBytes
this.refs.set(ref, {
backendNodeId: node.backendDOMNodeId,
generation: this.generation,
role
})
output.push(item)
}
return {
url,
title,
nodes: output,
truncated
}
}
private async resolveTarget(
ref: string,
action: 'click' | 'type' | 'select',
signal: AbortSignal
): Promise<ResolvedTarget> {
const binding = this.refs.get(ref)
if (!binding || binding.generation !== this.generation) {
throw new BrowserStaleReferenceError()
}
const described = await this.command<{
node?: Record<string, unknown> & {
attributes?: unknown
nodeName?: unknown
backendNodeId?: unknown
}
}>(
'DOM.describeNode',
{
backendNodeId: binding.backendNodeId,
depth: 0,
pierce: false
},
signal
)
const node = described.node
if (!node || node.backendNodeId !== binding.backendNodeId) {
throw new Error('浏览器元素状态已改变,请重新获取快照')
}
const attributes = Array.isArray(node.attributes)
? node.attributes.filter(
(value): value is string => typeof value === 'string'
)
: []
const attributeMap = new Map<string, string>()
for (let index = 0; index + 1 < attributes.length; index += 2) {
attributeMap.set(
(attributes[index] ?? '').toLowerCase(),
attributes[index + 1] ?? ''
)
}
const nodeName =
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
const blocked =
attributeMap.has('hidden') ||
attributeMap.has('disabled') ||
attributeMap.has('inert') ||
attributeMap.has('readonly') ||
attributeMap.get('aria-hidden') === 'true' ||
attributeMap.get('aria-disabled') === 'true' ||
inputType === 'hidden' ||
inputType === 'file'
if (blocked) {
throw new Error('浏览器拒绝操作隐藏、禁用、只读或文件字段')
}
if (
action === 'type' &&
!(
nodeName === 'textarea' ||
nodeName === 'input' ||
attributeMap.get('contenteditable') === 'true'
)
) {
throw new Error('浏览器目标不是可编辑字段')
}
if (action === 'select' && nodeName !== 'select') {
throw new Error('浏览器目标不是选择控件')
}
const model = await this.command<{
model?: {
content?: number[]
border?: number[]
}
}>(
'DOM.getBoxModel',
{ backendNodeId: binding.backendNodeId },
signal
)
const quad = model.model?.content ?? model.model?.border
if (!quad || quad.length !== 8 || quad.some((value) => !Number.isFinite(value))) {
throw new Error('浏览器元素不可见或没有有效边界')
}
const xs = [quad[0] ?? 0, quad[2] ?? 0, quad[4] ?? 0, quad[6] ?? 0]
const ys = [quad[1] ?? 0, quad[3] ?? 0, quad[5] ?? 0, quad[7] ?? 0]
const x = Math.min(...xs)
const y = Math.min(...ys)
const width = Math.max(...xs) - x
const height = Math.max(...ys) - y
const metrics = await this.command<{
cssVisualViewport?: { clientWidth?: number; clientHeight?: number }
layoutViewport?: { clientWidth?: number; clientHeight?: number }
}>('Page.getLayoutMetrics', undefined, signal)
const viewport = metrics.cssVisualViewport ?? metrics.layoutViewport
const viewportWidth = viewport?.clientWidth ?? 0
const viewportHeight = viewport?.clientHeight ?? 0
if (
width < 1 ||
height < 1 ||
x < 0 ||
y < 0 ||
x + width > viewportWidth ||
y + height > viewportHeight
) {
throw new Error('浏览器元素超出当前可见页面边界')
}
return {
backendNodeId: binding.backendNodeId,
bounds: { x, y, width, height }
}
}
async click(ref: string, signal: AbortSignal): Promise<void> {
const target = await this.resolveTarget(ref, 'click', signal)
const x = target.bounds.x + target.bounds.width / 2
const y = target.bounds.y + target.bounds.height / 2
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseMoved', x, y },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mousePressed', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 },
signal
)
}
async type(
ref: string,
text: string,
signal: AbortSignal
): Promise<void> {
if (!text || text.length > MAX_INPUT_LENGTH) {
throw new Error('浏览器输入内容为空或超过安全限制')
}
const target = await this.resolveTarget(ref, 'type', signal)
const x = target.bounds.x + target.bounds.width / 2
const y = target.bounds.y + target.bounds.height / 2
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mousePressed', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command('Input.insertText', { text }, signal)
}
async select(
ref: string,
value: string,
signal: AbortSignal
): Promise<void> {
if (!value || value.length > MAX_SELECT_LENGTH) {
throw new Error('浏览器选择值为空或超过安全限制')
}
const target = await this.resolveTarget(ref, 'select', signal)
const resolved = await this.command<{
object?: { objectId?: string }
}>(
'DOM.resolveNode',
{ backendNodeId: target.backendNodeId },
signal
)
const objectId = resolved.object?.objectId
if (!objectId) {
throw new Error('浏览器选择控件已失效')
}
try {
const result = await this.command<{
exceptionDetails?: unknown
result?: {
value?: { selected?: unknown; value?: unknown }
}
}>(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: SELECT_OPTION_FUNCTION,
arguments: [{ value }],
returnByValue: true,
awaitPromise: false,
silent: true
},
signal
)
if (
result.exceptionDetails ||
result.result?.value?.selected !== true ||
result.result.value.value !== value
) {
throw new Error('浏览器未找到完全匹配的选择项')
}
} finally {
await this.command(
'Runtime.releaseObject',
{ objectId },
new AbortController().signal
).catch(() => undefined)
}
}
async getBackTarget(signal: AbortSignal): Promise<BrowserHistoryTarget> {
const history = await this.command<{
currentIndex?: number
entries?: Array<{ id?: number; url?: string }>
}>('Page.getNavigationHistory', undefined, signal)
const index = history.currentIndex ?? -1
const entry = history.entries?.[index - 1]
if (
index < 1 ||
typeof entry?.id !== 'number' ||
typeof entry.url !== 'string' ||
entry.url.length === 0 ||
entry.url.length > 8_192
) {
throw new Error('浏览器没有可返回的页面')
}
return { entryId: entry.id, url: entry.url }
}
async backTo(
target: BrowserHistoryTarget,
signal: AbortSignal
): Promise<{ url: string }> {
const current = await this.getBackTarget(signal)
if (current.entryId !== target.entryId || current.url !== target.url) {
throw new Error('浏览器历史记录已改变,请重试')
}
this.invalidate()
const navigation = this.waitForMainFrameCommit(target.url, signal)
try {
await this.command(
'Page.navigateToHistoryEntry',
{ entryId: target.entryId },
signal
)
} catch (error) {
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
await navigation.promise
await this.waitForDocument(signal)
return { url: this.webContents.getURL() }
}
async back(signal: AbortSignal): Promise<{ url: string }> {
return this.backTo(await this.getBackTarget(signal), signal)
}
async screenshot(signal: AbortSignal): Promise<BrowserScreenshot> {
for (const quality of BROWSER_JPEG_QUALITIES) {
const result = await this.command<{ data?: string }>(
'Page.captureScreenshot',
{
format: 'jpeg',
quality,
fromSurface: true,
captureBeyondViewport: false
},
signal
)
if (
typeof result.data !== 'string' ||
result.data.length === 0 ||
result.data.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
result.data
)
) {
throw new Error('浏览器返回了无效截图')
}
const data = Buffer.from(result.data, 'base64')
if (
data.toString('base64') !== result.data ||
!isValidBrowserJpeg(data)
) {
throw new Error('浏览器截图无效')
}
if (data.byteLength <= this.maximumScreenshotBytes) {
return {
type: 'image',
mimeType: 'image/jpeg',
data: result.data
}
}
}
throw new Error('浏览器截图超过约 220KB 限制')
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
this.invalidate()
for (const cancel of this.navigationCancels) {
cancel(new Error('浏览器驱动已关闭'))
}
this.navigationCancels.clear()
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
}
}