@browserless/agent 0.0.0-stage → 14.0.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.md +21 -0
- package/README.md +637 -2
- package/examples/github.js +21 -0
- package/examples/google-flights.js +41 -0
- package/examples/hacker-news.js +23 -0
- package/examples/run.js +23 -0
- package/examples/util.js +15 -0
- package/examples/wallapop.js +26 -0
- package/examples/wikipedia.js +24 -0
- package/package.json +60 -4
- package/src/browser.js +212 -0
- package/src/errors.js +20 -0
- package/src/index.d.ts +108 -0
- package/src/index.js +333 -0
- package/src/model.js +453 -0
- package/src/outline.js +93 -0
- package/src/profiling.js +78 -0
- package/src/questions.js +13 -0
- package/src/rules.js +312 -0
- package/src/snapshot.js +339 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { observe, execute, settle, pageChanged, readStableOutline } = require('./browser')
|
|
4
|
+
const {
|
|
5
|
+
decide,
|
|
6
|
+
decideWithLanguageModel,
|
|
7
|
+
fieldText,
|
|
8
|
+
writeRules,
|
|
9
|
+
invalidRules,
|
|
10
|
+
REASONING_LEVELS
|
|
11
|
+
} = require('./model')
|
|
12
|
+
const {
|
|
13
|
+
applyRules,
|
|
14
|
+
assertRules,
|
|
15
|
+
fillFields,
|
|
16
|
+
readingTextByDefault,
|
|
17
|
+
isComplete,
|
|
18
|
+
hasData
|
|
19
|
+
} = require('./rules')
|
|
20
|
+
const { BlockedError, StaleDecisionError } = require('./errors')
|
|
21
|
+
const { createProfiling, createExtractProfiling } = require('./profiling')
|
|
22
|
+
|
|
23
|
+
const DEFAULT_LIMITS = { maxSteps: 60, maxDecisions: 120, waitMs: 100, timeout: 25000 }
|
|
24
|
+
const MINIMUM_LIMITS = { maxSteps: 1, maxDecisions: 1, waitMs: 0, timeout: 1 }
|
|
25
|
+
const DEFAULT_TEXT_MODEL = 'openai/gpt-6-luna'
|
|
26
|
+
const DEFAULT_DECISION_MODEL = 'typesafe-ai/jev'
|
|
27
|
+
const DEFAULT_EVALUATOR = 'typesafe-ai/jev'
|
|
28
|
+
const DEFAULT_REASONING = 'none'
|
|
29
|
+
const SUCCESS = 'success'
|
|
30
|
+
const ERROR = 'error'
|
|
31
|
+
const MAX_UNCHANGED_ACTIONS = 3
|
|
32
|
+
const MAX_STALE_DECISIONS_PER_TARGET = 3
|
|
33
|
+
const BLOCKED_RECHECK_MS = 300
|
|
34
|
+
const VERIFICATION_WALL =
|
|
35
|
+
/\b(captcha|verify you are human|verification required|verifica que eres humano)\b/i
|
|
36
|
+
|
|
37
|
+
const activePages = new WeakSet()
|
|
38
|
+
const ownExtractMethods = new WeakSet()
|
|
39
|
+
|
|
40
|
+
const isModel = model =>
|
|
41
|
+
(typeof model === 'string' && model.trim() !== '') ||
|
|
42
|
+
typeof model?.specificationVersion === 'string'
|
|
43
|
+
|
|
44
|
+
const withoutNullish = (options = {}) =>
|
|
45
|
+
Object.fromEntries(Object.entries(options).filter(([, value]) => value != null))
|
|
46
|
+
|
|
47
|
+
const withDefaults = (options, defaults) =>
|
|
48
|
+
Object.fromEntries(Object.entries(defaults).map(([key, value]) => [key, options[key] ?? value]))
|
|
49
|
+
|
|
50
|
+
const resolveOptions = options => {
|
|
51
|
+
const limits = withDefaults(options, DEFAULT_LIMITS)
|
|
52
|
+
for (const [key, minimum] of Object.entries(MINIMUM_LIMITS)) {
|
|
53
|
+
if (!Number.isSafeInteger(limits[key]) || limits[key] < minimum) {
|
|
54
|
+
throw new TypeError(`${key} must be an integer of at least ${minimum}.`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const decisions = options.decisions ?? DEFAULT_DECISION_MODEL
|
|
58
|
+
const models = {
|
|
59
|
+
decisions: decisions === false ? undefined : decisions,
|
|
60
|
+
text: options.text ?? DEFAULT_TEXT_MODEL
|
|
61
|
+
}
|
|
62
|
+
for (const [key, model] of Object.entries(models)) {
|
|
63
|
+
const required = key === 'text'
|
|
64
|
+
if ((required || model !== undefined) && !isModel(model)) {
|
|
65
|
+
throw new TypeError(`${key} must be a model id or an AI SDK model.`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const reasoning = options.reasoning ?? DEFAULT_REASONING
|
|
69
|
+
if (!REASONING_LEVELS.includes(reasoning)) {
|
|
70
|
+
throw new TypeError(`reasoning must be one of: ${REASONING_LEVELS.join(', ')}.`)
|
|
71
|
+
}
|
|
72
|
+
return { ...limits, models, reasoning }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const blockingSurface = state => {
|
|
76
|
+
if (VERIFICATION_WALL.test(state.text)) {
|
|
77
|
+
return ['captcha', 'A possible human-verification wall is visible; no bypass attempted.']
|
|
78
|
+
}
|
|
79
|
+
if (state.unsupported?.includes('password')) {
|
|
80
|
+
return ['login_wall', 'A visible password field requires manual login.']
|
|
81
|
+
}
|
|
82
|
+
if (state.unsupported?.length) {
|
|
83
|
+
return ['unsupported_surface', `Visible unsupported surface: ${state.unsupported.join(', ')}.`]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const actionKey = action => `${action.kind}:${action.node ?? action.id}:${action.value ?? ''}`
|
|
88
|
+
|
|
89
|
+
const staleTargetKey = ({ operation, action }) => `${operation}:${action.node ?? action.id}`
|
|
90
|
+
|
|
91
|
+
const timed = async call => {
|
|
92
|
+
const started = performance.now()
|
|
93
|
+
const value = await call()
|
|
94
|
+
return [value, Math.round(performance.now() - started)]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const asError = thrown =>
|
|
98
|
+
thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown })
|
|
99
|
+
|
|
100
|
+
const isPage = page => typeof page?.evaluate === 'function'
|
|
101
|
+
|
|
102
|
+
const isInstruction = text => typeof text === 'string' && text.trim() !== ''
|
|
103
|
+
|
|
104
|
+
const goal = async (page, goal, options = {}) => {
|
|
105
|
+
if (!isPage(page) || !isInstruction(goal)) {
|
|
106
|
+
throw new TypeError('goal requires a Puppeteer page and a nonempty goal.')
|
|
107
|
+
}
|
|
108
|
+
if (activePages.has(page)) throw new TypeError('A goal is already running on this page.')
|
|
109
|
+
const { maxSteps, maxDecisions, waitMs, timeout, models, reasoning } = resolveOptions(options)
|
|
110
|
+
const evaluator = options.evaluator ?? DEFAULT_EVALUATOR
|
|
111
|
+
if (!isModel(evaluator)) throw new TypeError('evaluator must be a model id or an AI SDK model.')
|
|
112
|
+
const calls = []
|
|
113
|
+
const request = {
|
|
114
|
+
timeout,
|
|
115
|
+
signal: options.signal,
|
|
116
|
+
reasoning,
|
|
117
|
+
onCall: metrics => calls.push(metrics)
|
|
118
|
+
}
|
|
119
|
+
const trace = []
|
|
120
|
+
const started = performance.now()
|
|
121
|
+
let state
|
|
122
|
+
const profiling = () =>
|
|
123
|
+
createProfiling({
|
|
124
|
+
goal,
|
|
125
|
+
state,
|
|
126
|
+
trace,
|
|
127
|
+
calls,
|
|
128
|
+
totalMs: Math.round(performance.now() - started),
|
|
129
|
+
evaluator,
|
|
130
|
+
timeout
|
|
131
|
+
})
|
|
132
|
+
const ineffectiveActions = new Set()
|
|
133
|
+
let steps = 0
|
|
134
|
+
let decisions = 0
|
|
135
|
+
let unchanged = 0
|
|
136
|
+
let staleTarget
|
|
137
|
+
let staleDecisions = 0
|
|
138
|
+
let popup = false
|
|
139
|
+
const onPopup = () => {
|
|
140
|
+
popup = true
|
|
141
|
+
}
|
|
142
|
+
const blocked = (reason, message) => {
|
|
143
|
+
throw new BlockedError(reason, message, trace)
|
|
144
|
+
}
|
|
145
|
+
activePages.add(page)
|
|
146
|
+
page.on('popup', onPopup)
|
|
147
|
+
try {
|
|
148
|
+
state = await observe(page)
|
|
149
|
+
while (true) {
|
|
150
|
+
options.signal?.throwIfAborted()
|
|
151
|
+
if (popup) {
|
|
152
|
+
blocked(
|
|
153
|
+
'unsupported_surface',
|
|
154
|
+
'Popup tabs are not supported; the original page was retained.'
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const surface = blockingSurface(state)
|
|
158
|
+
if (surface) blocked(...surface)
|
|
159
|
+
if (decisions >= maxDecisions) blocked('step_budget', 'Decision-request budget exhausted.')
|
|
160
|
+
decisions++
|
|
161
|
+
const offered = {
|
|
162
|
+
...state,
|
|
163
|
+
actions: state.actions.filter(action => !ineffectiveActions.has(actionKey(action)))
|
|
164
|
+
}
|
|
165
|
+
const [decision, decisionMs] = await timed(() =>
|
|
166
|
+
models.decisions
|
|
167
|
+
? decide(offered, goal, trace, models.decisions, request)
|
|
168
|
+
: decideWithLanguageModel(offered, goal, trace, models.text, request)
|
|
169
|
+
)
|
|
170
|
+
const entry = {
|
|
171
|
+
...decision,
|
|
172
|
+
action: decision.action?.id,
|
|
173
|
+
step: steps,
|
|
174
|
+
decision: decisions,
|
|
175
|
+
decisionMs
|
|
176
|
+
}
|
|
177
|
+
trace.push(entry)
|
|
178
|
+
if (decision.operation === 'DONE') {
|
|
179
|
+
return { status: SUCCESS, steps, decisions, trace, profiling: profiling() }
|
|
180
|
+
}
|
|
181
|
+
if (decision.operation === 'BLOCKED') {
|
|
182
|
+
await new Promise(resolve => setTimeout(resolve, BLOCKED_RECHECK_MS))
|
|
183
|
+
const recheck = await observe(page)
|
|
184
|
+
if (!pageChanged(state, recheck)) {
|
|
185
|
+
blocked('model_blocked', 'The model found no supported operation to progress.')
|
|
186
|
+
}
|
|
187
|
+
entry.pageChanged = true
|
|
188
|
+
unchanged = 0
|
|
189
|
+
staleTarget = undefined
|
|
190
|
+
ineffectiveActions.clear()
|
|
191
|
+
state = recheck
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (steps >= maxSteps) blocked('step_budget', 'Action budget exhausted.')
|
|
195
|
+
if (unchanged >= MAX_UNCHANGED_ACTIONS) {
|
|
196
|
+
blocked('no_change', 'Three consecutive actions did not change the observed page.')
|
|
197
|
+
}
|
|
198
|
+
let text
|
|
199
|
+
if (decision.operation === 'TYPE_TEXT') {
|
|
200
|
+
;[text, entry.textMs] = await timed(() =>
|
|
201
|
+
fieldText(goal, decision.action, state, trace, models.text, request)
|
|
202
|
+
)
|
|
203
|
+
entry.text = text
|
|
204
|
+
}
|
|
205
|
+
options.signal?.throwIfAborted()
|
|
206
|
+
if (popup) {
|
|
207
|
+
blocked('unsupported_surface', 'A popup appeared during the decision; no input executed.')
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await execute(page, state, decision.action, text, waitMs)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (!(error instanceof StaleDecisionError)) throw error
|
|
213
|
+
entry.stale = true
|
|
214
|
+
const target = staleTargetKey(decision)
|
|
215
|
+
staleDecisions = target === staleTarget ? staleDecisions + 1 : 1
|
|
216
|
+
staleTarget = target
|
|
217
|
+
if (staleDecisions >= MAX_STALE_DECISIONS_PER_TARGET) {
|
|
218
|
+
blocked(
|
|
219
|
+
'stale_target',
|
|
220
|
+
`${MAX_STALE_DECISIONS_PER_TARGET} consecutive decisions chose a target that failed its freshness check.`
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
state = await observe(page)
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
staleTarget = undefined
|
|
227
|
+
steps++
|
|
228
|
+
if (decision.action.kind !== 'wait') await settle(page, decision.action)
|
|
229
|
+
const next = await observe(page)
|
|
230
|
+
entry.pageChanged = pageChanged(state, next)
|
|
231
|
+
unchanged = entry.pageChanged ? 0 : unchanged + 1
|
|
232
|
+
if (entry.pageChanged) ineffectiveActions.clear()
|
|
233
|
+
else if (decision.action.kind !== 'wait') ineffectiveActions.add(actionKey(decision.action))
|
|
234
|
+
state = next
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof BlockedError && error.trace.length === 0) error.trace = trace
|
|
238
|
+
return { status: ERROR, error: asError(error), steps, decisions, trace, profiling: profiling() }
|
|
239
|
+
} finally {
|
|
240
|
+
page.off('popup', onPopup)
|
|
241
|
+
activePages.delete(page)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const usableRules = async (page, written, fields) => {
|
|
246
|
+
try {
|
|
247
|
+
const readingText = readingTextByDefault(assertRules(written))
|
|
248
|
+
const rules = fields ? fillFields(fields, readingText) : readingText
|
|
249
|
+
if (hasData(await applyRules(page, rules))) return rules
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw error instanceof TypeError ? invalidRules(error) : error
|
|
252
|
+
}
|
|
253
|
+
throw new TypeError('The rules the model wrote matched nothing on the page.')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const rulesFor = async (
|
|
257
|
+
page,
|
|
258
|
+
instruction,
|
|
259
|
+
fields,
|
|
260
|
+
{ timeout, models, reasoning, signal },
|
|
261
|
+
onCall
|
|
262
|
+
) => {
|
|
263
|
+
if (fields !== undefined && isComplete(fields)) return fields
|
|
264
|
+
const request = { timeout, signal, reasoning, onCall }
|
|
265
|
+
const outline = await readStableOutline(page)
|
|
266
|
+
const written = await writeRules(instruction, outline, fields, models.text, request)
|
|
267
|
+
return usableRules(page, written, fields)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const extractArguments = (input, rest) => {
|
|
271
|
+
const fromInstruction = typeof input === 'string'
|
|
272
|
+
const [fields, options = {}] = fromInstruction ? rest : [undefined, rest[0]]
|
|
273
|
+
if (!fromInstruction) {
|
|
274
|
+
const usesBuiltInEngine = options.extractor === undefined
|
|
275
|
+
return { rules: usesBuiltInEngine ? assertRules(input) : input, options }
|
|
276
|
+
}
|
|
277
|
+
if (!isInstruction(input)) {
|
|
278
|
+
throw new TypeError('extract requires rules or a nonempty instruction.')
|
|
279
|
+
}
|
|
280
|
+
if (fields !== undefined) assertRules(fields, 'rules')
|
|
281
|
+
return { instruction: input, fields, options, resolved: resolveOptions(options) }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const extract = async (page, input, ...rest) => {
|
|
285
|
+
if (!isPage(page)) throw new TypeError('extract requires a Puppeteer page.')
|
|
286
|
+
const { instruction, fields, rules, options, resolved } = extractArguments(input, rest)
|
|
287
|
+
const { extractor = applyRules } = options
|
|
288
|
+
const calls = []
|
|
289
|
+
const started = performance.now()
|
|
290
|
+
const used = { rules }
|
|
291
|
+
const profiling = () =>
|
|
292
|
+
createExtractProfiling({
|
|
293
|
+
rules: used.rules,
|
|
294
|
+
calls,
|
|
295
|
+
totalMs: Math.round(performance.now() - started)
|
|
296
|
+
})
|
|
297
|
+
try {
|
|
298
|
+
if (instruction !== undefined) {
|
|
299
|
+
const request = { ...resolved, signal: options.signal }
|
|
300
|
+
used.rules = await rulesFor(page, instruction, fields, request, metrics =>
|
|
301
|
+
calls.push(metrics)
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
const data = await extractor(page, used.rules)
|
|
305
|
+
return { status: SUCCESS, data, profiling: profiling() }
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return { status: ERROR, error: asError(error), profiling: profiling() }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const agent = (page, defaults = {}) => {
|
|
312
|
+
if (!isPage(page)) throw new TypeError('agent requires a Puppeteer page.')
|
|
313
|
+
const isHostEngine = typeof page.extract === 'function' && !ownExtractMethods.has(page.extract)
|
|
314
|
+
const hostExtract = isHostEngine ? page.extract.bind(page) : undefined
|
|
315
|
+
const extractor = defaults.extractor ?? (hostExtract && ((_, rules) => hostExtract(rules)))
|
|
316
|
+
const settings = extractor ? { ...defaults, extractor } : defaults
|
|
317
|
+
const withSettings = options => ({ ...settings, ...withoutNullish(options) })
|
|
318
|
+
const extractMethod = (input, ...rest) =>
|
|
319
|
+
typeof input === 'string'
|
|
320
|
+
? extract(page, input, rest[0], withSettings(rest[1]))
|
|
321
|
+
: extract(page, input, withSettings(rest[0]))
|
|
322
|
+
ownExtractMethods.add(extractMethod)
|
|
323
|
+
return Object.assign(page, {
|
|
324
|
+
goal: (text, options) => goal(page, text, withSettings(options)),
|
|
325
|
+
extract: extractMethod
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = agent
|
|
330
|
+
module.exports.goal = goal
|
|
331
|
+
module.exports.extract = extract
|
|
332
|
+
module.exports.applyRules = applyRules
|
|
333
|
+
module.exports.BlockedError = BlockedError
|