@browserless.io/mcp 1.6.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 +557 -0
- package/README.md +280 -0
- package/bin/cli.js +2 -0
- package/build/src/@types/types.d.ts +538 -0
- package/build/src/config.d.ts +3 -0
- package/build/src/config.js +42 -0
- package/build/src/index.d.ts +4 -0
- package/build/src/index.js +153 -0
- package/build/src/lib/account-resolver.d.ts +17 -0
- package/build/src/lib/account-resolver.js +78 -0
- package/build/src/lib/agent-client.d.ts +58 -0
- package/build/src/lib/agent-client.js +530 -0
- package/build/src/lib/agent-format.d.ts +35 -0
- package/build/src/lib/agent-format.js +155 -0
- package/build/src/lib/amplitude.d.ts +11 -0
- package/build/src/lib/amplitude.js +65 -0
- package/build/src/lib/analytics.d.ts +18 -0
- package/build/src/lib/analytics.js +79 -0
- package/build/src/lib/api-client.d.ts +17 -0
- package/build/src/lib/api-client.js +357 -0
- package/build/src/lib/bounded-event-store.d.ts +22 -0
- package/build/src/lib/bounded-event-store.js +69 -0
- package/build/src/lib/cache.d.ts +12 -0
- package/build/src/lib/cache.js +49 -0
- package/build/src/lib/define-tool.d.ts +71 -0
- package/build/src/lib/define-tool.js +71 -0
- package/build/src/lib/error-classifier.d.ts +4 -0
- package/build/src/lib/error-classifier.js +125 -0
- package/build/src/lib/redis-oauth-proxy.d.ts +13 -0
- package/build/src/lib/redis-oauth-proxy.js +214 -0
- package/build/src/lib/retry.d.ts +2 -0
- package/build/src/lib/retry.js +19 -0
- package/build/src/lib/schema-fields.d.ts +10 -0
- package/build/src/lib/schema-fields.js +27 -0
- package/build/src/lib/supabase-token-patch.d.ts +6 -0
- package/build/src/lib/supabase-token-patch.js +33 -0
- package/build/src/lib/utils.d.ts +27 -0
- package/build/src/lib/utils.js +67 -0
- package/build/src/prompts/extract-content.d.ts +2 -0
- package/build/src/prompts/extract-content.js +33 -0
- package/build/src/prompts/scrape-url.d.ts +2 -0
- package/build/src/prompts/scrape-url.js +36 -0
- package/build/src/resources/api-docs.d.ts +3 -0
- package/build/src/resources/api-docs.js +54 -0
- package/build/src/resources/status.d.ts +3 -0
- package/build/src/resources/status.js +30 -0
- package/build/src/skills/autonomous-login.md +95 -0
- package/build/src/skills/captchas.md +48 -0
- package/build/src/skills/cookie-consent.md +50 -0
- package/build/src/skills/dynamic-content.md +72 -0
- package/build/src/skills/index.d.ts +9 -0
- package/build/src/skills/index.js +221 -0
- package/build/src/skills/modals.md +56 -0
- package/build/src/skills/screenshots.md +53 -0
- package/build/src/skills/shadow-dom.md +64 -0
- package/build/src/skills/snapshot-misses.md +67 -0
- package/build/src/skills/system-prompt.d.ts +2 -0
- package/build/src/skills/system-prompt.js +128 -0
- package/build/src/skills/tabs.md +77 -0
- package/build/src/tools/agent.d.ts +15 -0
- package/build/src/tools/agent.js +299 -0
- package/build/src/tools/crawl.d.ts +75 -0
- package/build/src/tools/crawl.js +426 -0
- package/build/src/tools/download.d.ts +11 -0
- package/build/src/tools/download.js +92 -0
- package/build/src/tools/export.d.ts +28 -0
- package/build/src/tools/export.js +129 -0
- package/build/src/tools/function.d.ts +24 -0
- package/build/src/tools/function.js +144 -0
- package/build/src/tools/map.d.ts +23 -0
- package/build/src/tools/map.js +129 -0
- package/build/src/tools/performance.d.ts +25 -0
- package/build/src/tools/performance.js +103 -0
- package/build/src/tools/schemas.d.ts +466 -0
- package/build/src/tools/schemas.js +487 -0
- package/build/src/tools/search.d.ts +67 -0
- package/build/src/tools/search.js +184 -0
- package/build/src/tools/smartscraper.d.ts +42 -0
- package/build/src/tools/smartscraper.js +136 -0
- package/package.json +111 -0
- package/patches/mcp-proxy+6.4.0.patch +31 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function registerApiDocsResource(server, config) {
|
|
2
|
+
server.addResource({
|
|
3
|
+
uri: 'browserless://api-docs',
|
|
4
|
+
name: 'Browserless API Documentation',
|
|
5
|
+
mimeType: 'text/markdown',
|
|
6
|
+
async load() {
|
|
7
|
+
return {
|
|
8
|
+
text: [
|
|
9
|
+
'# Browserless Smart Scraper API',
|
|
10
|
+
'',
|
|
11
|
+
'## Endpoint',
|
|
12
|
+
`POST ${config.browserlessApiUrl}/smart-scrape`,
|
|
13
|
+
'',
|
|
14
|
+
'## Authentication',
|
|
15
|
+
'Pass your API token as the `token` query parameter or via the `Authorization: Bearer <token>` header.',
|
|
16
|
+
'',
|
|
17
|
+
'## Request Body',
|
|
18
|
+
'```json',
|
|
19
|
+
'{',
|
|
20
|
+
' "url": "https://example.com",',
|
|
21
|
+
' "formats": ["markdown", "screenshot"]',
|
|
22
|
+
'}',
|
|
23
|
+
'```',
|
|
24
|
+
'',
|
|
25
|
+
'## Parameters',
|
|
26
|
+
'- **url** (required): The URL to scrape. Must use http or https protocol.',
|
|
27
|
+
'- **formats** (optional, default: `["html"]`): Output formats to include in the response.',
|
|
28
|
+
' - `"markdown"` – page content converted to markdown',
|
|
29
|
+
' - `"html"` – cleaned HTML (returned by default in `content`)',
|
|
30
|
+
' - `"screenshot"` – full-page PNG screenshot as base64 (forces browser strategy)',
|
|
31
|
+
' - `"pdf"` – PDF of the page as base64 (forces browser strategy)',
|
|
32
|
+
' - `"links"` – list of links extracted from the page',
|
|
33
|
+
'',
|
|
34
|
+
'## Response',
|
|
35
|
+
'Returns JSON with fields: ok, statusCode, content, contentType, headers, strategy, attempted, message, screenshot, pdf, markdown, links.',
|
|
36
|
+
'',
|
|
37
|
+
'## Scraping Strategies',
|
|
38
|
+
'The smart scraper automatically cascades through multiple strategies:',
|
|
39
|
+
'1. HTTP fetch (fast, no browser)',
|
|
40
|
+
'2. HTTP fetch with proxy',
|
|
41
|
+
'3. Headless browser',
|
|
42
|
+
'4. Headless browser with captcha solving',
|
|
43
|
+
'',
|
|
44
|
+
'When `screenshot` or `pdf` is in formats, a browser strategy is forced.',
|
|
45
|
+
'The response includes which strategy succeeded and which were attempted.',
|
|
46
|
+
'',
|
|
47
|
+
'## Documentation',
|
|
48
|
+
'- [Browserless Docs](https://docs.browserless.io/)',
|
|
49
|
+
'- [REST APIs](https://docs.browserless.io/rest-apis/intro)',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createApiClient } from '../lib/api-client.js';
|
|
2
|
+
export function registerStatusResource(server, config) {
|
|
3
|
+
server.addResource({
|
|
4
|
+
uri: 'browserless://status',
|
|
5
|
+
name: 'Browserless Service Status',
|
|
6
|
+
mimeType: 'application/json',
|
|
7
|
+
async load() {
|
|
8
|
+
const { browserlessToken } = config;
|
|
9
|
+
if (!browserlessToken) {
|
|
10
|
+
return {
|
|
11
|
+
text: JSON.stringify({
|
|
12
|
+
apiUrl: config.browserlessApiUrl,
|
|
13
|
+
ok: false,
|
|
14
|
+
message: 'No BROWSERLESS_TOKEN configured. For HTTP: pass Authorization header.',
|
|
15
|
+
timestamp: new Date().toISOString(),
|
|
16
|
+
}, null, 2),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const client = createApiClient({ ...config, browserlessToken });
|
|
20
|
+
const status = await client.getStatus();
|
|
21
|
+
return {
|
|
22
|
+
text: JSON.stringify({
|
|
23
|
+
apiUrl: config.browserlessApiUrl,
|
|
24
|
+
...status,
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
}, null, 2),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Autonomous Login
|
|
2
|
+
|
|
3
|
+
Page wants auth. **Default: don't.** Logins are intrusive and can damage account state. Proceed only when both gates pass.
|
|
4
|
+
|
|
5
|
+
## Gate 1 — Login required for continuing _this_ task?
|
|
6
|
+
|
|
7
|
+
If the user's task is literally "log in / post / DM", or needs login to continue, gate passed. For extract/read/observe tasks, check whether the wall actually blocks the goal:
|
|
8
|
+
|
|
9
|
+
- Target content already in DOM beneath the wall? Read it directly.
|
|
10
|
+
- Dismiss available (`Maybe later`, `Skip`, modal `×`)? Click it.
|
|
11
|
+
- Alternative path — public mirror, archive.org, RSS, JSON endpoint, deep link?
|
|
12
|
+
|
|
13
|
+
If the rest of the task completes without auth → `LOGIN_NOT_NEEDED`. Wikipedia, public docs/news, public read-only profiles.
|
|
14
|
+
|
|
15
|
+
## Gate 2 — Credentials unambiguously for _this_ site?
|
|
16
|
+
|
|
17
|
+
**Password is not required to pass Gate 2.** Many sites use magic-link / email-only / passkey auth — an email alone (or any contextually-matched identifier) can be sufficient. Don't preemptively fail Gate 2 because no password is in context; let the form tell you at runtime. Only fail Gate 2 if the form actually demands a credential type you don't have.
|
|
18
|
+
|
|
19
|
+
Identified **contextually** by name-to-domain correspondence — fixed names not required. Bar is **extraordinary evidence**, not plausibility.
|
|
20
|
+
|
|
21
|
+
- ✅ `instagram.com` + `instagramHandle` / `instagramPassword`
|
|
22
|
+
- ✅ `LOGIN_USERNAME` / `LOGIN_PASSWORD` paired with `LOGIN_TARGET_URL` whose host matches
|
|
23
|
+
- ❌ `wikipedia.org` + `instagramHandle` (names belong to a different service)
|
|
24
|
+
- ❌ Bare `username` / `password` with no domain qualifier (ambiguous)
|
|
25
|
+
|
|
26
|
+
Absent / ambiguous / multiple plausible pairs → `MISSING_CONTEXT`. TOTP follows the same rule.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
If either gate fails, stop and emit the matching `reason_code`. Rest runs only when both pass.
|
|
31
|
+
|
|
32
|
+
## Reach the form
|
|
33
|
+
|
|
34
|
+
- Password input in snapshot → continue.
|
|
35
|
+
- Sign-in link/button visible → click, wait, re-snapshot.
|
|
36
|
+
- Email-first (username only): type username, click `Continue` / `Next`, `waitForSelector` on `input[type="password"]` (10000ms), re-snapshot.
|
|
37
|
+
- After two transitions with no password input → `FORM_NOT_FOUND`.
|
|
38
|
+
|
|
39
|
+
## Sanity check
|
|
40
|
+
|
|
41
|
+
Confirm login (not signup/reset): submit name is `Sign in` / `Log in` / `Continue` (not `Sign up` / `Register` / `Reset`), and exactly **one** password field present. Else `FORM_NOT_FOUND`.
|
|
42
|
+
|
|
43
|
+
## Field selection (anchor off password)
|
|
44
|
+
|
|
45
|
+
- **Password**: `input[type="password"]`. With multiples: matches `/password/i` and **not** `confirm|new password`.
|
|
46
|
+
- **Username** (first match): same-form `input[type="email"]` → input matching `/email|username|user|login|account/i` → visible text/email/tel input immediately preceding the password in `ref` order.
|
|
47
|
+
- **Submit** (first match): same-form button matching `/^(sign in|log in|login|continue|submit)$/i` → `button[type="submit"]` in form → the only non-SSO visible button (skip `Continue with Google` etc. unless context names that provider).
|
|
48
|
+
|
|
49
|
+
Any missing → `FORM_NOT_FOUND` with what's missing.
|
|
50
|
+
|
|
51
|
+
## Submit
|
|
52
|
+
|
|
53
|
+
Single batched call (type username, type password, click submit) with Gate-2 values. Then `waitForNavigation` (10000ms) or `waitForResponse` on `*`. If both time out, verify anyway — page may have updated in place. Re-snapshot.
|
|
54
|
+
|
|
55
|
+
## Verify success (any one, priority order)
|
|
56
|
+
|
|
57
|
+
1. URL no longer matches `/login|signin|sign-in|log-in|auth|sso|account\/sign/i`.
|
|
58
|
+
2. Password input absent from new snapshot.
|
|
59
|
+
3. Authed-state element matching `/log out|sign out|my account|profile|dashboard|avatar/i`.
|
|
60
|
+
|
|
61
|
+
If none holds:
|
|
62
|
+
|
|
63
|
+
- Form error matching `/invalid|incorrect|wrong|doesn'?t match|not recognized|please try again/i` → `INVALID_CREDENTIALS`.
|
|
64
|
+
- Captcha indicator → invoke `captchas` skill, re-verify. Unsolvable → `CAPTCHA_BLOCKED`.
|
|
65
|
+
- MFA prompt → MFA branch.
|
|
66
|
+
- No change, no error → `SUBMIT_NO_FEEDBACK`.
|
|
67
|
+
|
|
68
|
+
**Never retype the same credentials to retry.** Caller's call.
|
|
69
|
+
|
|
70
|
+
## MFA branch
|
|
71
|
+
|
|
72
|
+
Required when snapshot has `autocomplete="one-time-code"`, numeric input with `maxlength` ∈ {4, 6, 8}, or label/`name`/`placeholder` matching `/code|verification|otp|2fa|two[- ]?factor|authenticator/i`.
|
|
73
|
+
|
|
74
|
+
- Contextually-matched TOTP available (same Gate-2 rule) → type, click submit, re-verify.
|
|
75
|
+
- **No matching TOTP in context → ask the user for the code in plain text and STOP this turn. Do not call `close`. Do not emit the final JSON block. Leave the agent session open so the next turn can resume — the OTP input is still on the page and the cookies/state are intact.** When the user replies with a code, treat it as the TOTP value, type + click submit + re-verify. If the user declines or says they don't have one → `MFA_INPUT_MISSING`. Never attempt SMS/email/WebAuthn flows.
|
|
76
|
+
- TOTP rejected (`/invalid|expired|incorrect/i`) → ask user for a fresh code (same don't-close rule); after one fresh-code rejection → `MFA_FAILED`.
|
|
77
|
+
- Second MFA prompt after first cleared → `UNEXPECTED_STATE`.
|
|
78
|
+
|
|
79
|
+
## Final response
|
|
80
|
+
|
|
81
|
+
Call `close`, then emit **exactly one** fenced JSON block — nothing before or after, no prose. Fields: `success`, `reason_code`, `final_url`, `evidence`, `steps_taken` (JSON-RPC call count; batched call = 1). On failure, `success: false` and `final_url` = current URL.
|
|
82
|
+
|
|
83
|
+
`reason_code` ∈ `SUCCESS` | `LOGIN_NOT_NEEDED` | `MISSING_CONTEXT` | `INVALID_CREDENTIALS` | `MFA_INPUT_MISSING` | `MFA_FAILED` | `CAPTCHA_BLOCKED` | `FORM_NOT_FOUND` | `SUBMIT_NO_FEEDBACK` | `FIELD_TYPE_MISMATCH` | `UNEXPECTED_STATE`.
|
|
84
|
+
|
|
85
|
+
## Don't
|
|
86
|
+
|
|
87
|
+
- Log in just because a form is visible — gates first.
|
|
88
|
+
- Use credentials whose names don't unambiguously belong to this site.
|
|
89
|
+
- Guess among multiple plausible pairs — `MISSING_CONTEXT`.
|
|
90
|
+
- Retry with the same credentials after failure.
|
|
91
|
+
- Try SSO buttons unless the task names that provider.
|
|
92
|
+
- `evaluate` to set input `value` — use `type` so real keystrokes fire.
|
|
93
|
+
- Leak credentials into narration, errors, or non-`type.params.text` fields.
|
|
94
|
+
- Emit anything other than the final JSON block in your last _terminal_ message (ask-the-user turns are not terminal — emit plain prose and stop without `close`).
|
|
95
|
+
- Close the session while waiting for a user-supplied OTP — leave it open so cookies, page state, and the OTP input survive the round-trip.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Captchas & Bot Challenges
|
|
2
|
+
|
|
3
|
+
Page shows captcha widget (reCAPTCHA, hCaptcha, Cloudflare Turnstile, DataDome) **or** navigation returned 403/429 with bot-challenge headers. Use `solve` command, not click.
|
|
4
|
+
|
|
5
|
+
> **EXPERIMENTAL — Cloud-only.** `solve` works on `production.browserless.io` only. Self-hosted Enterprise lacks solver backend; use `smartscraper` or `liveURL` instead.
|
|
6
|
+
|
|
7
|
+
## `solve` command
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"method": "solve",
|
|
12
|
+
"params": { "type": "recaptcha", "wait": true, "timeout": 30000 }
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Params (all optional):**
|
|
17
|
+
|
|
18
|
+
- `type` — auto-detects if omitted. Specify only when needed: `cloudflare`, `hcaptcha`, `recaptcha`, `recaptchaV3`, `geetest`, `normal`, `friendlyCaptcha`, `capy`, `textCaptcha`, `amazonWaf`, `dataDome`, `akamai`, `lemin`, `mtcaptcha`, `slider`
|
|
19
|
+
- `wait` (default `true`) — wait for captcha appearance. `false` if already visible
|
|
20
|
+
- `timeout` (default 30000ms) — wait duration for detection
|
|
21
|
+
|
|
22
|
+
## Response
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{ "found": true, "solved": true, "time": 18342, "token": "03AGdBq25..." }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `found: false` → no captcha within timeout. May be passed or wrong heuristic
|
|
29
|
+
- `found: true, solved: false` → detected but failed (rate-limited/unsupported variant/nav mid-solve). Re-snapshot; don't retry blindly
|
|
30
|
+
- `solved: true` → token injected. **Re-snapshot for Continue/Submit button** or await auto-navigation
|
|
31
|
+
|
|
32
|
+
## Recipe
|
|
33
|
+
|
|
34
|
+
1. Run `solve` with no `type`: `{ "method": "solve", "params": {} }`
|
|
35
|
+
2. Check `found`/`solved`
|
|
36
|
+
3. Re-snapshot (page state changed)
|
|
37
|
+
4. Click continue button if present, or `waitForNavigation`
|
|
38
|
+
|
|
39
|
+
## Escalation on failure
|
|
40
|
+
|
|
41
|
+
1. `found: false` but widget visible → specify `type`, retry once
|
|
42
|
+
2. `solved: false` → re-snapshot first (don't retry immediately; costs & rate-limited)
|
|
43
|
+
3. Repeated failures → use `smartscraper` or surface `liveURL` for human
|
|
44
|
+
|
|
45
|
+
## Don't
|
|
46
|
+
|
|
47
|
+
- Click checkboxes via `click` — opens challenge UI but doesn't solve
|
|
48
|
+
- `evaluate` JS to set `g-recaptcha-response` — tokens session-bound; hand-written values rejected
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Cookie Consent Banners
|
|
2
|
+
|
|
3
|
+
Snapshot contains button/link matching `accept all`, `reject all`, `consent`, or `cookies`. Handle consent banner **before** anything else. Banners overlay content, intercept clicks, break selectors.
|
|
4
|
+
|
|
5
|
+
## Recipe
|
|
6
|
+
|
|
7
|
+
1. **Find dismiss button in snapshot.** Look for:
|
|
8
|
+
- `Reject all`, `Decline`, `Deny`, `Refuse all`
|
|
9
|
+
- `Accept all`, `Accept`, `Agree` (only if no reject — can't interact with site rejecting consent every load)
|
|
10
|
+
- `Manage preferences`, `Cookie settings` (avoid — opens sub-flow)
|
|
11
|
+
2. **Click via `ref=` or `deep-ref=`.** Modern banners (OneTrust, Cookiebot, Didomi, Quantcast Choice, TrustArc) render in shadow DOM; expect `deep-ref=`
|
|
12
|
+
3. **Re-snapshot.** DOM behind banner changes on close; previous refs stale
|
|
13
|
+
4. **Proceed** with actual task
|
|
14
|
+
|
|
15
|
+
## Dismiss button NOT in snapshot
|
|
16
|
+
|
|
17
|
+
Banner rendering inside shadow root accessibility tree didn't pierce. Try deep selector by host:
|
|
18
|
+
|
|
19
|
+
| Vendor | Common deep selector |
|
|
20
|
+
| --------- | -------------------------------------------------------------------- |
|
|
21
|
+
| OneTrust | `< #onetrust-reject-all-handler` or `< #onetrust-accept-btn-handler` |
|
|
22
|
+
| Cookiebot | `< #CybotCookiebotDialogBodyButtonDecline` |
|
|
23
|
+
| Didomi | `< #didomi-notice-disagree-button` |
|
|
24
|
+
| Quantcast | `< button.css-47sehv` (reject) — class names rotate, snapshot first |
|
|
25
|
+
| TrustArc | `< *consent.trustarc.com* #decline_btn_text` (iframe-hosted) |
|
|
26
|
+
| Cookieyes | `< .cky-btn-reject` |
|
|
27
|
+
|
|
28
|
+
No match → fallback to attribute-based deep selectors: `< button[aria-label*="Reject" i]`, `< button[id*="reject" i]`. See shadow-dom skill for full syntax.
|
|
29
|
+
|
|
30
|
+
## Don't
|
|
31
|
+
|
|
32
|
+
- Click `Accept all` reflexively. Sites track aggressively and may serve different content. Prefer reject when both present
|
|
33
|
+
- Dismiss via `evaluate` removing banner element. Consent state server-side/cookies; hiding banner doesn't grant access, leaves event handlers blocking clicks
|
|
34
|
+
- Continue with selectors from pre-dismiss snapshot. Always re-snapshot after close
|
|
35
|
+
|
|
36
|
+
## Batching
|
|
37
|
+
|
|
38
|
+
Combine dismiss click with re-snapshot:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"commands": [
|
|
43
|
+
{
|
|
44
|
+
"method": "click",
|
|
45
|
+
"params": { "selector": "< #onetrust-reject-all-handler" }
|
|
46
|
+
},
|
|
47
|
+
{ "method": "snapshot" }
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Waiting for Dynamic Content
|
|
2
|
+
|
|
3
|
+
`wait*` timed out or page loads async content. Wait for signal work is done, not arbitrary delay.
|
|
4
|
+
|
|
5
|
+
## Decision tree
|
|
6
|
+
|
|
7
|
+
| Situation | Use |
|
|
8
|
+
| ------------------------------ | ------------------------------------------ |
|
|
9
|
+
| Know API endpoint | `waitForResponse { url, statuses: [200] }` |
|
|
10
|
+
| Know CSS selector appears | `waitForSelector { selector, timeout }` |
|
|
11
|
+
| Page navigates | `waitForNavigation { timeout }` |
|
|
12
|
+
| Nothing specific (last resort) | `waitForTimeout { time: 3000 }` |
|
|
13
|
+
|
|
14
|
+
`waitForResponse` most reliable — fires on network event. Prefer when URL pattern known.
|
|
15
|
+
|
|
16
|
+
## Patterns
|
|
17
|
+
|
|
18
|
+
**Search results:**
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"commands": [
|
|
23
|
+
{ "method": "type", "params": { "selector": "input#q", "text": "query" } },
|
|
24
|
+
{ "method": "click", "params": { "selector": "button#search" } },
|
|
25
|
+
{
|
|
26
|
+
"method": "waitForResponse",
|
|
27
|
+
"params": { "url": "*api/search*", "statuses": [200] }
|
|
28
|
+
},
|
|
29
|
+
{ "method": "snapshot" }
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Form with redirect:**
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"commands": [
|
|
39
|
+
{ "method": "click", "params": { "selector": "button#submit" } },
|
|
40
|
+
{ "method": "waitForNavigation", "params": { "timeout": 10000 } },
|
|
41
|
+
{ "method": "snapshot" }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Lazy modal:**
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"commands": [
|
|
51
|
+
{ "method": "click", "params": { "selector": "button#open" } },
|
|
52
|
+
{
|
|
53
|
+
"method": "waitForSelector",
|
|
54
|
+
"params": { "selector": "[role='dialog']", "timeout": 5000 }
|
|
55
|
+
},
|
|
56
|
+
{ "method": "snapshot" }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## When timeout occurs
|
|
62
|
+
|
|
63
|
+
1. **Re-snapshot** — content may already be there (wrong wait condition)
|
|
64
|
+
2. **Widen pattern** — `*api/search*` matches more than exact URL
|
|
65
|
+
3. **Switch wait type** — if `waitForResponse` fails, try `waitForSelector` for rendered output
|
|
66
|
+
4. **Last resort:** `waitForTimeout { time: 3000 }`
|
|
67
|
+
|
|
68
|
+
## Avoid
|
|
69
|
+
|
|
70
|
+
- `evaluate` with setTimeout/Promise (returns before timer completes)
|
|
71
|
+
- Multiple `waitForTimeout` stacked (use specific wait methods)
|
|
72
|
+
- Tight snapshot loop without wait (burns tokens, races page)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DetectContext, Skill, SkillFireState, SkillId } from '../@types/types.js';
|
|
2
|
+
export type { SkillId, DetectContext, SkillFireState, } from '../@types/types.js';
|
|
3
|
+
export declare const skillsRegistry: ReadonlyArray<Skill>;
|
|
4
|
+
export declare const isCloudApi: (apiUrl: string | undefined) => boolean;
|
|
5
|
+
export declare const createSkillState: () => SkillFireState;
|
|
6
|
+
export declare const detectSkills: (ctx: DetectContext, state: SkillFireState) => SkillId[];
|
|
7
|
+
export declare const markFired: (state: SkillFireState, ids: ReadonlyArray<SkillId>) => void;
|
|
8
|
+
export declare const renderSkill: (id: SkillId) => string;
|
|
9
|
+
export declare const renderSkills: (ids: ReadonlyArray<SkillId>) => string;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const DEFAULT_MAX_ELEMENTS = 500;
|
|
5
|
+
const CLOUD_API_HOSTS = ['production.browserless.io', 'chrome.browserless.io'];
|
|
6
|
+
const COOKIE_NAME_RE = /\b(accept all|reject all|consent|cookies?)\b/i;
|
|
7
|
+
const CAPTCHA_TEXT_RE = /\b(verify you are human|verifying you are human|i'?m not a robot|checking your browser|are you human|complete the (captcha|challenge))\b/i;
|
|
8
|
+
const CAPTCHA_HOST_RE = /(challenges\.cloudflare\.com|geo\.captcha-delivery\.com|hcaptcha\.com|recaptcha\.net|google\.com\/recaptcha)/i;
|
|
9
|
+
const CAPTCHA_ERROR_RE = /\b(captcha|cloudflare|challenge|forbidden|429|403)\b/i;
|
|
10
|
+
const LOGIN_URL_RE = /\/(login|signin|sign-?in|log-?in|auth|sso|oauth)\b|\/account\/sign/i;
|
|
11
|
+
const LOGIN_NUDGE_RE = /sign in to (view|see|continue|access|read|comment|post|reply|save|order|buy|checkout|your account)|please sign in|signed out\b.*sign in|create (an )?account to/i;
|
|
12
|
+
const TAB_ERROR_CODES = ['TAB_NOT_FOUND', 'TAB_CLOSED', 'TAB_LIMIT_EXCEEDED'];
|
|
13
|
+
const TAB_COMMAND_METHODS = ['getTabs', 'switchTab', 'createTab', 'closeTab'];
|
|
14
|
+
const evalPredicate = (p, ctx) => {
|
|
15
|
+
switch (p.kind) {
|
|
16
|
+
case 'snapshot.has-element': {
|
|
17
|
+
const els = ctx.snapshot?.elements;
|
|
18
|
+
if (!els)
|
|
19
|
+
return false;
|
|
20
|
+
return els.some((el) => {
|
|
21
|
+
if (p.roles && !p.roles.includes(el.role))
|
|
22
|
+
return false;
|
|
23
|
+
if (p.nameRegex) {
|
|
24
|
+
const name = el.name || el.text || '';
|
|
25
|
+
if (!p.nameRegex.test(name))
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (p.selectorPrefix && !el.selector?.startsWith(p.selectorPrefix)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
case 'snapshot.has-input-type':
|
|
35
|
+
return !!ctx.snapshot?.elements?.some((el) => el.type === p.type);
|
|
36
|
+
case 'snapshot.url-match':
|
|
37
|
+
return !!ctx.snapshot?.url && p.regex.test(ctx.snapshot.url);
|
|
38
|
+
case 'snapshot.has-detected-challenge':
|
|
39
|
+
return !!ctx.snapshot?.detectedChallenges?.length;
|
|
40
|
+
case 'snapshot.tabs-at-least':
|
|
41
|
+
return (ctx.snapshot?.tabs?.length ?? 0) >= p.count;
|
|
42
|
+
case 'snapshot.element-cap-hit': {
|
|
43
|
+
const snap = ctx.snapshot;
|
|
44
|
+
const cmd = ctx.cmd;
|
|
45
|
+
if (!snap || cmd?.method !== 'snapshot')
|
|
46
|
+
return false;
|
|
47
|
+
const len = snap.elements.length;
|
|
48
|
+
if (len === 0)
|
|
49
|
+
return true;
|
|
50
|
+
const requestedMax = typeof cmd.params?.maxElements === 'number'
|
|
51
|
+
? cmd.params.maxElements
|
|
52
|
+
: DEFAULT_MAX_ELEMENTS;
|
|
53
|
+
return len >= requestedMax;
|
|
54
|
+
}
|
|
55
|
+
case 'error.code':
|
|
56
|
+
return !!ctx.error?.code && p.codes.includes(ctx.error.code);
|
|
57
|
+
case 'error.message-match':
|
|
58
|
+
return !!ctx.error?.message && p.regex.test(ctx.error.message);
|
|
59
|
+
case 'command.method':
|
|
60
|
+
return !!ctx.cmd?.method && p.methods.includes(ctx.cmd.method);
|
|
61
|
+
case 'command.method-prefix':
|
|
62
|
+
return !!ctx.cmd?.method && ctx.cmd.method.startsWith(p.prefix);
|
|
63
|
+
case 'command.selector-not-deep': {
|
|
64
|
+
const sel = ctx.cmd?.params?.selector;
|
|
65
|
+
return typeof sel === 'string' && !sel.startsWith('< ');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const SKILL_SPECS = [
|
|
70
|
+
{
|
|
71
|
+
id: 'shadow-dom',
|
|
72
|
+
path: 'src/skills/shadow-dom.md',
|
|
73
|
+
refireAfter: 3,
|
|
74
|
+
triggers: [
|
|
75
|
+
// snapshot contains a deep-ref element
|
|
76
|
+
[{ kind: 'snapshot.has-element', selectorPrefix: '< ' }],
|
|
77
|
+
// selector-not-found error on a non-deep selector
|
|
78
|
+
[
|
|
79
|
+
{ kind: 'error.code', codes: ['SELECTOR_NOT_FOUND'] },
|
|
80
|
+
{ kind: 'command.selector-not-deep' },
|
|
81
|
+
],
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'cookie-consent',
|
|
86
|
+
path: 'src/skills/cookie-consent.md',
|
|
87
|
+
triggers: [
|
|
88
|
+
[
|
|
89
|
+
{
|
|
90
|
+
kind: 'snapshot.has-element',
|
|
91
|
+
roles: ['button', 'link'],
|
|
92
|
+
nameRegex: COOKIE_NAME_RE,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'modals',
|
|
99
|
+
path: 'src/skills/modals.md',
|
|
100
|
+
triggers: [
|
|
101
|
+
[{ kind: 'snapshot.has-element', roles: ['dialog', 'alertdialog'] }],
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'snapshot-misses',
|
|
106
|
+
path: 'src/skills/snapshot-misses.md',
|
|
107
|
+
triggers: [[{ kind: 'snapshot.element-cap-hit' }]],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'screenshots',
|
|
111
|
+
path: 'src/skills/screenshots.md',
|
|
112
|
+
triggers: [[{ kind: 'command.method', methods: ['screenshot'] }]],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'dynamic-content',
|
|
116
|
+
path: 'src/skills/dynamic-content.md',
|
|
117
|
+
triggers: [
|
|
118
|
+
[
|
|
119
|
+
{ kind: 'command.method-prefix', prefix: 'wait' },
|
|
120
|
+
{ kind: 'error.message-match', regex: /timeout|timed out/i },
|
|
121
|
+
],
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'tabs',
|
|
126
|
+
path: 'src/skills/tabs.md',
|
|
127
|
+
triggers: [
|
|
128
|
+
[{ kind: 'snapshot.tabs-at-least', count: 2 }],
|
|
129
|
+
[{ kind: 'error.code', codes: TAB_ERROR_CODES }],
|
|
130
|
+
[{ kind: 'command.method', methods: TAB_COMMAND_METHODS }],
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'autonomous-login',
|
|
135
|
+
path: 'src/skills/autonomous-login.md',
|
|
136
|
+
triggers: [
|
|
137
|
+
[{ kind: 'snapshot.has-input-type', type: 'password' }],
|
|
138
|
+
[{ kind: 'snapshot.url-match', regex: LOGIN_URL_RE }],
|
|
139
|
+
[
|
|
140
|
+
{
|
|
141
|
+
kind: 'snapshot.has-element',
|
|
142
|
+
roles: ['button', 'link'],
|
|
143
|
+
nameRegex: LOGIN_NUDGE_RE,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'captchas',
|
|
150
|
+
path: 'src/skills/captchas.md',
|
|
151
|
+
cloudOnly: true,
|
|
152
|
+
refireAfter: 3,
|
|
153
|
+
triggers: [
|
|
154
|
+
[{ kind: 'snapshot.has-detected-challenge' }],
|
|
155
|
+
[{ kind: 'snapshot.url-match', regex: CAPTCHA_HOST_RE }],
|
|
156
|
+
[{ kind: 'snapshot.has-element', nameRegex: CAPTCHA_TEXT_RE }],
|
|
157
|
+
[
|
|
158
|
+
{ kind: 'command.method', methods: ['goto'] },
|
|
159
|
+
{ kind: 'error.message-match', regex: CAPTCHA_ERROR_RE },
|
|
160
|
+
],
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
const skillsDir = dirname(fileURLToPath(import.meta.url));
|
|
165
|
+
const loadBody = (filename) => readFileSync(join(skillsDir, filename), 'utf-8');
|
|
166
|
+
const skills = SKILL_SPECS.map((spec) => ({
|
|
167
|
+
...spec,
|
|
168
|
+
body: loadBody(basename(spec.path)),
|
|
169
|
+
}));
|
|
170
|
+
export const skillsRegistry = skills;
|
|
171
|
+
export const isCloudApi = (apiUrl) => {
|
|
172
|
+
if (!apiUrl)
|
|
173
|
+
return false;
|
|
174
|
+
try {
|
|
175
|
+
const host = new URL(apiUrl).hostname;
|
|
176
|
+
return CLOUD_API_HOSTS.includes(host);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
export const createSkillState = () => ({
|
|
183
|
+
fired: new Map(),
|
|
184
|
+
cmdIndex: 0,
|
|
185
|
+
});
|
|
186
|
+
const fires = (skill, ctx) => skill.triggers.some((trigger) => trigger.every((p) => evalPredicate(p, ctx)));
|
|
187
|
+
export const detectSkills = (ctx, state) => {
|
|
188
|
+
const triggered = [];
|
|
189
|
+
for (const skill of skills) {
|
|
190
|
+
if (skill.cloudOnly && !isCloudApi(ctx.apiUrl))
|
|
191
|
+
continue;
|
|
192
|
+
if (!fires(skill, ctx))
|
|
193
|
+
continue;
|
|
194
|
+
const lastFired = state.fired.get(skill.id);
|
|
195
|
+
if (lastFired === undefined) {
|
|
196
|
+
triggered.push(skill.id);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (skill.refireAfter !== undefined &&
|
|
200
|
+
state.cmdIndex - lastFired >= skill.refireAfter) {
|
|
201
|
+
triggered.push(skill.id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return triggered;
|
|
205
|
+
};
|
|
206
|
+
export const markFired = (state, ids) => {
|
|
207
|
+
for (const id of ids) {
|
|
208
|
+
state.fired.set(id, state.cmdIndex);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
export const renderSkill = (id) => {
|
|
212
|
+
const skill = skills.find((s) => s.id === id);
|
|
213
|
+
if (!skill)
|
|
214
|
+
return '';
|
|
215
|
+
return [
|
|
216
|
+
`--- SKILL: ${skill.id} (${skill.path}) ---`,
|
|
217
|
+
skill.body.trimEnd(),
|
|
218
|
+
'--- END SKILL ---',
|
|
219
|
+
].join('\n');
|
|
220
|
+
};
|
|
221
|
+
export const renderSkills = (ids) => ids.map(renderSkill).filter(Boolean).join('\n\n');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Modal Dialogs
|
|
2
|
+
|
|
3
|
+
Snapshot shows `role: dialog` or `role: alertdialog` — modal is open, traps focus/clicks.
|
|
4
|
+
|
|
5
|
+
## Strategy
|
|
6
|
+
|
|
7
|
+
**Want to interact with it?** → Use element refs from current snapshot.
|
|
8
|
+
**Want it gone?** → Close it, then re-snapshot.
|
|
9
|
+
|
|
10
|
+
## Closing (try in order)
|
|
11
|
+
|
|
12
|
+
1. **Close button in snapshot** — `Close`, `×`, `Dismiss`, `No thanks`, etc. Click its ref.
|
|
13
|
+
|
|
14
|
+
2. **Aria-labeled close:**
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{ "method": "click", "params": { "selector": "[aria-label='Close']" } }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
3. **Escape key:**
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"method": "evaluate",
|
|
25
|
+
"params": {
|
|
26
|
+
"content": "(() => { document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); })()"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
4. **Click backdrop:**
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"method": "click",
|
|
36
|
+
"params": {
|
|
37
|
+
"selector": ".modal-backdrop, [class*='overlay']:not([class*='inner'])"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
5. **Re-snapshot** to confirm gone.
|
|
43
|
+
|
|
44
|
+
## alertdialog
|
|
45
|
+
|
|
46
|
+
Critical confirmations ("Delete?"). Don't auto-dismiss. Find explicit button (`Confirm`, `Delete`, `Yes`) if task requires it.
|
|
47
|
+
|
|
48
|
+
## After closing
|
|
49
|
+
|
|
50
|
+
- Refs behind modal still valid (overlay, not reflow)
|
|
51
|
+
- Focus/scroll may have shifted — re-snapshot before type/scroll actions
|
|
52
|
+
|
|
53
|
+
## Avoid
|
|
54
|
+
|
|
55
|
+
- Removing modal DOM via evaluate (SPAs remount it)
|
|
56
|
+
- Interacting with page behind without closing first (pointer events captured)
|