@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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const agent = require('..')
|
|
4
|
+
const debug = require('debug-logfmt')('browserless:agent')
|
|
5
|
+
const { flat, succeeded } = require('./util')
|
|
6
|
+
|
|
7
|
+
module.exports = async ({ page, browserless }) => {
|
|
8
|
+
agent(page)
|
|
9
|
+
|
|
10
|
+
await browserless.goto(page, { url: 'https://github.com/microlinkhq/browserless' })
|
|
11
|
+
const opened = await page.goal('Open the list of open issues of this repository.')
|
|
12
|
+
debug('open issues', { status: opened.status, ...flat(await opened.profiling()) })
|
|
13
|
+
succeeded(opened)
|
|
14
|
+
|
|
15
|
+
const { data } = succeeded(
|
|
16
|
+
await page.extract('get the open issues listed on the page', {
|
|
17
|
+
issues: { attr: { title: { type: 'string' }, url: { type: 'url' } } }
|
|
18
|
+
})
|
|
19
|
+
)
|
|
20
|
+
return data
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const agent = require('..')
|
|
4
|
+
const debug = require('debug-logfmt')('browserless:agent')
|
|
5
|
+
const { flat, succeeded } = require('./util')
|
|
6
|
+
|
|
7
|
+
const DAYS_AHEAD = 30
|
|
8
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
9
|
+
const FLIGHT_FIELDS = ['airline', 'departure', 'arrival', 'duration', 'stops', 'price']
|
|
10
|
+
|
|
11
|
+
const departure = new Date(Date.now() + DAYS_AHEAD * DAY_MS).toLocaleDateString('en-US', {
|
|
12
|
+
month: 'long',
|
|
13
|
+
day: 'numeric',
|
|
14
|
+
year: 'numeric'
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
module.exports = async ({ page, browserless }) => {
|
|
18
|
+
agent(page)
|
|
19
|
+
|
|
20
|
+
await browserless.goto(page, {
|
|
21
|
+
url: 'https://www.google.com/travel/flights?hl=en',
|
|
22
|
+
adblock: false
|
|
23
|
+
})
|
|
24
|
+
const consented = await page.goal(
|
|
25
|
+
'If a cookie consent page is shown, scroll down and reject all cookies.'
|
|
26
|
+
)
|
|
27
|
+
debug('reject cookies', { status: consented.status, ...flat(await consented.profiling()) })
|
|
28
|
+
succeeded(consented)
|
|
29
|
+
const found = await page.goal(
|
|
30
|
+
`Find one-way flights from Zurich to London on ${departure}, for one adult in economy. Stop when matching flight options are visible.`
|
|
31
|
+
)
|
|
32
|
+
debug('find flights', { status: found.status, ...flat(await found.profiling()) })
|
|
33
|
+
succeeded(found)
|
|
34
|
+
|
|
35
|
+
const { data } = succeeded(
|
|
36
|
+
await page.extract('get the flights listed on the page', {
|
|
37
|
+
flights: { attr: Object.fromEntries(FLIGHT_FIELDS.map(name => [name, { type: 'string' }])) }
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
return data
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const agent = require('..')
|
|
4
|
+
const debug = require('debug-logfmt')('browserless:agent')
|
|
5
|
+
const { flat, succeeded } = require('./util')
|
|
6
|
+
|
|
7
|
+
module.exports = async ({ page, browserless }) => {
|
|
8
|
+
agent(page)
|
|
9
|
+
|
|
10
|
+
await browserless.goto(page, { url: 'https://news.ycombinator.com' })
|
|
11
|
+
const opened = await page.goal('Open the comments page of the first story on the front page.')
|
|
12
|
+
debug('open comments', { status: opened.status, ...flat(await opened.profiling()) })
|
|
13
|
+
succeeded(opened)
|
|
14
|
+
|
|
15
|
+
const { data } = succeeded(
|
|
16
|
+
await page.extract('get the story title, its points and the comments', {
|
|
17
|
+
title: { type: 'string' },
|
|
18
|
+
points: { type: 'number' },
|
|
19
|
+
comments: { attr: { author: { type: 'string' }, text: { type: 'string' } } }
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
return data
|
|
23
|
+
}
|
package/examples/run.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const agent = require('..')
|
|
4
|
+
const debug = require('debug-logfmt')('browserless:agent')
|
|
5
|
+
const { flat, succeeded } = require('./util')
|
|
6
|
+
|
|
7
|
+
module.exports = async ({ page, browserless, opts }) => {
|
|
8
|
+
const { url, goal, extract } = opts
|
|
9
|
+
if (!url || !goal || !extract) {
|
|
10
|
+
throw new TypeError(
|
|
11
|
+
'Usage: browserless exec examples/run.js --url=<url> --goal=<goal> --extract=<what to get>'
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
agent(page)
|
|
15
|
+
|
|
16
|
+
await browserless.goto(page, { url })
|
|
17
|
+
const reached = await page.goal(goal)
|
|
18
|
+
debug('goal', { status: reached.status, ...flat(await reached.profiling()) })
|
|
19
|
+
succeeded(reached)
|
|
20
|
+
|
|
21
|
+
const { data } = succeeded(await page.extract(extract))
|
|
22
|
+
return data
|
|
23
|
+
}
|
package/examples/util.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const flat = ({ accuracy, cost, timing, error }) => ({
|
|
4
|
+
...accuracy,
|
|
5
|
+
...cost,
|
|
6
|
+
...timing,
|
|
7
|
+
...(error && { error: error.message })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const succeeded = result => {
|
|
11
|
+
if (result.status === 'error') throw result.error
|
|
12
|
+
return result
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { flat, succeeded }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const agent = require('..')
|
|
4
|
+
const debug = require('debug-logfmt')('browserless:agent')
|
|
5
|
+
const { flat, succeeded } = require('./util')
|
|
6
|
+
|
|
7
|
+
module.exports = async ({ page, browserless }) => {
|
|
8
|
+
agent(page)
|
|
9
|
+
|
|
10
|
+
await browserless.goto(page, { url: 'https://wallapop.com' })
|
|
11
|
+
const searched = await page.goal('busca "bmw x3"')
|
|
12
|
+
debug('search', { status: searched.status, ...flat(await searched.profiling()) })
|
|
13
|
+
succeeded(searched)
|
|
14
|
+
const sorted = await page.goal('ordena los resultados de más barato a más caro')
|
|
15
|
+
debug('sort', { status: sorted.status, ...flat(await sorted.profiling()) })
|
|
16
|
+
succeeded(sorted)
|
|
17
|
+
|
|
18
|
+
const { data } = succeeded(
|
|
19
|
+
await page.extract('get the search results', {
|
|
20
|
+
products: {
|
|
21
|
+
attr: { name: { type: 'string' }, price: { type: 'number' }, url: { type: 'url' } }
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
)
|
|
25
|
+
return data
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const agent = require('..')
|
|
4
|
+
const debug = require('debug-logfmt')('browserless:agent')
|
|
5
|
+
const { flat, succeeded } = require('./util')
|
|
6
|
+
|
|
7
|
+
module.exports = async ({ page, browserless }) => {
|
|
8
|
+
agent(page)
|
|
9
|
+
|
|
10
|
+
await browserless.goto(page, { url: 'https://en.wikipedia.org/wiki/Main_Page' })
|
|
11
|
+
const opened = await page.goal(
|
|
12
|
+
'Search for "Gödel\'s incompleteness theorems" and open that article.'
|
|
13
|
+
)
|
|
14
|
+
debug('open article', { status: opened.status, ...flat(await opened.profiling()) })
|
|
15
|
+
succeeded(opened)
|
|
16
|
+
|
|
17
|
+
const { data } = succeeded(
|
|
18
|
+
await page.extract('get the article title and its first paragraph', {
|
|
19
|
+
title: { type: 'string' },
|
|
20
|
+
firstParagraph: { type: 'string' }
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
return data
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@browserless/agent",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "Drive a Puppeteer page toward a natural-language goal using finite decisions.",
|
|
4
|
+
"homepage": "https://browserless.js.org",
|
|
5
|
+
"version": "14.0.0",
|
|
6
|
+
"main": "src",
|
|
7
|
+
"author": {
|
|
8
|
+
"email": "hello@microlink.io",
|
|
9
|
+
"name": "microlink.io",
|
|
10
|
+
"url": "https://microlink.io"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"directory": "packages/agent",
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/microlinkhq/browserless.git#master"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/microlinkhq/browserless/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"agent",
|
|
22
|
+
"ai-sdk",
|
|
23
|
+
"browserless",
|
|
24
|
+
"jev",
|
|
25
|
+
"puppeteer"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"ai": "7",
|
|
29
|
+
"debug-logfmt": "~1.4.15"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@ai-sdk/typesafe-ai": "~3.0.13",
|
|
33
|
+
"@browserless/cli": "14.0.0",
|
|
34
|
+
"ava": "5",
|
|
35
|
+
"jsdom": "26",
|
|
36
|
+
"puppeteer": "25"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">= 24"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"examples",
|
|
43
|
+
"src",
|
|
44
|
+
"LICENSE.md"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "ava",
|
|
48
|
+
"start": "browserless exec examples/wallapop.js --no-headless",
|
|
49
|
+
"benchmark": "browserless exec scripts/benchmark.js",
|
|
50
|
+
"compare": "browserless exec scripts/compare.js"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"ava": {
|
|
54
|
+
"timeout": "30s",
|
|
55
|
+
"workerThreads": false
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"types": "src/index.d.ts",
|
|
61
|
+
"gitHead": "bf10b39022a6cc44cccd545756f162bed90dd589"
|
|
62
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/* global location, innerWidth, innerHeight, getSelection, requestAnimationFrame */
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const snapshot = require('./snapshot')
|
|
5
|
+
const pageOutline = require('./outline')
|
|
6
|
+
const { BlockedError, StaleDecisionError } = require('./errors')
|
|
7
|
+
|
|
8
|
+
const KEYBOARD_TARGET = { focused: true }
|
|
9
|
+
const SETTLE_LIMITS = { autocompleteMs: 200, defaultMs: 50, minFrames: 2 }
|
|
10
|
+
const DOCUMENT_READY_LIMIT_MS = 3000
|
|
11
|
+
const DOCUMENT_READY_POLL_MS = 50
|
|
12
|
+
const OUTLINE_LIMITS = { characters: 60000, text: 80, attribute: 80, siblings: 3 }
|
|
13
|
+
const OUTLINE_STABLE_POLL_MS = 250
|
|
14
|
+
const OUTLINE_STABLE_LIMIT_MS = 3000
|
|
15
|
+
|
|
16
|
+
const pageChanged = (before, after) =>
|
|
17
|
+
JSON.stringify(before.marker) !== JSON.stringify(after.marker)
|
|
18
|
+
|
|
19
|
+
const NAVIGATING = /Execution context was destroyed|Cannot find context with specified id/
|
|
20
|
+
const TARGET_GONE = new RegExp(
|
|
21
|
+
`${NAVIGATING.source}|Node is detached from document|not clickable or not an Element`
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const snapshotUnlessNavigating = page =>
|
|
25
|
+
page.evaluate(snapshot).catch(error => {
|
|
26
|
+
if (NAVIGATING.test(error.message)) return null
|
|
27
|
+
throw error
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const observe = async page => {
|
|
31
|
+
const deadline = Date.now() + DOCUMENT_READY_LIMIT_MS
|
|
32
|
+
while (true) {
|
|
33
|
+
const state = await snapshotUnlessNavigating(page)
|
|
34
|
+
if (state) return state
|
|
35
|
+
if (Date.now() >= deadline) {
|
|
36
|
+
throw new BlockedError('unsupported_surface', 'No document body is available.')
|
|
37
|
+
}
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, DOCUMENT_READY_POLL_MS))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Geometry is resolved now, not from model-time coordinates. Form state is local
|
|
43
|
+
// to the target; unrelated page changes do not invalidate this decision.
|
|
44
|
+
const targetFresh = (element, state, action, requirements = {}) => {
|
|
45
|
+
const cache = window.__browserlessAgent
|
|
46
|
+
if (
|
|
47
|
+
!cache ||
|
|
48
|
+
!element?.isConnected ||
|
|
49
|
+
cache.nodes.get(action.node) !== element ||
|
|
50
|
+
JSON.stringify(cache.guard(element)) !== JSON.stringify(state.guards[action.node]) ||
|
|
51
|
+
performance.timeOrigin !== state.pageKey[0] ||
|
|
52
|
+
location.href !== state.url ||
|
|
53
|
+
cache.closest(element, '[aria-disabled="true"],[inert]') ||
|
|
54
|
+
['password', 'file', 'hidden'].includes(element.type)
|
|
55
|
+
) {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
if (requirements.focused && cache.activeElement() !== element) return false
|
|
59
|
+
const r = element.getBoundingClientRect()
|
|
60
|
+
const x = r.x + r.width / 2
|
|
61
|
+
const y = r.y + r.height / 2
|
|
62
|
+
if (
|
|
63
|
+
!r.width ||
|
|
64
|
+
!r.height ||
|
|
65
|
+
x < 0 ||
|
|
66
|
+
y < 0 ||
|
|
67
|
+
x >= innerWidth ||
|
|
68
|
+
y >= innerHeight ||
|
|
69
|
+
!cache.contains(element, cache.elementFromPoint(x, y))
|
|
70
|
+
) {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
action.kind === 'fill' &&
|
|
75
|
+
(element.readOnly || element.getAttribute('aria-readonly') === 'true')
|
|
76
|
+
) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
action.kind === 'select' &&
|
|
81
|
+
(element.tagName !== 'SELECT' ||
|
|
82
|
+
![...element.options].some(
|
|
83
|
+
o => o.value === action.value && !o.disabled && !o.closest('optgroup[disabled]')
|
|
84
|
+
))
|
|
85
|
+
) {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const selectContents = element => {
|
|
92
|
+
if (typeof element.select === 'function') element.select()
|
|
93
|
+
else getSelection().selectAllChildren(element)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const readOutline = page => page.evaluate(pageOutline, OUTLINE_LIMITS)
|
|
97
|
+
|
|
98
|
+
const readStableOutline = async page => {
|
|
99
|
+
const deadline = Date.now() + OUTLINE_STABLE_LIMIT_MS
|
|
100
|
+
let current = await readOutline(page)
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, OUTLINE_STABLE_POLL_MS))
|
|
103
|
+
const next = await readOutline(page)
|
|
104
|
+
if (next.outline === current.outline) return next
|
|
105
|
+
current = next
|
|
106
|
+
}
|
|
107
|
+
return current
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const settled = (action, limits) =>
|
|
111
|
+
new Promise(resolve => {
|
|
112
|
+
const field = window.__browserlessAgent?.nodes.get(action.node)
|
|
113
|
+
const autocomplete = action.kind === 'fill' && field?.getAttribute('role') === 'combobox'
|
|
114
|
+
const suggestionVisible = () => {
|
|
115
|
+
const ids = (field.getAttribute('aria-controls') || field.getAttribute('aria-owns') || '')
|
|
116
|
+
.split(/\s+/)
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
const roots = ids.length
|
|
119
|
+
? ids.map(id => field.getRootNode().getElementById(id)).filter(Boolean)
|
|
120
|
+
: [document]
|
|
121
|
+
return roots
|
|
122
|
+
.flatMap(root => [...root.querySelectorAll('[role="option"]')])
|
|
123
|
+
.some(option => {
|
|
124
|
+
const r = option.getBoundingClientRect()
|
|
125
|
+
return (
|
|
126
|
+
r.width > 0 &&
|
|
127
|
+
r.height > 0 &&
|
|
128
|
+
r.bottom > 0 &&
|
|
129
|
+
r.top < innerHeight &&
|
|
130
|
+
option.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
let frames = 0
|
|
135
|
+
let finished = false
|
|
136
|
+
const finish = () => {
|
|
137
|
+
finished = true
|
|
138
|
+
resolve()
|
|
139
|
+
}
|
|
140
|
+
const onFrame = () => {
|
|
141
|
+
if (finished) return
|
|
142
|
+
if (++frames >= limits.minFrames && (!autocomplete || suggestionVisible())) finish()
|
|
143
|
+
else requestAnimationFrame(onFrame)
|
|
144
|
+
}
|
|
145
|
+
setTimeout(finish, autocomplete ? limits.autocompleteMs : limits.defaultMs)
|
|
146
|
+
requestAnimationFrame(onFrame)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Navigation can destroy the page context mid-wait; the next observation handles that.
|
|
150
|
+
const settle = (page, action) => page.evaluate(settled, action, SETTLE_LIMITS).catch(() => {})
|
|
151
|
+
|
|
152
|
+
const execute = async (page, state, action, text, waitMs) => {
|
|
153
|
+
if (action.kind === 'wait') {
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, waitMs))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (action.kind === 'scroll') {
|
|
158
|
+
if (pageChanged(state, await observe(page))) throw new StaleDecisionError()
|
|
159
|
+
await page.mouse.move(state.w / 2, state.h / 2)
|
|
160
|
+
await page.mouse.wheel({ deltaY: action.delta })
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
let handle
|
|
164
|
+
try {
|
|
165
|
+
handle = await page.evaluateHandle(
|
|
166
|
+
node => window.__browserlessAgent?.nodes.get(node) || null,
|
|
167
|
+
action.node
|
|
168
|
+
)
|
|
169
|
+
const element = handle.asElement()
|
|
170
|
+
const assertFresh = async (requirements = {}) => {
|
|
171
|
+
if (!(await element?.evaluate(targetFresh, state, action, requirements))) {
|
|
172
|
+
throw new StaleDecisionError()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Focus can trigger menus/re-renders, so freshness is checked again after it.
|
|
176
|
+
const focusForKeyboard = async () => {
|
|
177
|
+
await element.focus()
|
|
178
|
+
await assertFresh(KEYBOARD_TARGET)
|
|
179
|
+
}
|
|
180
|
+
await assertFresh()
|
|
181
|
+
if (action.kind === 'fill') {
|
|
182
|
+
await focusForKeyboard()
|
|
183
|
+
await element.evaluate(selectContents)
|
|
184
|
+
await assertFresh(KEYBOARD_TARGET)
|
|
185
|
+
await page.keyboard.sendCharacter(text)
|
|
186
|
+
} else if (action.kind === 'submit') {
|
|
187
|
+
await focusForKeyboard()
|
|
188
|
+
await page.keyboard.press('Enter')
|
|
189
|
+
} else if (action.kind === 'select') {
|
|
190
|
+
await element.select(action.value)
|
|
191
|
+
} else if (action.kind === 'click') {
|
|
192
|
+
await element.click()
|
|
193
|
+
} else throw new TypeError('Unknown observed action.')
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (TARGET_GONE.test(error.message)) throw new StaleDecisionError()
|
|
196
|
+
throw error
|
|
197
|
+
} finally {
|
|
198
|
+
await handle?.dispose().catch(() => {})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
readStableOutline,
|
|
204
|
+
pageOutline,
|
|
205
|
+
observe,
|
|
206
|
+
execute,
|
|
207
|
+
settle,
|
|
208
|
+
settled,
|
|
209
|
+
pageChanged,
|
|
210
|
+
targetFresh,
|
|
211
|
+
selectContents
|
|
212
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
class BlockedError extends Error {
|
|
4
|
+
constructor (reason, message, trace = []) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'BlockedError'
|
|
7
|
+
this.code = 'BLOCKED'
|
|
8
|
+
this.reason = reason
|
|
9
|
+
this.trace = trace
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class StaleDecisionError extends Error {
|
|
14
|
+
constructor () {
|
|
15
|
+
super('Target or nearby context changed; observe again before input.')
|
|
16
|
+
this.name = 'StaleDecisionError'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { BlockedError, StaleDecisionError }
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Page } from 'puppeteer' with { 'resolution-mode': 'import' }
|
|
2
|
+
import type { Experimental_DecisionModel, LanguageModel } from 'ai' with { 'resolution-mode': 'import' }
|
|
3
|
+
|
|
4
|
+
declare function agent<PAGE extends Page>(page: PAGE, defaults?: agent.Options): PAGE & agent.Methods
|
|
5
|
+
|
|
6
|
+
declare namespace agent {
|
|
7
|
+
type BlockedReason = 'captcha' | 'login_wall' | 'unsupported_surface' | 'step_budget' | 'no_change' | 'stale_target' | 'model_blocked'
|
|
8
|
+
type Reasoning = 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
|
9
|
+
interface Rule {
|
|
10
|
+
selector?: string | string[]
|
|
11
|
+
selectorAll?: string | string[]
|
|
12
|
+
attr?: string | string[] | Rules
|
|
13
|
+
type?: string
|
|
14
|
+
}
|
|
15
|
+
type Rules = Record<string, Rule | Rule[]>
|
|
16
|
+
type Data = Record<string, unknown>
|
|
17
|
+
type Extractor = (page: Page, rules: Rules) => Promise<Data>
|
|
18
|
+
interface Methods {
|
|
19
|
+
goal(goal: string, options?: Options): Promise<Result>
|
|
20
|
+
extract(rules: Rules, options?: Options): Promise<Extracted>
|
|
21
|
+
extract(instruction: string, rules?: Rules, options?: Options): Promise<Extracted>
|
|
22
|
+
}
|
|
23
|
+
function goal(page: Page, goal: string, options?: Options): Promise<Result>
|
|
24
|
+
function extract(page: Page, rules: Rules, options?: Options): Promise<Extracted>
|
|
25
|
+
function extract(page: Page, instruction: string, rules?: Rules, options?: Options): Promise<Extracted>
|
|
26
|
+
const applyRules: Extractor
|
|
27
|
+
interface Options {
|
|
28
|
+
extractor?: Extractor
|
|
29
|
+
decisions?: Experimental_DecisionModel | false
|
|
30
|
+
text?: LanguageModel
|
|
31
|
+
evaluator?: Experimental_DecisionModel
|
|
32
|
+
reasoning?: Reasoning
|
|
33
|
+
maxSteps?: number
|
|
34
|
+
maxDecisions?: number
|
|
35
|
+
waitMs?: number
|
|
36
|
+
timeout?: number
|
|
37
|
+
signal?: AbortSignal
|
|
38
|
+
}
|
|
39
|
+
interface TraceEntry {
|
|
40
|
+
operation: 'CLICK' | 'TYPE_TEXT' | 'SELECT' | 'SUBMIT' | 'SCROLL_UP' | 'SCROLL_DOWN' | 'WAIT' | 'DONE' | 'BLOCKED'
|
|
41
|
+
action?: string
|
|
42
|
+
confidence?: number
|
|
43
|
+
probabilities?: Record<string, number>
|
|
44
|
+
decisionMs: number
|
|
45
|
+
textMs?: number
|
|
46
|
+
targetConfidence?: number
|
|
47
|
+
targetProbabilities?: Record<string, number>
|
|
48
|
+
step: number
|
|
49
|
+
decision: number
|
|
50
|
+
text?: string
|
|
51
|
+
stale?: boolean
|
|
52
|
+
pageChanged?: boolean
|
|
53
|
+
}
|
|
54
|
+
interface Accuracy {
|
|
55
|
+
passed: boolean
|
|
56
|
+
probability: number
|
|
57
|
+
}
|
|
58
|
+
interface Cost {
|
|
59
|
+
calls: number
|
|
60
|
+
inputTokens: number | undefined
|
|
61
|
+
outputTokens: number | undefined
|
|
62
|
+
cachedInputTokens: number
|
|
63
|
+
cacheWriteTokens: number
|
|
64
|
+
reasoningTokens: number
|
|
65
|
+
usd: number | undefined
|
|
66
|
+
generationIds: string[]
|
|
67
|
+
}
|
|
68
|
+
interface Timing {
|
|
69
|
+
totalMs: number
|
|
70
|
+
modelMs: number
|
|
71
|
+
otherMs: number
|
|
72
|
+
evaluationMs: number
|
|
73
|
+
}
|
|
74
|
+
interface Profiling {
|
|
75
|
+
accuracy: Accuracy | undefined
|
|
76
|
+
cost: Cost
|
|
77
|
+
timing: Timing
|
|
78
|
+
error?: Error
|
|
79
|
+
}
|
|
80
|
+
interface ProfilingOptions {
|
|
81
|
+
signal?: AbortSignal
|
|
82
|
+
}
|
|
83
|
+
type ProfilingFunction = (options?: ProfilingOptions) => Promise<Profiling>
|
|
84
|
+
interface Run {
|
|
85
|
+
steps: number
|
|
86
|
+
decisions: number
|
|
87
|
+
trace: TraceEntry[]
|
|
88
|
+
profiling: ProfilingFunction
|
|
89
|
+
}
|
|
90
|
+
type Result = (Run & { status: 'success' }) | (Run & { status: 'error'; error: Error })
|
|
91
|
+
interface ExtractProfiling {
|
|
92
|
+
rules: Rules | undefined
|
|
93
|
+
cost: Cost
|
|
94
|
+
timing: Timing
|
|
95
|
+
}
|
|
96
|
+
type ExtractProfilingFunction = () => Promise<ExtractProfiling>
|
|
97
|
+
type Extracted =
|
|
98
|
+
| { status: 'success'; data: Data; profiling: ExtractProfilingFunction }
|
|
99
|
+
| { status: 'error'; error: Error; profiling: ExtractProfilingFunction }
|
|
100
|
+
class BlockedError extends Error {
|
|
101
|
+
constructor(reason: BlockedReason, message: string, trace?: TraceEntry[])
|
|
102
|
+
code: 'BLOCKED'
|
|
103
|
+
reason: BlockedReason
|
|
104
|
+
trace: TraceEntry[]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export = agent
|