mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 21:21:53 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
import type { BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction } from '@/types/models'
|
||||
import { callableExecutionPolicy, settleCallableResult } from './execution'
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024
|
||||
const MAX_CONTROLS = 2_000
|
||||
const MAX_FIELDS = 64
|
||||
const MAX_MUTATIONS = 2_000
|
||||
const DEFAULT_TIMEOUT_MS = 4_000
|
||||
|
||||
interface CapturedRequest {
|
||||
boundary: 'fetch' | 'xhr' | 'beacon' | 'form'
|
||||
method: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
bodyText: string
|
||||
}
|
||||
|
||||
interface TransactionContext {
|
||||
domInputCount: number
|
||||
}
|
||||
|
||||
export interface RequestTransactionInvocation {
|
||||
transaction: BrowserPageCallableTransaction
|
||||
logicalInput: unknown
|
||||
invoke(context: TransactionContext): unknown
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
interface RollbackController {
|
||||
finish(): number
|
||||
}
|
||||
|
||||
interface MutableControl extends Element {
|
||||
value?: string
|
||||
checked?: boolean
|
||||
selectedIndex?: number
|
||||
name?: string
|
||||
id: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
function error(message: string): Error {
|
||||
return new Error(`请求事务失败:${message}`)
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function absoluteUrl(value: string): string {
|
||||
try { return new URL(value, location.href).toString() } catch { return value }
|
||||
}
|
||||
|
||||
function runtimeBaseUrl(): string {
|
||||
return typeof location === 'undefined' ? 'http://localhost/' : location.href
|
||||
}
|
||||
|
||||
function comparableUrl(value: string, baseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(value, baseUrl)
|
||||
return `${url.origin}${url.pathname}${url.search}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function requestMatchesTransaction(
|
||||
transaction: BrowserPageCallableTransaction,
|
||||
method: string,
|
||||
url: string,
|
||||
baseUrl = runtimeBaseUrl(),
|
||||
): boolean {
|
||||
return transaction.request.method.toUpperCase() === method.toUpperCase()
|
||||
&& comparableUrl(transaction.request.url, baseUrl) === comparableUrl(url, baseUrl)
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
async function bodyText(value: unknown): Promise<string> {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return value.toString()
|
||||
if (typeof Blob !== 'undefined' && value instanceof Blob) return value.text()
|
||||
if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
||||
const form = new URLSearchParams()
|
||||
for (const [key, item] of value.entries()) {
|
||||
if (typeof item !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
||||
form.append(key, item)
|
||||
}
|
||||
return form.toString()
|
||||
}
|
||||
if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value))
|
||||
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength))
|
||||
throw error(`不支持的请求 Body 类型 ${Object.prototype.toString.call(value)}`)
|
||||
}
|
||||
|
||||
function headerRecord(headers: Headers): Record<string, string> {
|
||||
const output: Record<string, string> = Object.create(null) as Record<string, string>
|
||||
headers.forEach((value, key) => { output[key.toLowerCase()] = value })
|
||||
return output
|
||||
}
|
||||
|
||||
function parseForm(value: string): Record<string, string | string[]> {
|
||||
const output: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
|
||||
for (const [key, item] of new URLSearchParams(value)) {
|
||||
const previous = output[key]
|
||||
output[key] = previous === undefined ? item : Array.isArray(previous) ? [...previous, item] : [previous, item]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function capturedBody(request: CapturedRequest): unknown {
|
||||
const contentType = request.headers['content-type']?.toLowerCase() || ''
|
||||
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) {
|
||||
try { return JSON.parse(request.bodyText) as unknown } catch { throw error('页面生成的请求 Body 不是有效 JSON') }
|
||||
}
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) return parseForm(request.bodyText)
|
||||
return request.bodyText
|
||||
}
|
||||
|
||||
function readOwnPath(input: unknown, path: string): unknown {
|
||||
let current = input
|
||||
for (const segment of path.split('.').filter(Boolean)) {
|
||||
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) return undefined
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function validateRequestTransactionOutput(value: unknown, destinations: string[]): void {
|
||||
const missing = destinations.filter((destination) => {
|
||||
const path = destination === 'body' ? '' : destination.startsWith('body.') ? destination.slice(5) : destination
|
||||
return path ? readOwnPath(value, path) === undefined : value === undefined
|
||||
})
|
||||
if (missing.length) throw error(`截获的请求缺少目标字段:${missing.join('、')}`)
|
||||
}
|
||||
|
||||
function logicalObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let current = value
|
||||
if (typeof current === 'string') {
|
||||
try { current = JSON.parse(current) as unknown } catch { return undefined }
|
||||
}
|
||||
return current && typeof current === 'object' && !Array.isArray(current)
|
||||
? current as Record<string, unknown>
|
||||
: undefined
|
||||
}
|
||||
|
||||
interface LogicalField {
|
||||
path: string
|
||||
key: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
function logicalFields(value: unknown): LogicalField[] {
|
||||
const root = logicalObject(value)
|
||||
if (!root) return []
|
||||
const output: LogicalField[] = []
|
||||
const visit = (current: Record<string, unknown>, prefix: string, depth: number) => {
|
||||
if (depth > 4 || output.length >= MAX_FIELDS) return
|
||||
for (const [key, item] of Object.entries(current)) {
|
||||
if (output.length >= MAX_FIELDS) break
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) visit(item as Record<string, unknown>, path, depth + 1)
|
||||
else output.push({ path, key, value: item })
|
||||
}
|
||||
}
|
||||
visit(root, '', 0)
|
||||
return output
|
||||
}
|
||||
|
||||
function controlNames(control: MutableControl): string[] {
|
||||
const name = typeof control.name === 'string' ? control.name : ''
|
||||
return [name, control.id, name.replace(/\[([^\]]+)\]/g, '.$1')].filter(Boolean)
|
||||
}
|
||||
|
||||
function setControlValue(control: MutableControl, value: unknown): void {
|
||||
const type = String(control.type || '').toLowerCase()
|
||||
if ((type === 'checkbox' || type === 'radio') && typeof control.checked === 'boolean') {
|
||||
if (type === 'radio') control.checked = String(control.value ?? '') === String(value)
|
||||
else control.checked = typeof value === 'boolean' ? value : Array.isArray(value)
|
||||
? value.map(String).includes(String(control.value ?? ''))
|
||||
: Boolean(value)
|
||||
return
|
||||
}
|
||||
if ('value' in control) {
|
||||
control.value = value === undefined || value === null ? ''
|
||||
: typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function bindLogicalInput(value: unknown): number {
|
||||
const fields = logicalFields(value)
|
||||
if (!fields.length) return 0
|
||||
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
||||
const missing: string[] = []
|
||||
let matched = 0
|
||||
for (const field of fields) {
|
||||
const candidates = controls.filter((control) => controlNames(control).some((name) => (
|
||||
name === field.path || name === field.key || name.endsWith(`.${field.path}`) || name.endsWith(`.${field.key}`)
|
||||
)))
|
||||
if (!candidates.length) {
|
||||
missing.push(field.path)
|
||||
continue
|
||||
}
|
||||
candidates.forEach((control) => setControlValue(control, field.value))
|
||||
matched += 1
|
||||
}
|
||||
if (matched && missing.length) throw error(`无法把明文字段映射到页面输入:${missing.join('、')}`)
|
||||
return matched
|
||||
}
|
||||
|
||||
function beginDomRollback(): RollbackController {
|
||||
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
||||
const controlSnapshots = controls.map((control) => ({
|
||||
control,
|
||||
value: control.value,
|
||||
checked: control.checked,
|
||||
selectedIndex: control.selectedIndex,
|
||||
}))
|
||||
const mutations: MutationRecord[] = []
|
||||
const root = document.documentElement
|
||||
const observer = root && typeof MutationObserver !== 'undefined'
|
||||
? new MutationObserver((records) => {
|
||||
if (mutations.length < MAX_MUTATIONS) mutations.push(...records.slice(0, MAX_MUTATIONS - mutations.length))
|
||||
})
|
||||
: undefined
|
||||
observer?.observe(root, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
characterData: true,
|
||||
characterDataOldValue: true,
|
||||
})
|
||||
let finished = false
|
||||
return {
|
||||
finish() {
|
||||
if (finished) return mutations.length
|
||||
finished = true
|
||||
if (observer) mutations.push(...observer.takeRecords().slice(0, Math.max(0, MAX_MUTATIONS - mutations.length)))
|
||||
observer?.disconnect()
|
||||
for (const snapshot of controlSnapshots) {
|
||||
if (snapshot.value !== undefined) snapshot.control.value = snapshot.value
|
||||
if (snapshot.checked !== undefined) snapshot.control.checked = snapshot.checked
|
||||
if (snapshot.selectedIndex !== undefined) snapshot.control.selectedIndex = snapshot.selectedIndex
|
||||
}
|
||||
for (const mutation of [...mutations].reverse()) {
|
||||
try {
|
||||
if (mutation.type === 'attributes') {
|
||||
if (!mutation.attributeName) continue
|
||||
if (mutation.oldValue === null) (mutation.target as Element).removeAttributeNS(mutation.attributeNamespace, mutation.attributeName)
|
||||
else (mutation.target as Element).setAttributeNS(mutation.attributeNamespace, mutation.attributeName, mutation.oldValue)
|
||||
} else if (mutation.type === 'characterData') {
|
||||
mutation.target.nodeValue = mutation.oldValue
|
||||
} else {
|
||||
mutation.addedNodes.forEach((node) => { if (node.parentNode === mutation.target) mutation.target.removeChild(node) })
|
||||
const before = mutation.nextSibling?.parentNode === mutation.target ? mutation.nextSibling : null
|
||||
mutation.removedNodes.forEach((node) => mutation.target.insertBefore(node, before))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort rollback is followed by fail-closed validation at the request boundary.
|
||||
}
|
||||
}
|
||||
return mutations.length
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setMethod<T extends object, K extends keyof T>(target: T, key: K, value: T[K], restorers: Array<() => void>): void {
|
||||
const previous = target[key]
|
||||
try {
|
||||
target[key] = value
|
||||
restorers.push(() => { target[key] = previous })
|
||||
} catch {
|
||||
// A non-writable optional boundary remains protected by the other installed boundaries.
|
||||
}
|
||||
}
|
||||
|
||||
function formRequest(form: HTMLFormElement, submitter?: HTMLElement | null): CapturedRequest {
|
||||
const method = (form.method || 'GET').toUpperCase()
|
||||
const url = absoluteUrl(form.action || location.href)
|
||||
const formData = new FormData(form, submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement ? submitter : undefined)
|
||||
const encoded = new URLSearchParams()
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (typeof value !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
||||
encoded.append(key, value)
|
||||
}
|
||||
return {
|
||||
boundary: 'form', method, url,
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
bodyText: encoded.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
|
||||
const timeoutMs = callableExecutionPolicy('auto', input.timeoutMs ?? DEFAULT_TIMEOUT_MS).timeoutMs
|
||||
const rollback = beginDomRollback()
|
||||
const restorers: Array<() => void> = []
|
||||
let captured: CapturedRequest | undefined
|
||||
let captureFailure: Error | undefined
|
||||
let resolveCapture!: () => void
|
||||
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
|
||||
|
||||
const capture = async (request: CapturedRequest): Promise<void> => {
|
||||
if (captured || captureFailure) {
|
||||
captureFailure = error('页面流程产生了多个网络请求,无法唯一确定转换边界')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
if (!requestMatchesTransaction(input.transaction, request.method, request.url)) {
|
||||
captureFailure = error(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
|
||||
captureFailure = error('页面生成的请求 Body 超过 8 MiB')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
captured = request
|
||||
resolveCapture()
|
||||
}
|
||||
|
||||
const previousFetch = window.fetch
|
||||
setMethod(window, 'fetch', (async function transactionFetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
const request = new Request(requestInput, init)
|
||||
await capture({
|
||||
boundary: 'fetch',
|
||||
method: request.method.toUpperCase(),
|
||||
url: request.url,
|
||||
headers: headerRecord(request.headers),
|
||||
bodyText: await request.clone().text(),
|
||||
})
|
||||
return new Response(JSON.stringify({ success: false, error: 'request captured by Yakit Browser Agent' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof previousFetch, restorers)
|
||||
|
||||
const xhrMetadata = new WeakMap<XMLHttpRequest, { method: string; url: string; headers: Record<string, string> }>()
|
||||
const xhrPrototype = XMLHttpRequest.prototype
|
||||
const previousOpen = xhrPrototype.open
|
||||
const previousSetHeader = xhrPrototype.setRequestHeader
|
||||
const previousSend = xhrPrototype.send
|
||||
setMethod(xhrPrototype, 'open', (function transactionOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
|
||||
xhrMetadata.set(this, { method: method.toUpperCase(), url: absoluteUrl(String(url)), headers: Object.create(null) as Record<string, string> })
|
||||
return Reflect.apply(previousOpen, this, [method, url, ...rest] as never)
|
||||
}) as typeof previousOpen, restorers)
|
||||
setMethod(xhrPrototype, 'setRequestHeader', (function transactionSetHeader(this: XMLHttpRequest, name: string, value: string) {
|
||||
const metadata = xhrMetadata.get(this)
|
||||
if (metadata) metadata.headers[name.toLowerCase()] = value
|
||||
return Reflect.apply(previousSetHeader, this, [name, value])
|
||||
}) as typeof previousSetHeader, restorers)
|
||||
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
const metadata = xhrMetadata.get(this)
|
||||
if (!metadata) throw error('XHR 没有可验证的 open 边界')
|
||||
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata, bodyText: text })).catch((reason) => {
|
||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||
resolveCapture()
|
||||
})
|
||||
}) as typeof previousSend, restorers)
|
||||
|
||||
if (typeof navigator.sendBeacon === 'function') {
|
||||
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
|
||||
void bodyText(data).then((text) => capture({
|
||||
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: {}, bodyText: text,
|
||||
})).catch((reason) => {
|
||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||
resolveCapture()
|
||||
})
|
||||
return true
|
||||
}) as typeof navigator.sendBeacon, restorers)
|
||||
}
|
||||
|
||||
const formPrototype = HTMLFormElement.prototype
|
||||
const previousSubmit = formPrototype.submit
|
||||
const previousRequestSubmit = formPrototype.requestSubmit
|
||||
setMethod(formPrototype, 'submit', (function transactionSubmit(this: HTMLFormElement) {
|
||||
void capture(formRequest(this)).catch(() => undefined)
|
||||
}) as typeof previousSubmit, restorers)
|
||||
setMethod(formPrototype, 'requestSubmit', (function transactionRequestSubmit(this: HTMLFormElement, submitter?: HTMLElement | null) {
|
||||
void capture(formRequest(this, submitter)).catch(() => undefined)
|
||||
}) as typeof previousRequestSubmit, restorers)
|
||||
const submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
if (event.target instanceof HTMLFormElement) void capture(formRequest(event.target, event.submitter)).catch(() => undefined)
|
||||
}
|
||||
document.addEventListener('submit', submitListener, true)
|
||||
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
||||
|
||||
setMethod(window, 'alert', (() => undefined) as typeof window.alert, restorers)
|
||||
setMethod(window, 'confirm', (() => false) as typeof window.confirm, restorers)
|
||||
setMethod(window, 'prompt', (() => null) as typeof window.prompt, restorers)
|
||||
setMethod(window, 'open', (() => null) as typeof window.open, restorers)
|
||||
|
||||
let invocationFailure: unknown
|
||||
let returned: unknown
|
||||
try {
|
||||
const domInputCount = bindLogicalInput(input.logicalInput)
|
||||
try { returned = input.invoke({ domInputCount }) } catch (reason) {
|
||||
invocationFailure = reason
|
||||
resolveCapture()
|
||||
}
|
||||
void Promise.resolve(returned).catch((reason) => {
|
||||
invocationFailure = reason
|
||||
if (!captured) resolveCapture()
|
||||
})
|
||||
await Promise.race([
|
||||
captureSignal,
|
||||
delay(timeoutMs).then(() => {
|
||||
if (!captured && !captureFailure) captureFailure = error('等待页面生成目标请求超时')
|
||||
}),
|
||||
])
|
||||
if (captureFailure) throw captureFailure
|
||||
if (!captured) {
|
||||
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
||||
throw error('页面函数没有产生目标请求')
|
||||
}
|
||||
await delay(0)
|
||||
if (captureFailure) throw captureFailure
|
||||
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
||||
const value = capturedBody(captured)
|
||||
validateRequestTransactionOutput(value, input.transaction.request.expectedDestinations)
|
||||
return value
|
||||
} finally {
|
||||
for (const restore of restorers.reverse()) {
|
||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||
}
|
||||
rollback.finish()
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSideEffectFreeCallable(
|
||||
invoke: () => unknown,
|
||||
execution: BrowserPageCallableExecutionPolicy,
|
||||
): Promise<unknown> {
|
||||
const rollback = beginDomRollback()
|
||||
const restorers: Array<() => void> = []
|
||||
let attemptedBoundary = ''
|
||||
const block = (boundary: string): never => {
|
||||
attemptedBoundary = boundary
|
||||
throw error(`普通页面函数尝试触发 ${boundary},必须改用请求事务`)
|
||||
}
|
||||
setMethod(window, 'fetch', (() => block('Fetch')) as typeof window.fetch, restorers)
|
||||
setMethod(XMLHttpRequest.prototype, 'send', (function blockedXhrSend() { return block('XHR') }) as typeof XMLHttpRequest.prototype.send, restorers)
|
||||
if (typeof navigator.sendBeacon === 'function') {
|
||||
setMethod(navigator, 'sendBeacon', (() => block('Beacon')) as typeof navigator.sendBeacon, restorers)
|
||||
}
|
||||
setMethod(HTMLFormElement.prototype, 'submit', (function blockedSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.submit, restorers)
|
||||
setMethod(HTMLFormElement.prototype, 'requestSubmit', (function blockedRequestSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.requestSubmit, restorers)
|
||||
const submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
attemptedBoundary = 'Form Submit'
|
||||
}
|
||||
document.addEventListener('submit', submitListener, true)
|
||||
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
||||
try {
|
||||
const value = await settleCallableResult(invoke(), execution)
|
||||
await Promise.resolve()
|
||||
if (attemptedBoundary) throw error(`普通页面函数尝试触发 ${attemptedBoundary},必须改用请求事务`)
|
||||
const mutationCount = rollback.finish()
|
||||
if (mutationCount) throw error('普通页面函数修改了页面 DOM,必须改用请求事务')
|
||||
return value
|
||||
} finally {
|
||||
for (const restore of restorers.reverse()) {
|
||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||
}
|
||||
rollback.finish()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user