feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
+797
View File
@@ -0,0 +1,797 @@
import { createHash, randomBytes } from 'node:crypto'
import type {
BrowserDebugger,
BrowserEventListener,
BrowserWebContents
} from './electron-browser-session'
const DEFAULT_TIMEOUT_MS = 15_000
const MAX_AX_NODES = 500
const MAX_AX_DEPTH = 20
const MAX_AX_BYTES = 1024 * 1024
const MAX_SNAPSHOT_BYTES = 128 * 1024
const MAX_SCREENSHOT_BYTES = 512 * 1024
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
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 type BrowserScreenshot = {
type: 'image'
mimeType: 'image/png'
data: string
}
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
protected: boolean
}
export type CdpBrowserDriverOptions = {
timeoutMs?: number
maximumAxNodes?: number
maximumAxDepth?: number
maximumAxBytes?: number
maximumSnapshotBytes?: number
maximumScreenshotBytes?: number
}
type ResolvedTarget = {
backendNodeId: number
bounds: { x: number; y: number; width: number; height: number }
}
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 jsonStringBytes(value: string): number {
let bytes = 2
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d ||
code === 0x22 ||
code === 0x5c
) {
bytes += 2
} else if (code < 0x20) {
bytes += 6
} else if (code < 0x80) {
bytes += 1
} else if (code < 0x800) {
bytes += 2
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
value.charCodeAt(index + 1) >= 0xdc00 &&
value.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4
index += 1
} else if (code >= 0xd800 && code <= 0xdfff) {
bytes += 6
} else {
bytes += 3
}
}
return bytes
}
function exceedsJsonByteLimit(value: unknown, maximumBytes: number): boolean {
let bytes = 0
const stack = [value]
const seen = new WeakSet<object>()
const add = (amount: number): boolean => {
bytes += amount
return bytes > maximumBytes
}
while (stack.length > 0) {
const current = stack.pop()
if (current === null) {
if (add(4)) return true
} else if (typeof current === 'string') {
if (add(jsonStringBytes(current))) return true
} else if (typeof current === 'number') {
if (add(Number.isFinite(current) ? String(current).length : 4)) {
return true
}
} else if (typeof current === 'boolean') {
if (add(current ? 4 : 5)) return true
} else if (Array.isArray(current)) {
if (seen.has(current) || add(current.length > 0 ? current.length + 1 : 2)) {
return true
}
seen.add(current)
for (let index = current.length - 1; index >= 0; index -= 1) {
stack.push(current[index])
}
} else if (typeof current === 'object') {
if (seen.has(current)) return true
seen.add(current)
const entries = Object.entries(current).filter(
([, entryValue]) => entryValue !== undefined
)
if (add(entries.length > 0 ? entries.length + 1 : 2)) return true
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, entryValue] = entries[index]!
if (add(jsonStringBytes(key) + 1)) return true
stack.push(entryValue)
}
} else {
return true
}
}
return false
}
export class CdpBrowserDriver {
private readonly debugger: BrowserDebugger
private readonly timeoutMs: number
private readonly maximumAxNodes: number
private readonly maximumAxDepth: number
private readonly maximumAxBytes: 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 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.maximumAxBytes = options.maximumAxBytes ?? MAX_AX_BYTES
this.maximumSnapshotBytes =
options.maximumSnapshotBytes ?? MAX_SNAPSHOT_BYTES
this.maximumScreenshotBytes =
options.maximumScreenshotBytes ?? MAX_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 result = await this.command<{
errorText?: string
}>('Page.navigate', { url }, signal)
if (result.errorText) {
throw new Error(`浏览器导航失败:${result.errorText.slice(0, 200)}`)
}
await this.waitForDocument(signal)
return { url: this.webContents.getURL() || url }
}
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> {
this.invalidate()
const response = await this.command<{ nodes?: CdpAxNode[] }>(
'Accessibility.getFullAXTree',
{ depth: this.maximumAxDepth },
signal
)
if (exceedsJsonByteLimit(response, this.maximumAxBytes)) {
throw new Error('浏览器可访问性树超过安全限制')
}
const allNodes = response.nodes ?? []
const limited = allNodes.slice(0, this.maximumAxNodes)
const knownDepth = new Map<string, number>()
const output: BrowserSnapshotNode[] = []
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)
this.refs.set(ref, {
backendNodeId: node.backendDOMNodeId,
generation: this.generation,
role,
protected: protectedNode
})
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
}
output.push(item)
}
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
)
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 snapshot = {
url,
title,
nodes: output,
truncated: allNodes.length > limited.length
}
if (Buffer.byteLength(JSON.stringify(snapshot)) > this.maximumSnapshotBytes) {
this.refs.clear()
throw new Error('浏览器快照超过安全限制')
}
return snapshot
}
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 =
binding.protected ||
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 === 'password' ||
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()
await this.command(
'Page.navigateToHistoryEntry',
{ entryId: target.entryId },
signal
)
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> {
const result = await this.command<{ data?: string }>(
'Page.captureScreenshot',
{
format: 'png',
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.byteLength > this.maximumScreenshotBytes ||
data.byteLength < 8 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
) ||
data.toString('base64') !== result.data
) {
throw new Error('浏览器截图无效或超过安全限制')
}
return { type: 'image', mimeType: 'image/png', data: result.data }
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
this.invalidate()
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
}
}