@effect-uai/browser 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/Connect.d.mts +31 -0
- package/dist/Connect.d.mts.map +1 -0
- package/dist/Connect.mjs +122 -0
- package/dist/Connect.mjs.map +1 -0
- package/dist/cdp-DZgualSC.d.mts +27027 -0
- package/dist/cdp-DZgualSC.d.mts.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/internal/cdp.d.mts +2 -0
- package/dist/internal/cdp.mjs +123 -0
- package/dist/internal/cdp.mjs.map +1 -0
- package/dist/internal/injected.d.mts +68 -0
- package/dist/internal/injected.d.mts.map +1 -0
- package/dist/internal/injected.mjs +341 -0
- package/dist/internal/injected.mjs.map +1 -0
- package/dist/internal/session.d.mts +19 -0
- package/dist/internal/session.d.mts.map +1 -0
- package/dist/internal/session.mjs +322 -0
- package/dist/internal/session.mjs.map +1 -0
- package/package.json +61 -0
- package/src/Connect.ts +180 -0
- package/src/index.ts +1 -0
- package/src/internal/cdp.ts +203 -0
- package/src/internal/injected.ts +413 -0
- package/src/internal/session.ts +606 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal raw Chrome DevTools Protocol client over a WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* CDP is JSON-RPC-shaped: commands are `{ id, method, params, sessionId? }`,
|
|
5
|
+
* replies are `{ id, result }` or `{ id, error }`, and events are `{ method,
|
|
6
|
+
* params }` with no id. Commands are correlated via a pending-`Deferred`
|
|
7
|
+
* map; events are demuxed into a `PubSub` that callers subscribe to (e.g.
|
|
8
|
+
* navigation waits on `Page.loadEventFired` instead of polling).
|
|
9
|
+
*
|
|
10
|
+
* Commands are typed against the official `devtools-protocol` schema (types
|
|
11
|
+
* only, zero runtime): `send("Page.navigate", params)` gets its params and
|
|
12
|
+
* return type from the protocol. The transport rides Effect's native
|
|
13
|
+
* `WebSocket` (via `layerWebSocketConstructorGlobal`), so no `ws` dependency is
|
|
14
|
+
* needed on modern runtimes; CDP connect uses a bare `ws://` URL with no custom
|
|
15
|
+
* headers.
|
|
16
|
+
*
|
|
17
|
+
* Failures are transport-shaped ({@link CdpError}), not the public
|
|
18
|
+
* `BrowserError`: the transport cannot know which browser verb a command was
|
|
19
|
+
* serving, so the session layer maps `CdpError` onto the typed `BrowserError`
|
|
20
|
+
* using the operation it knows.
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
Cause,
|
|
24
|
+
Data,
|
|
25
|
+
Deferred,
|
|
26
|
+
Effect,
|
|
27
|
+
HashMap,
|
|
28
|
+
Option,
|
|
29
|
+
PubSub,
|
|
30
|
+
Ref,
|
|
31
|
+
Schema,
|
|
32
|
+
type Scope,
|
|
33
|
+
} from "effect"
|
|
34
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
35
|
+
import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping.js"
|
|
36
|
+
import * as JSONL from "@effect-uai/core/JSONL"
|
|
37
|
+
|
|
38
|
+
type Commands = ProtocolMapping.Commands
|
|
39
|
+
|
|
40
|
+
export type CdpMethod = keyof Commands
|
|
41
|
+
export type CdpParams<M extends CdpMethod> = Commands[M]["paramsType"][0]
|
|
42
|
+
export type CdpReturn<M extends CdpMethod> = Commands[M]["returnType"]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Transport-level failure. `reply` is the browser rejecting a command,
|
|
46
|
+
* `write` is a failed socket send, `closed` is the connection going away
|
|
47
|
+
* (or never opening; the socket connects lazily) with the command still in
|
|
48
|
+
* flight. `method` names the CDP command that was in flight.
|
|
49
|
+
*/
|
|
50
|
+
export class CdpError extends Data.TaggedError("CdpError")<{
|
|
51
|
+
kind: "reply" | "write" | "closed"
|
|
52
|
+
method?: string
|
|
53
|
+
reason?: string
|
|
54
|
+
raw?: unknown
|
|
55
|
+
}> {}
|
|
56
|
+
|
|
57
|
+
/** An id-less CDP message: an event emitted by the browser. */
|
|
58
|
+
export type CdpEvent = {
|
|
59
|
+
readonly method: string
|
|
60
|
+
readonly params: unknown
|
|
61
|
+
readonly sessionId: string | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type Cdp = {
|
|
65
|
+
readonly send: <M extends CdpMethod>(
|
|
66
|
+
method: M,
|
|
67
|
+
params?: CdpParams<M>,
|
|
68
|
+
sessionId?: string,
|
|
69
|
+
) => Effect.Effect<CdpReturn<M>, CdpError>
|
|
70
|
+
/**
|
|
71
|
+
* Subscribe to browser events. Subscribe BEFORE issuing the command
|
|
72
|
+
* whose event you await, or the event can slip past. Subscription
|
|
73
|
+
* lifetime is the scope.
|
|
74
|
+
*/
|
|
75
|
+
readonly subscribe: Effect.Effect<PubSub.Subscription<CdpEvent>, never, Scope.Scope>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Replies are heterogeneous per command; the map is keyed by request id and the
|
|
79
|
+
// awaited value is narrowed back to `CdpReturn<M>` at the `send` boundary. The
|
|
80
|
+
// method rides along so a failure can name the command it was for.
|
|
81
|
+
type Pending = {
|
|
82
|
+
readonly method: string
|
|
83
|
+
readonly deferred: Deferred.Deferred<unknown, CdpError>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const CdpMessage = Schema.Struct({
|
|
87
|
+
id: Schema.optional(Schema.Number),
|
|
88
|
+
result: Schema.optional(Schema.Unknown),
|
|
89
|
+
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
|
|
90
|
+
method: Schema.optional(Schema.String),
|
|
91
|
+
params: Schema.optional(Schema.Unknown),
|
|
92
|
+
sessionId: Schema.optional(Schema.String),
|
|
93
|
+
})
|
|
94
|
+
const decodeMessage = Schema.decodeUnknownEffect(CdpMessage)
|
|
95
|
+
|
|
96
|
+
const frame = (
|
|
97
|
+
id: number,
|
|
98
|
+
method: string,
|
|
99
|
+
params: unknown,
|
|
100
|
+
sessionId: string | undefined,
|
|
101
|
+
): string =>
|
|
102
|
+
JSON.stringify(
|
|
103
|
+
sessionId === undefined ? { id, method, params } : { id, method, params, sessionId },
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Open a scoped CDP connection to a browser-level WebSocket endpoint. The
|
|
108
|
+
* socket and its reader fiber are torn down on scope close. The underlying
|
|
109
|
+
* socket connects lazily, so a bad endpoint surfaces on the first `send` as
|
|
110
|
+
* a `CdpError` of kind `closed` (carrying the socket failure on `raw`), not
|
|
111
|
+
* here.
|
|
112
|
+
*/
|
|
113
|
+
export const openCdp = (endpoint: string): Effect.Effect<Cdp, never, Scope.Scope> =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const socket = yield* Socket.makeWebSocket(endpoint, {
|
|
116
|
+
// Effect's Socket treats every close code as an error; whitelist the
|
|
117
|
+
// standard clean-close codes.
|
|
118
|
+
closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
|
|
119
|
+
}).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))
|
|
120
|
+
|
|
121
|
+
const pending = yield* Ref.make(HashMap.empty<number, Pending>())
|
|
122
|
+
const counter = yield* Ref.make(0)
|
|
123
|
+
const events = yield* PubSub.unbounded<CdpEvent>()
|
|
124
|
+
// Why the socket died (failed to open, dropped, dirty close); carried on
|
|
125
|
+
// the `closed` errors handed to in-flight commands.
|
|
126
|
+
const closeCause = yield* Ref.make<unknown>(undefined)
|
|
127
|
+
const write = yield* socket.writer
|
|
128
|
+
|
|
129
|
+
const dispatch = (raw: string): Effect.Effect<void> =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const json = yield* JSONL.parseSafe(raw)
|
|
132
|
+
if (json === undefined) return
|
|
133
|
+
const message = yield* decodeMessage(json).pipe(Effect.option)
|
|
134
|
+
if (Option.isNone(message)) return
|
|
135
|
+
if (message.value.id === undefined) {
|
|
136
|
+
if (message.value.method !== undefined) {
|
|
137
|
+
yield* PubSub.publish(events, {
|
|
138
|
+
method: message.value.method,
|
|
139
|
+
params: message.value.params,
|
|
140
|
+
sessionId: message.value.sessionId,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const id = message.value.id
|
|
146
|
+
const taken = yield* Ref.modify(pending, (m) => [HashMap.get(m, id), HashMap.remove(m, id)])
|
|
147
|
+
if (Option.isNone(taken)) return
|
|
148
|
+
yield* message.value.error === undefined
|
|
149
|
+
? Deferred.succeed(taken.value.deferred, message.value.result ?? {})
|
|
150
|
+
: Deferred.fail(
|
|
151
|
+
taken.value.deferred,
|
|
152
|
+
new CdpError({
|
|
153
|
+
kind: "reply",
|
|
154
|
+
method: taken.value.method,
|
|
155
|
+
reason: message.value.error.message ?? "CDP error",
|
|
156
|
+
raw: message.value.error,
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Reader fiber: fail every still-pending command when the socket closes so
|
|
162
|
+
// callers don't hang.
|
|
163
|
+
const failPending = Effect.gen(function* () {
|
|
164
|
+
const raw = yield* Ref.get(closeCause)
|
|
165
|
+
const taken = yield* Ref.getAndSet(pending, HashMap.empty())
|
|
166
|
+
yield* Effect.forEach(HashMap.values(taken), ({ deferred, method }) =>
|
|
167
|
+
Deferred.fail(
|
|
168
|
+
deferred,
|
|
169
|
+
new CdpError({ kind: "closed", method, reason: "connection closed", raw }),
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
yield* socket.runString(dispatch).pipe(
|
|
174
|
+
Effect.tapCause((cause) => Ref.set(closeCause, Cause.squash(cause))),
|
|
175
|
+
Effect.ensuring(failPending),
|
|
176
|
+
Effect.forkScoped,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const send = <M extends CdpMethod>(
|
|
180
|
+
method: M,
|
|
181
|
+
params?: CdpParams<M>,
|
|
182
|
+
sessionId?: string,
|
|
183
|
+
): Effect.Effect<CdpReturn<M>, CdpError> =>
|
|
184
|
+
Effect.gen(function* () {
|
|
185
|
+
const id = yield* Ref.updateAndGet(counter, (n) => n + 1)
|
|
186
|
+
const deferred = yield* Deferred.make<unknown, CdpError>()
|
|
187
|
+
const entry: Pending = { method, deferred }
|
|
188
|
+
yield* Ref.update(pending, HashMap.set(id, entry))
|
|
189
|
+
// `ensuring` reclaims the entry on write failure and on caller
|
|
190
|
+
// interruption; on the success path dispatch has already removed it.
|
|
191
|
+
const result = yield* write(frame(id, method, params ?? {}, sessionId)).pipe(
|
|
192
|
+
Effect.mapError(
|
|
193
|
+
(raw) => new CdpError({ kind: "write", method, reason: "socket write failed", raw }),
|
|
194
|
+
),
|
|
195
|
+
Effect.flatMap(() => Deferred.await(deferred)),
|
|
196
|
+
Effect.ensuring(Ref.update(pending, HashMap.remove(id))),
|
|
197
|
+
)
|
|
198
|
+
return result as CdpReturn<M>
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const cdp: Cdp = { send, subscribe: PubSub.subscribe(events) }
|
|
202
|
+
return cdp
|
|
203
|
+
})
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page agent: the code that runs INSIDE the browser page.
|
|
3
|
+
*
|
|
4
|
+
* Everything lives in one self-contained function, {@link installPageAgent},
|
|
5
|
+
* which is serialized with `Function.prototype.toString` and shipped through
|
|
6
|
+
* `Runtime.evaluate` (the lowest-common-denominator CDP method, so it works
|
|
7
|
+
* against partial CDP servers like obscura). It installs an idempotent
|
|
8
|
+
* global (`globalThis.__effectUaiPageAgent`) holding the helpers the session
|
|
9
|
+
* verbs call. Arguments cross the wire as `JSON.stringify` values, never as
|
|
10
|
+
* interpolated code, so there is no escaping surface.
|
|
11
|
+
*
|
|
12
|
+
* Rules for code inside `installPageAgent`:
|
|
13
|
+
* - Self-contained: no captures of module scope, no imports. Types are fine
|
|
14
|
+
* (they erase); values are not.
|
|
15
|
+
* - Helpers signal failure by throwing `Error` with a descriptive message;
|
|
16
|
+
* the host surfaces it via `exceptionDetails`. The exception is
|
|
17
|
+
* `waitForSelector`, which reports timeout as `false` so the host can
|
|
18
|
+
* raise a typed `BrowserTimeout` instead of an action failure.
|
|
19
|
+
*
|
|
20
|
+
* The whole in-page world (including the ref registry) is reset by every
|
|
21
|
+
* navigation, which is exactly the documented lifetime of element refs.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Schema } from "effect"
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Wire shapes. Values crossing page -> host are untrusted JSON; the host
|
|
28
|
+
// decodes them with these schemas instead of casting. The derived types
|
|
29
|
+
// double as the agent's own annotations (types erase, so serialization
|
|
30
|
+
// stays self-contained).
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Viewport-relative center of an element, for `Input.dispatch*` events. */
|
|
34
|
+
export const InjectedPoint = Schema.Struct({ x: Schema.Number, y: Schema.Number })
|
|
35
|
+
export type InjectedPoint = typeof InjectedPoint.Type
|
|
36
|
+
|
|
37
|
+
/** Element box: viewport-relative from `query`, page coords from `pageBox`. */
|
|
38
|
+
export const InjectedBox = Schema.Struct({
|
|
39
|
+
x: Schema.Number,
|
|
40
|
+
y: Schema.Number,
|
|
41
|
+
width: Schema.Number,
|
|
42
|
+
height: Schema.Number,
|
|
43
|
+
})
|
|
44
|
+
export type InjectedBox = typeof InjectedBox.Type
|
|
45
|
+
|
|
46
|
+
export const InjectedSize = Schema.Struct({ width: Schema.Number, height: Schema.Number })
|
|
47
|
+
export type InjectedSize = typeof InjectedSize.Type
|
|
48
|
+
|
|
49
|
+
/** Element data returned by `query`; serializable, never a live handle. */
|
|
50
|
+
export const InjectedElementInfo = Schema.Struct({
|
|
51
|
+
ref: Schema.String,
|
|
52
|
+
tag: Schema.String,
|
|
53
|
+
text: Schema.String,
|
|
54
|
+
attributes: Schema.Record(Schema.String, Schema.String),
|
|
55
|
+
box: InjectedBox,
|
|
56
|
+
})
|
|
57
|
+
export type InjectedElementInfo = typeof InjectedElementInfo.Type
|
|
58
|
+
|
|
59
|
+
/** The surface installed on `globalThis.__effectUaiPageAgent`. */
|
|
60
|
+
export type PageAgentApi = {
|
|
61
|
+
readonly query: (selector: string, limit: number) => Array<InjectedElementInfo>
|
|
62
|
+
readonly targetPoint: (selector: string) => InjectedPoint
|
|
63
|
+
readonly pageBox: (selector: string) => InjectedBox
|
|
64
|
+
readonly contentSize: () => InjectedSize
|
|
65
|
+
readonly fill: (selector: string, text: string) => void
|
|
66
|
+
readonly selectValue: (selector: string, value: string) => void
|
|
67
|
+
readonly isChecked: (selector: string) => boolean
|
|
68
|
+
readonly focusEl: (selector: string) => void
|
|
69
|
+
readonly scrollBy: (direction: "up" | "down" | "left" | "right", pixels: number) => void
|
|
70
|
+
readonly scrollIntoView: (selector: string) => void
|
|
71
|
+
readonly waitForSelector: (selector: string, timeoutMs: number) => Promise<boolean>
|
|
72
|
+
readonly html: () => string
|
|
73
|
+
readonly markdown: () => string
|
|
74
|
+
readonly readyState: () => string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const AGENT_GLOBAL = "__effectUaiPageAgent"
|
|
78
|
+
|
|
79
|
+
const installPageAgent = (): void => {
|
|
80
|
+
const holder = globalThis as Record<string, unknown>
|
|
81
|
+
// Global name must match AGENT_GLOBAL; duplicated because this function
|
|
82
|
+
// is serialized and cannot capture module scope.
|
|
83
|
+
if (holder["__effectUaiPageAgent"] !== undefined) return
|
|
84
|
+
|
|
85
|
+
// -- ref registry: `@e1`-style refs minted by `query`, valid until the
|
|
86
|
+
// -- next navigation (a navigation resets this whole world).
|
|
87
|
+
const byRef = new Map<string, Element>()
|
|
88
|
+
const refOf = new WeakMap<Element, string>()
|
|
89
|
+
let refCounter = 0
|
|
90
|
+
|
|
91
|
+
const refFor = (el: Element): string => {
|
|
92
|
+
const existing = refOf.get(el)
|
|
93
|
+
if (existing !== undefined) return existing
|
|
94
|
+
refCounter += 1
|
|
95
|
+
const ref = `@e${refCounter}`
|
|
96
|
+
byRef.set(ref, el)
|
|
97
|
+
refOf.set(el, ref)
|
|
98
|
+
return ref
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const mustResolve = (selector: string): Element => {
|
|
102
|
+
if (selector.startsWith("@")) {
|
|
103
|
+
const el = byRef.get(selector)
|
|
104
|
+
if (el === undefined)
|
|
105
|
+
throw new Error(`stale or unknown ref ${selector} (refs reset on navigation)`)
|
|
106
|
+
if (!el.isConnected) throw new Error(`ref ${selector} is no longer in the document`)
|
|
107
|
+
return el
|
|
108
|
+
}
|
|
109
|
+
const el = document.querySelector(selector)
|
|
110
|
+
if (el === null) throw new Error(`no element matches selector ${selector}`)
|
|
111
|
+
return el
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// -- markdown serialization ------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "SVG", "HEAD"])
|
|
117
|
+
|
|
118
|
+
const isHidden = (el: Element): boolean =>
|
|
119
|
+
el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true"
|
|
120
|
+
|
|
121
|
+
const collapse = (text: string): string => text.replace(/\s+/g, " ").trim()
|
|
122
|
+
|
|
123
|
+
const inlineText = (node: Node): string => {
|
|
124
|
+
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\s+/g, " ")
|
|
125
|
+
if (!(node instanceof Element)) return ""
|
|
126
|
+
const tag = node.tagName.toUpperCase()
|
|
127
|
+
if (SKIP.has(tag) || isHidden(node)) return ""
|
|
128
|
+
const kids = (): string => Array.from(node.childNodes).map(inlineText).join("")
|
|
129
|
+
if (tag === "BR") return "\n"
|
|
130
|
+
if (tag === "STRONG" || tag === "B") {
|
|
131
|
+
const t = collapse(kids())
|
|
132
|
+
return t === "" ? "" : `**${t}**`
|
|
133
|
+
}
|
|
134
|
+
if (tag === "EM" || tag === "I") {
|
|
135
|
+
const t = collapse(kids())
|
|
136
|
+
return t === "" ? "" : `*${t}*`
|
|
137
|
+
}
|
|
138
|
+
if (tag === "CODE") {
|
|
139
|
+
const t = collapse(node.textContent ?? "")
|
|
140
|
+
return t === "" ? "" : `\`${t}\``
|
|
141
|
+
}
|
|
142
|
+
if (tag === "A") {
|
|
143
|
+
const t = collapse(kids())
|
|
144
|
+
if (t === "") return ""
|
|
145
|
+
const href = node.getAttribute("href")
|
|
146
|
+
return href === null || href.startsWith("javascript:") ? t : `[${t}](${href})`
|
|
147
|
+
}
|
|
148
|
+
if (tag === "IMG") {
|
|
149
|
+
const src = node.getAttribute("src")
|
|
150
|
+
return src === null ? "" : ``
|
|
151
|
+
}
|
|
152
|
+
return kids()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const renderList = (el: Element, depth: number): string => {
|
|
156
|
+
const ordered = el.tagName.toUpperCase() === "OL"
|
|
157
|
+
const items: Array<string> = []
|
|
158
|
+
let index = 0
|
|
159
|
+
for (const li of Array.from(el.children)) {
|
|
160
|
+
if (li.tagName.toUpperCase() !== "LI") continue
|
|
161
|
+
index += 1
|
|
162
|
+
let own = ""
|
|
163
|
+
const sublists: Array<Element> = []
|
|
164
|
+
for (const child of Array.from(li.childNodes)) {
|
|
165
|
+
const childTag = child instanceof Element ? child.tagName.toUpperCase() : ""
|
|
166
|
+
if (childTag === "UL" || childTag === "OL") sublists.push(child as Element)
|
|
167
|
+
else own += inlineText(child)
|
|
168
|
+
}
|
|
169
|
+
let line = " ".repeat(depth) + (ordered ? `${index}. ` : "- ") + collapse(own)
|
|
170
|
+
for (const sub of sublists) line += "\n" + renderList(sub, depth + 1)
|
|
171
|
+
items.push(line)
|
|
172
|
+
}
|
|
173
|
+
return items.join("\n")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const renderTable = (el: Element): string => {
|
|
177
|
+
const rows = Array.from(el.querySelectorAll("tr"))
|
|
178
|
+
const first = rows[0]
|
|
179
|
+
if (first === undefined) return ""
|
|
180
|
+
const cells = (tr: Element): Array<string> =>
|
|
181
|
+
Array.from(tr.children)
|
|
182
|
+
.filter((c) => c.tagName.toUpperCase() === "TD" || c.tagName.toUpperCase() === "TH")
|
|
183
|
+
.map((c) => collapse(inlineText(c)) || " ")
|
|
184
|
+
const lines = rows.map((tr) => `| ${cells(tr).join(" | ")} |`)
|
|
185
|
+
const separator = `| ${cells(first)
|
|
186
|
+
.map(() => "---")
|
|
187
|
+
.join(" | ")} |`
|
|
188
|
+
return [lines[0], separator, ...lines.slice(1)].join("\n")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const walkBlocks = (el: Element, out: Array<string>): void => {
|
|
192
|
+
for (const child of Array.from(el.children)) {
|
|
193
|
+
const tag = child.tagName.toUpperCase()
|
|
194
|
+
if (SKIP.has(tag) || isHidden(child)) continue
|
|
195
|
+
const heading = /^H([1-6])$/.exec(tag)
|
|
196
|
+
if (heading !== null) {
|
|
197
|
+
const t = collapse(inlineText(child))
|
|
198
|
+
if (t !== "") out.push("#".repeat(Number(heading[1] ?? "1")) + " " + t)
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (tag === "P") {
|
|
202
|
+
const t = collapse(inlineText(child))
|
|
203
|
+
if (t !== "") out.push(t)
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (tag === "PRE") {
|
|
207
|
+
out.push("```\n" + (child.textContent ?? "").replace(/\s+$/, "") + "\n```")
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (tag === "BLOCKQUOTE") {
|
|
211
|
+
const t = collapse(inlineText(child))
|
|
212
|
+
if (t !== "") out.push("> " + t)
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (tag === "UL" || tag === "OL") {
|
|
216
|
+
const t = renderList(child, 0)
|
|
217
|
+
if (t !== "") out.push(t)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (tag === "TABLE") {
|
|
221
|
+
const t = renderTable(child)
|
|
222
|
+
if (t !== "") out.push(t)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (tag === "HR") {
|
|
226
|
+
out.push("---")
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (child.children.length > 0) {
|
|
230
|
+
walkBlocks(child, out)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const t = collapse(inlineText(child))
|
|
234
|
+
if (t !== "") out.push(t)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// -- the agent surface -----------------------------------------------------
|
|
239
|
+
|
|
240
|
+
const agent: PageAgentApi = {
|
|
241
|
+
query: (selector, limit) => {
|
|
242
|
+
const els = selector.startsWith("@")
|
|
243
|
+
? [mustResolve(selector)]
|
|
244
|
+
: Array.from(document.querySelectorAll(selector))
|
|
245
|
+
return els.slice(0, limit).map((el) => {
|
|
246
|
+
const rect = el.getBoundingClientRect()
|
|
247
|
+
const attributes: Record<string, string> = {}
|
|
248
|
+
for (const attr of Array.from(el.attributes)) attributes[attr.name] = attr.value
|
|
249
|
+
return {
|
|
250
|
+
ref: refFor(el),
|
|
251
|
+
tag: el.tagName.toLowerCase(),
|
|
252
|
+
text: collapse(el.textContent ?? "").slice(0, 200),
|
|
253
|
+
attributes,
|
|
254
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
targetPoint: (selector) => {
|
|
260
|
+
const el = mustResolve(selector)
|
|
261
|
+
el.scrollIntoView({ block: "center", inline: "center" })
|
|
262
|
+
const rect = el.getBoundingClientRect()
|
|
263
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
264
|
+
throw new Error(`element ${selector} has no visible box`)
|
|
265
|
+
}
|
|
266
|
+
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
pageBox: (selector) => {
|
|
270
|
+
const rect = mustResolve(selector).getBoundingClientRect()
|
|
271
|
+
return {
|
|
272
|
+
x: rect.x + window.scrollX,
|
|
273
|
+
y: rect.y + window.scrollY,
|
|
274
|
+
width: rect.width,
|
|
275
|
+
height: rect.height,
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
contentSize: () => ({
|
|
280
|
+
width: document.documentElement.scrollWidth,
|
|
281
|
+
height: document.documentElement.scrollHeight,
|
|
282
|
+
}),
|
|
283
|
+
|
|
284
|
+
fill: (selector, text) => {
|
|
285
|
+
const el = mustResolve(selector)
|
|
286
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
287
|
+
// Set through the native prototype setter so framework value
|
|
288
|
+
// trackers (React et al.) see the change, then fire the events a
|
|
289
|
+
// real keyboard session would.
|
|
290
|
+
const proto =
|
|
291
|
+
el instanceof HTMLInputElement
|
|
292
|
+
? HTMLInputElement.prototype
|
|
293
|
+
: HTMLTextAreaElement.prototype
|
|
294
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
|
|
295
|
+
el.focus()
|
|
296
|
+
if (setter !== undefined) setter.call(el, text)
|
|
297
|
+
else el.value = text
|
|
298
|
+
el.dispatchEvent(new Event("input", { bubbles: true }))
|
|
299
|
+
el.dispatchEvent(new Event("change", { bubbles: true }))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
if (el instanceof HTMLElement && el.isContentEditable) {
|
|
303
|
+
el.focus()
|
|
304
|
+
el.textContent = text
|
|
305
|
+
el.dispatchEvent(new Event("input", { bubbles: true }))
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
throw new Error(`element ${selector} is not fillable (input, textarea, or contenteditable)`)
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
selectValue: (selector, value) => {
|
|
312
|
+
const el = mustResolve(selector)
|
|
313
|
+
if (!(el instanceof HTMLSelectElement)) {
|
|
314
|
+
throw new Error(`element ${selector} is not a <select>`)
|
|
315
|
+
}
|
|
316
|
+
const option = Array.from(el.options).find((o) => o.value === value)
|
|
317
|
+
if (option === undefined) {
|
|
318
|
+
throw new Error(`select ${selector} has no option with value ${JSON.stringify(value)}`)
|
|
319
|
+
}
|
|
320
|
+
el.value = value
|
|
321
|
+
el.dispatchEvent(new Event("input", { bubbles: true }))
|
|
322
|
+
el.dispatchEvent(new Event("change", { bubbles: true }))
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
isChecked: (selector) => {
|
|
326
|
+
const el = mustResolve(selector)
|
|
327
|
+
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
|
|
328
|
+
return el.checked
|
|
329
|
+
}
|
|
330
|
+
throw new Error(`element ${selector} is not a checkbox or radio input`)
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
focusEl: (selector) => {
|
|
334
|
+
const el = mustResolve(selector)
|
|
335
|
+
if (!(el instanceof HTMLElement)) throw new Error(`element ${selector} is not focusable`)
|
|
336
|
+
el.focus()
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
scrollBy: (direction, pixels) => {
|
|
340
|
+
const [dx, dy] =
|
|
341
|
+
direction === "up"
|
|
342
|
+
? [0, -pixels]
|
|
343
|
+
: direction === "down"
|
|
344
|
+
? [0, pixels]
|
|
345
|
+
: direction === "left"
|
|
346
|
+
? [-pixels, 0]
|
|
347
|
+
: [pixels, 0]
|
|
348
|
+
window.scrollBy({ left: dx, top: dy, behavior: "instant" })
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
scrollIntoView: (selector) => {
|
|
352
|
+
mustResolve(selector).scrollIntoView({ block: "center", inline: "center" })
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
waitForSelector: (selector, timeoutMs) =>
|
|
356
|
+
new Promise((resolve) => {
|
|
357
|
+
const found = (): boolean =>
|
|
358
|
+
selector.startsWith("@")
|
|
359
|
+
? (byRef.get(selector)?.isConnected ?? false)
|
|
360
|
+
: document.querySelector(selector) !== null
|
|
361
|
+
if (found()) return resolve(true)
|
|
362
|
+
const observer = new MutationObserver(() => {
|
|
363
|
+
if (!found()) return
|
|
364
|
+
observer.disconnect()
|
|
365
|
+
window.clearTimeout(timer)
|
|
366
|
+
resolve(true)
|
|
367
|
+
})
|
|
368
|
+
const timer = window.setTimeout(() => {
|
|
369
|
+
observer.disconnect()
|
|
370
|
+
resolve(false)
|
|
371
|
+
}, timeoutMs)
|
|
372
|
+
observer.observe(document.documentElement, {
|
|
373
|
+
childList: true,
|
|
374
|
+
subtree: true,
|
|
375
|
+
attributes: true,
|
|
376
|
+
})
|
|
377
|
+
}),
|
|
378
|
+
|
|
379
|
+
html: () => document.documentElement.outerHTML,
|
|
380
|
+
|
|
381
|
+
markdown: () => {
|
|
382
|
+
const out: Array<string> = []
|
|
383
|
+
const title = document.title.trim()
|
|
384
|
+
if (title !== "") out.push(`# ${title}`)
|
|
385
|
+
// Prefer the primary content region so the serialization leads with the
|
|
386
|
+
// article, not a site-wide nav/sidebar (which in DOM order often comes
|
|
387
|
+
// first and would otherwise dominate a truncated view).
|
|
388
|
+
const root = document.querySelector("main, [role=main], article") ?? document.body
|
|
389
|
+
if (root !== null) walkBlocks(root, out)
|
|
390
|
+
return out.join("\n\n")
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
readyState: () => document.readyState,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
holder["__effectUaiPageAgent"] = agent
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Build the `Runtime.evaluate` expression for one agent call: install the
|
|
401
|
+
* agent (idempotent) and invoke the named helper. Arguments are embedded via
|
|
402
|
+
* `JSON.stringify`, so user input never becomes code.
|
|
403
|
+
*
|
|
404
|
+
* The whole thing is one IIFE that `return`s the helper result, so the
|
|
405
|
+
* expression has a single, well-defined completion value. A bare sequence of
|
|
406
|
+
* `;`-separated statements would leave the completion value engine-dependent
|
|
407
|
+
* (obscura's `Runtime.evaluate` yields `undefined` for it), and a returned
|
|
408
|
+
* promise still resolves correctly under `awaitPromise`.
|
|
409
|
+
*/
|
|
410
|
+
export const agentExpression = (fn: keyof PageAgentApi, args: ReadonlyArray<unknown>): string =>
|
|
411
|
+
`(()=>{(${installPageAgent.toString()})();return globalThis.${AGENT_GLOBAL}.${fn}(${args
|
|
412
|
+
.map((a) => JSON.stringify(a))
|
|
413
|
+
.join(",")});})()`
|