@opencraw/mcp 0.1.44 → 0.1.47
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/README.md +27 -1
- package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
- package/dist/diff-tool/index.d.ts +3 -0
- package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/docs-tool/index.d.ts +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.esm.js +449 -6
- package/dist/list-tool/index.d.ts +3 -0
- package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
- package/dist/main.d.ts +7 -0
- package/dist/probe-tool/index.d.ts +2 -0
- package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
- package/dist/recipe-source/index.d.ts +3 -0
- package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
- package/dist/run-tool/index.d.ts +3 -0
- package/dist/run-tool/run-tool.handler.d.ts +51 -0
- package/dist/server/create-server.use-case.d.ts +11 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/src/docs-tool/index.d.ts +3 -0
- package/dist/src/server/create-server.use-case.d.ts +1 -1
- package/dist/src/studio-tool/index.d.ts +2 -0
- package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/studio-tool/index.d.ts +2 -0
- package/dist/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/validate-tool/index.d.ts +3 -0
- package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
- package/docs/how-it-works/01-recipes.md +698 -0
- package/docs/how-it-works/02-page-steps.md +672 -0
- package/docs/how-it-works/03-data-steps.md +813 -0
- package/docs/how-it-works/04-flow-steps.md +738 -0
- package/docs/how-it-works/05-templates-and-scope.md +428 -0
- package/docs/how-it-works/06-documents.md +1413 -0
- package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
- package/docs/how-it-works/08-policies.md +405 -0
- package/docs/how-it-works/09-running.md +804 -0
- package/docs/how-it-works/README.md +225 -0
- package/docs/recipes/README.md +22 -0
- package/docs/recipes/access.md +440 -0
- package/docs/recipes/authoring.md +1503 -0
- package/docs/recipes/azure-durable.md +180 -0
- package/docs/recipes/captcha.md +204 -0
- package/docs/recipes/movies-example.md +12 -0
- package/docs/recipes/programmatic-authoring.md +101 -0
- package/docs/recipes/quick-guide.md +157 -0
- package/docs/recipes/vehicles-example.md +9 -0
- package/docs/recipes/worker-mode.md +221 -0
- package/docs/studio/manual.md +312 -0
- package/docs/studio/pdf-agreements.md +150 -0
- package/package.json +29 -4
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Access: proxies and where traffic goes
|
|
6
|
+
|
|
7
|
+
Some sites block the network a crawl runs from before any recipe logic matters. IMDb answered this
|
|
8
|
+
project's sandbox with an AWS WAF challenge, and Peugeot UK's Akamai edge answered "Access Denied", real
|
|
9
|
+
browser included. No selector fixes an IP-level block. The fix is to send the traffic from somewhere else,
|
|
10
|
+
through a proxy.
|
|
11
|
+
|
|
12
|
+
This page explains how OpenCraw does that: the recipe says what the site needs, a separate access config
|
|
13
|
+
says how to get it, and the engine applies the result to every browser page, bootstrap and HTTP request of
|
|
14
|
+
the run. When a response is a block anyway, the run can take a new lease and retry. A profile can also point at
|
|
15
|
+
a remote browser service instead of a proxy.
|
|
16
|
+
|
|
17
|
+
## Two files, two concerns
|
|
18
|
+
|
|
19
|
+
| Where | Holds | Shared? |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| The input recipe, `session.access` | What the **site** needs: which profile, which country, whether the IP must stay the same. | Yes. No secrets. |
|
|
22
|
+
| The access config, a runner-side JSON file | How **you** get it: providers, gateways, credentials (as `{{env.NAME}}`). | No. It points at your accounts. |
|
|
23
|
+
|
|
24
|
+
A recipe:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
"session": { "access": { "profile": "residential", "country": "gb" } }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
An access config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"$schema": "../packages/core/schemas/access-config.schema.json",
|
|
35
|
+
"profiles": {
|
|
36
|
+
"residential": { "kind": "proxy", "preset": "brightdata",
|
|
37
|
+
"params": { "customer": "{{env.BRD_CUSTOMER}}", "zone": "residential", "password": "{{env.BRD_PASSWORD}}" } },
|
|
38
|
+
"office": { "kind": "proxy", "server": "http://proxy.internal:3128", "username": "crawler", "password": "{{env.OFFICE_PROXY_PASSWORD}}" }
|
|
39
|
+
},
|
|
40
|
+
"default": "office"
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`default` is used by every recipe that names no profile. Without an access config, or without a default,
|
|
45
|
+
recipes go direct.
|
|
46
|
+
|
|
47
|
+
Run it:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
BRD_CUSTOMER=hl_123 BRD_PASSWORD=... opencraw run recipes/ --access access.json
|
|
51
|
+
opencraw run recipes/ --access access.json --access-profile residential # override the default
|
|
52
|
+
OPENCRAW_ACCESS=access.json opencraw probe https://example.com # the env var works too
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
From code: `createCrawler({ access: await loadAccessConfig('access.json') })`. From the MCP server: start it
|
|
56
|
+
with `OPENCRAW_ACCESS=/path/to/access.json` in its environment. The `run` and `probe` tools then take an
|
|
57
|
+
`access` argument naming a profile; the file never passes through the tool call.
|
|
58
|
+
|
|
59
|
+
## Why a config and not a plugin per provider
|
|
60
|
+
|
|
61
|
+
Before designing this, we surveyed 10 proxy networks, 8 "unblocker" products, 6 scraping APIs and 9
|
|
62
|
+
remote-browser services. Each one connects in one of three ways:
|
|
63
|
+
|
|
64
|
+
| Shape | Who | How OpenCraw handles it |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| HTTP proxy, `{ server, username, password }` | All 10 proxy networks (Bright Data, Oxylabs, Decodo, IPRoyal, Webshare, SOAX, NetNut, Rayobyte, Evomi, Massive) and every unblocker's proxy mode (Bright Data Web Unlocker, Oxylabs Web Unblocker, Zyte, ScraperAPI, ScrapingBee, Scrapfly, Decodo, ZenRows) | `proxy` profiles, with presets |
|
|
67
|
+
| Remote browser over CDP | Bright Data Browser API, Browserless, Browserbase, Zyte, Oxylabs, Steel, Hyperbrowser | `cdp` profiles; a plugin when the URL comes from an API call |
|
|
68
|
+
| URL-rewriting API (`GET api.x.com?url=`) | ScraperAPI, ScrapingBee, Zyte, Scrapfly, ZenRows | Not supported: no live page, a different request and response shape per vendor, and every one of these vendors also sells the proxy shape |
|
|
69
|
+
|
|
70
|
+
The proxy shape covers almost everyone. The providers differ only in where they want options written:
|
|
71
|
+
|
|
72
|
+
- in the **username** (Bright Data `-country-us-session-abc`, Oxylabs `-cc-US-sessid-abc`);
|
|
73
|
+
- in the **password** (IPRoyal `_country-us_session-abcd1234`);
|
|
74
|
+
- in **headers** (Zyte `Zyte-Geolocation`, Oxylabs `x-oxylabs-geo-location`).
|
|
75
|
+
|
|
76
|
+
Those differences are data, not code, so a provider is a **preset**: a few templates. A plugin is only needed
|
|
77
|
+
where connecting takes code (see [Plugins](#plugins)).
|
|
78
|
+
|
|
79
|
+
## Profiles
|
|
80
|
+
|
|
81
|
+
Every string in a profile is a template. It can read:
|
|
82
|
+
|
|
83
|
+
| Path | Value |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `{{env.NAME}}` | The process environment. A variable that is not set fails the recipe with its name, so an empty password never reaches the provider. |
|
|
86
|
+
| `{{params.x}}` | The profile's own `params`, which may themselves read `env`. |
|
|
87
|
+
| `{{session}}` | A new random session id per lease, in the profile's `session.idFormat`. Absent when the lease is not sticky. |
|
|
88
|
+
| `{{country}}` | The recipe's `session.access.country`. Absent when the recipe names none. |
|
|
89
|
+
| `{{recipe.id}}` | The input recipe's id. |
|
|
90
|
+
|
|
91
|
+
Templates use the full expression syntax (authoring §3.4), so optional parts are one ternary:
|
|
92
|
+
`"user-{{params.user}}{{country ? '-country-' + lower(country) : ''}}"`.
|
|
93
|
+
|
|
94
|
+
### `proxy`
|
|
95
|
+
|
|
96
|
+
| Field | Meaning |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `preset` | A built-in preset (below) that fills `server`, `username`, `password`, `headers` and defaults. Explicit fields override it. |
|
|
99
|
+
| `params` | Values the preset's templates read as `params.*`. |
|
|
100
|
+
| `server` | `http://host:port`. `https://` proxies work too. SOCKS only without credentials: Chromium never sends SOCKS credentials (microsoft/playwright#10567), so an authenticated SOCKS profile is refused when loaded. |
|
|
101
|
+
| `username`, `password` | Templates. |
|
|
102
|
+
| `bypass` | Comma-separated hosts that skip the proxy. |
|
|
103
|
+
| `headers` | Sent with every request. Templates; a header that renders empty is dropped. |
|
|
104
|
+
| `ignoreHTTPSErrors` | Needed for providers that intercept HTTPS: every unblocker, and Bright Data residential and mobile zones. |
|
|
105
|
+
| `session.idFormat` | `alnum10` (default), or `alnum`, `digits` or `hex` followed by a length. IPRoyal wants exactly 8 characters, Webshare and ScrapingBee want digits. |
|
|
106
|
+
| `session.rotate` | `per-recipe` (default): one session, so one IP, for the whole recipe run. `per-request`: no session id, and the provider rotates the IP on every request. A recipe's `session.access.sticky` overrides it. |
|
|
107
|
+
| `blockResources` | Resource types pages never load, such as `image`, `font`, `media`, `stylesheet`. Residential and mobile proxies bill per GB, and a crawl rarely needs images. Web mode only. |
|
|
108
|
+
|
|
109
|
+
### `pool`
|
|
110
|
+
|
|
111
|
+
A list of proxies, for providers that sell IP lists (Webshare direct, dedicated datacenter IPs) or your own.
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "kind": "pool", "rotate": "round-robin", "proxies": [
|
|
115
|
+
{ "server": "http://203.0.113.10:8080", "username": "u", "password": "{{env.POOL_PASSWORD}}" },
|
|
116
|
+
{ "server": "http://203.0.113.11:8080", "username": "u", "password": "{{env.POOL_PASSWORD}}" }
|
|
117
|
+
]}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Each lease takes the next entry, or a random one with `"rotate": "random"`. `headers`, `ignoreHTTPSErrors`
|
|
121
|
+
and `blockResources` apply to every entry.
|
|
122
|
+
|
|
123
|
+
### `cdp`: a remote browser
|
|
124
|
+
|
|
125
|
+
Remote-browser services run the browser for you and deal with IPs, fingerprints and challenges. A web recipe
|
|
126
|
+
connects to one over the Chrome DevTools Protocol instead of launching Chromium:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
"cloud": { "kind": "cdp",
|
|
130
|
+
"endpoint": "wss://brd-customer-{{env.BRD_CUSTOMER}}-zone-{{env.BRD_ZONE}}{{country ? '-country-' + lower(country) : ''}}:{{env.BRD_PASSWORD}}@brd.superproxy.io:9222" }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Field | Meaning |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `endpoint` | `wss://…` or `http://host:port`. A template: tokens and credentials usually live in it. |
|
|
136
|
+
| `headers` | Sent with the connection, for providers that authenticate with a header. Templates. |
|
|
137
|
+
| `params` | Read as `params.*`, as in `proxy` profiles. |
|
|
138
|
+
| `session.idFormat` | The format of `{{session}}`, for providers that name sessions in the URL. |
|
|
139
|
+
| `blockResources` | As in `proxy` profiles. |
|
|
140
|
+
|
|
141
|
+
Endpoints of common services, to adapt from the provider's current docs:
|
|
142
|
+
|
|
143
|
+
| Service | `endpoint` |
|
|
144
|
+
|---|---|
|
|
145
|
+
| Bright Data Browser API | `wss://brd-customer-…-zone-…[-country-xx]:PASSWORD@brd.superproxy.io:9222` |
|
|
146
|
+
| Browserless | `wss://production-sfo.browserless.io?token=…[&proxy=residential&proxyCountry=xx]` |
|
|
147
|
+
| Oxylabs Headless Browser | `wss://USER:PASS@hb.oxylabs.io?p_cc=XX` |
|
|
148
|
+
| Zyte | `https://browser.zyte.com/?ttl=600[&proxy_region=XX]`, with `headers: { "Authorization": "Basic {{env.ZYTE_BASIC}}" }` (the key followed by `:`, base64-encoded) |
|
|
149
|
+
|
|
150
|
+
How a remote session is used:
|
|
151
|
+
|
|
152
|
+
- **Web recipes only.** An api recipe on a `cdp` profile fails at the start of its run, before any request,
|
|
153
|
+
with a message saying it needs a proxy profile. `probe` refuses one too.
|
|
154
|
+
- **The bootstrap runs in the same remote session as the crawl.** Providers tie the IP and fingerprint to the
|
|
155
|
+
connection, so a login in one connection would not carry over to another.
|
|
156
|
+
- **The provider's own context is reused** when it offers one, because some providers pin the proxy and
|
|
157
|
+
fingerprint to it. Cookies (from `session.cookies` or a saved `storageStatePath`), headers, blocked resources
|
|
158
|
+
and the viewport are applied to it. `session.userAgent` cannot change on an existing context and is ignored.
|
|
159
|
+
From a saved state only cookies apply, not localStorage.
|
|
160
|
+
- **Closing disconnects**, which ends the session on the provider's side (and its billing).
|
|
161
|
+
- **Rotation reconnects.** With `onBlock.rotate`, a block opens a new connection, and so a new remote session.
|
|
162
|
+
Some providers allow one domain per session and short idle timeouts, so rotation is the normal way to get a
|
|
163
|
+
fresh one.
|
|
164
|
+
|
|
165
|
+
### `direct` and `plugin`
|
|
166
|
+
|
|
167
|
+
`{ "kind": "direct" }` names "no proxy" explicitly, for example as the default while some recipes ask for a
|
|
168
|
+
proxy. `{ "kind": "plugin", "name": "...", "options": { ... } }` hands the lease to a registered plugin.
|
|
169
|
+
|
|
170
|
+
## Presets
|
|
171
|
+
|
|
172
|
+
| Preset | Provider | `params` | Where options go | Notes |
|
|
173
|
+
|---|---|---|---|---|
|
|
174
|
+
| `brightdata` | Bright Data proxy zones: residential, datacenter, ISP, mobile, Web Unlocker | `customer`, `zone`, `password` | username | Port 44445: the certificates of the old ports 22225 and 33335 expired on 2026-09-25. Intercepts HTTPS, so `ignoreHTTPSErrors` is on. Bright Data advises against browsers behind Web Unlocker. |
|
|
175
|
+
| `oxylabs-residential` | Oxylabs residential | `user`, `password`, `sessionMinutes?` | username | Sticky session length defaults to 10 minutes. |
|
|
176
|
+
| `decodo` | Decodo (formerly Smartproxy) residential | `user`, `password`, `sessionMinutes?` | username | |
|
|
177
|
+
| `iproyal` | IPRoyal residential | `user`, `password`, `lifetime?` (such as `10m`) | password | Session ids are exactly 8 characters. |
|
|
178
|
+
| `webshare` | Webshare rotating backbone | `user`, `password` | username | Not sticky means `-rotate`. |
|
|
179
|
+
| `scrapingbee` | ScrapingBee in proxy mode | `apiKey`, `premium?` | password | `render_js=False` is always sent: the browser renders. Every sub-resource costs credits, so block resources. |
|
|
180
|
+
| `zyte` | Zyte API in proxy mode | `apiKey` | headers | Intercepts HTTPS. |
|
|
181
|
+
|
|
182
|
+
Adding a provider means adding an entry to `packages/core/src/access/access-preset.store.ts`: a server and
|
|
183
|
+
templates, no code. Any provider can also be written as a raw `proxy` profile without a preset.
|
|
184
|
+
|
|
185
|
+
## What the engine does with a lease
|
|
186
|
+
|
|
187
|
+
- **One lease per recipe run.** The bootstrap (a login, a consent wall) and the crawl that follows share it,
|
|
188
|
+
so the cookies a login produced are used from the same IP. A new lease, with a new session id, is taken for
|
|
189
|
+
each recipe run.
|
|
190
|
+
- **Both modes.** Web recipes get the proxy on their browser context. Api recipes get it on their HTTP
|
|
191
|
+
request context, which tunnels even `http://` targets through `CONNECT`.
|
|
192
|
+
- **The trace shows it.** `access:lease` names the profile, the kind, the proxy server and the session id,
|
|
193
|
+
never the credentials:
|
|
194
|
+
|
|
195
|
+
```text
|
|
196
|
+
▶ imdb (web)
|
|
197
|
+
⇄ access residential (proxy https://brd.superproxy.io:44445, session k3v9x0q2ma)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- **A recipe that asks for a country with no profile applying** runs direct and gets a `warning` event rather
|
|
201
|
+
than silently ignoring it.
|
|
202
|
+
- **A block** (below) can make the run take a new lease mid-crawl.
|
|
203
|
+
|
|
204
|
+
## Blocks and rotation
|
|
205
|
+
|
|
206
|
+
Every navigation (web) and request (api) is checked against the recipe's block rule. A match fails the step
|
|
207
|
+
with a `BlockedError` naming the URL and the reason, and emits `access:blocked`. That replaces the old
|
|
208
|
+
symptom, a selector that finds nothing on a challenge page.
|
|
209
|
+
|
|
210
|
+
The default rule treats these as a block: status 403 or 429, or an `x-amzn-waf-action: challenge` header
|
|
211
|
+
(AWS WAF answers `202` with it, which is how IMDb refused this project's sandbox). A recipe can replace the
|
|
212
|
+
rule:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
"session": {
|
|
216
|
+
"blockedWhen": { "status": [403, 503], "header": { "server": "AkamaiGHost" }, "text": "access denied|verify you are human" },
|
|
217
|
+
"onBlock": { "rotate": true, "attempts": 2 }
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`blockedWhen` has three optional conditions, and any one that matches is a block:
|
|
222
|
+
|
|
223
|
+
- `status`: a list of HTTP statuses.
|
|
224
|
+
- `header`: header names mapped to a pattern the value must match.
|
|
225
|
+
- `text`: a pattern the body must match.
|
|
226
|
+
|
|
227
|
+
Patterns are case-insensitive regular expressions. A rule you set replaces the default rule completely.
|
|
228
|
+
|
|
229
|
+
`onBlock.rotate` makes the run take a new lease, which means a new session id and so a new IP on rotating
|
|
230
|
+
providers. It then reopens the browser or HTTP context, runs the bootstrap again (so a login happens on the new
|
|
231
|
+
IP), and retries the blocked step. `attempts` caps the rotations per recipe run and defaults to 2. A rotation
|
|
232
|
+
does not use up the step's own `retry` attempts. Once the rotations are used up, the block fails the step like
|
|
233
|
+
any other error, so `onError: skip` or `retry` still apply.
|
|
234
|
+
|
|
235
|
+
Under concurrency, several iterations blocked on the same lease trigger one rotation, not one each. The old
|
|
236
|
+
contexts stay open until the run ends, so requests still in flight on them finish.
|
|
237
|
+
|
|
238
|
+
The trace shows the route:
|
|
239
|
+
|
|
240
|
+
```text
|
|
241
|
+
⇄ access residential (proxy https://brd.superproxy.io:44445, session k3v9x0q2ma)
|
|
242
|
+
⇢ page 1 https://www.imdb.com/chart/top/ [403]
|
|
243
|
+
⛔ blocked https://www.imdb.com/chart/top/: HTTP 403
|
|
244
|
+
↻ new access lease (attempt 2)
|
|
245
|
+
⇄ access residential (proxy https://brd.superproxy.io:44445, session 0p2wq7x1ze)
|
|
246
|
+
⇢ page 1 https://www.imdb.com/chart/top/
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Rotation only helps when the new lease reaches the site from somewhere else: a proxy profile with sticky
|
|
250
|
+
sessions, a pool, or a plugin. On a `direct` profile it reopens the session from the same IP.
|
|
251
|
+
|
|
252
|
+
## Plugins
|
|
253
|
+
|
|
254
|
+
A plugin is for access that config cannot express: a remote browser whose URL comes from a REST call
|
|
255
|
+
(Browserbase, Steel, Hyperbrowser), a proxy list fetched from an API, a credential that expires. It is a name and
|
|
256
|
+
one function:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { createCrawler } from '@opencraw/core'
|
|
260
|
+
import type { AccessPlugin } from '@opencraw/core'
|
|
261
|
+
|
|
262
|
+
const rotatingList: AccessPlugin = {
|
|
263
|
+
name: 'my-list',
|
|
264
|
+
async lease ({ options, attempt }) {
|
|
265
|
+
const response = await fetch(String(options.listUrl))
|
|
266
|
+
const proxies = (await response.json()) as { server: string }[]
|
|
267
|
+
|
|
268
|
+
return { proxy: proxies[attempt % proxies.length] }
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const crawler = createCrawler({ accessPlugins: [rotatingList], access: {
|
|
273
|
+
profiles: { list: { kind: 'plugin', name: 'my-list', options: { listUrl: 'https://api.example/proxies?key={{env.LIST_KEY}}' } } },
|
|
274
|
+
default: 'list',
|
|
275
|
+
} })
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The plugin receives its profile's `options` with every string rendered, plus the recipe id, the requested
|
|
279
|
+
country, stickiness and the attempt number. It returns a lease: `proxy` or `cdp`, `headers`,
|
|
280
|
+
`ignoreHTTPSErrors`, `blockResources`, `session`, and an optional `release()` called when the run ends.
|
|
281
|
+
|
|
282
|
+
A remote browser created through an API is the same shape, returning `cdp`. This sketch follows Browserbase's
|
|
283
|
+
REST API; check the provider's current docs for the exact endpoints and fields:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const browserbase: AccessPlugin = {
|
|
287
|
+
name: 'browserbase',
|
|
288
|
+
async lease ({ options }) {
|
|
289
|
+
const headers = { 'X-BB-API-Key': String(options.apiKey), 'Content-Type': 'application/json' }
|
|
290
|
+
const created = await fetch('https://api.browserbase.com/v1/sessions', { method: 'POST', headers, body: JSON.stringify({ projectId: options.projectId, proxies: true }) })
|
|
291
|
+
const session = (await created.json()) as { id: string, connectUrl: string }
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
cdp: { endpoint: session.connectUrl },
|
|
295
|
+
session: session.id,
|
|
296
|
+
release: async () => {
|
|
297
|
+
await fetch(`https://api.browserbase.com/v1/sessions/${session.id}`, { method: 'POST', headers, body: JSON.stringify({ projectId: options.projectId, status: 'REQUEST_RELEASE' }) })
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`release()` runs when the recipe run ends, including after a rotation, so a remote session is never left
|
|
305
|
+
running.
|
|
306
|
+
|
|
307
|
+
**In any language, or behind an API.** A plugin does not have to be JavaScript: `commandAccessPlugin` runs
|
|
308
|
+
a program and `httpAccessPlugin` calls a service, and the profile is the same `{ "kind": "plugin", "name": … }`.
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
// plugins.mjs
|
|
312
|
+
import { commandAccessPlugin, httpAccessPlugin } from '@opencraw/core'
|
|
313
|
+
|
|
314
|
+
export const accessPlugins = [
|
|
315
|
+
httpAccessPlugin('leaser', 'https://proxies.example.com/lease', { tokenEnv: 'LEASER_TOKEN', allowedHosts: ['proxies.example.com'] }),
|
|
316
|
+
commandAccessPlugin('local-list', ['python3', 'plugins/lease_from_list.py']),
|
|
317
|
+
]
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The handler gets a callout request with `kind: "access"` (`schemas/callout-access-input.schema.json`):
|
|
321
|
+
|
|
322
|
+
```json
|
|
323
|
+
{ "kind": "access", "name": "leaser", "idempotencyKey": "…", "args": {}, "context": { "recipeId": "shop", "scope": {} },
|
|
324
|
+
"input": { "phase": "lease", "nonce": "5b1c…", "request": { "profile": "residential", "country": "it", "attempt": 1, "options": { "plan": "gold" } } } }
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
and answers with the lease (`schemas/callout-access-output.schema.json`), any of `proxy`, `cdp`, `session`,
|
|
328
|
+
`headers`, `ignoreHTTPSErrors` and `blockResources`:
|
|
329
|
+
|
|
330
|
+
```json
|
|
331
|
+
{ "status": "ok", "output": { "proxy": { "server": "http://gate.example:9000", "username": "u", "password": "p" }, "session": "s-81", "release": true } }
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- **`release: true`** asks to be told when the lease ends (`phase: "release"`, with the `profile` and the
|
|
335
|
+
`session`); the call is best effort, so a service should also expire what it is not told about.
|
|
336
|
+
- **Every lease is a new action.** The `nonce` is new per lease, so two leases for the same recipe, profile and
|
|
337
|
+
attempt are two leases. A `pending` answer is asked again with the same request, so the same nonce.
|
|
338
|
+
- **A lease is wanted now**: `pending` is polled up to `maxWaitMs` (120 s), never parked on a durable host.
|
|
339
|
+
- `{ "status": "error", "error": "no capacity" }` fails the recipe's run with the reason.
|
|
340
|
+
|
|
341
|
+
**From the cli and the MCP server**, plugins come from a plugins module: the same file that gives the recipes'
|
|
342
|
+
hooks, with named exports.
|
|
343
|
+
|
|
344
|
+
```js
|
|
345
|
+
// plugins.mjs
|
|
346
|
+
export const hooks = { positive: input => Number(input) > 0 }
|
|
347
|
+
export const accessPlugins = [rotatingList, browserbase]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
```sh
|
|
351
|
+
opencraw run recipes/ --plugins plugins.mjs --access access.json # or OPENCRAW_PLUGINS=plugins.mjs
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`probe` takes `--plugins` too, so it can lease from a plugin profile. The MCP server reads `OPENCRAW_PLUGINS`
|
|
355
|
+
(or `OPENCRAW_HOOKS`) from its own environment, never from a tool call. A profile naming a plugin the module
|
|
356
|
+
does not provide fails before the crawl starts.
|
|
357
|
+
|
|
358
|
+
## Throttling per site
|
|
359
|
+
|
|
360
|
+
`limits.delayMs` and `limits.concurrency` belong to one recipe. Two recipes that crawl the same site each
|
|
361
|
+
keep their own pace, so together they hit it twice as hard. The access config's `throttle` is the crawler's
|
|
362
|
+
politeness towards each **site**, across every recipe and run it executes:
|
|
363
|
+
|
|
364
|
+
```json
|
|
365
|
+
{
|
|
366
|
+
"profiles": {},
|
|
367
|
+
"throttle": {
|
|
368
|
+
"delayMs": 500,
|
|
369
|
+
"concurrency": 2,
|
|
370
|
+
"domains": {
|
|
371
|
+
"example.com": { "delayMs": 2000, "concurrency": 1 },
|
|
372
|
+
"api.example.com": { "delayMs": 0 }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- `delayMs`: at least this long between two request starts to one site. `concurrency`: at most this many of
|
|
379
|
+
its requests in flight. At the top level they apply to every site; under `domains`, to that domain and its
|
|
380
|
+
subdomains (`example.com` covers `www.example.com`), the longest match winning.
|
|
381
|
+
- A site is its host name unless a `domains` entry groups it: `www.example.com` and `shop.example.com` share
|
|
382
|
+
one lane under the `example.com` rule above, and have one each without it.
|
|
383
|
+
- It counts every navigation, request, bootstrap page and `next.selector` click the engine starts. Requests
|
|
384
|
+
a page makes by itself (its images, its scripts, its XHRs) are not counted.
|
|
385
|
+
- A recipe's own `limits` still apply on top: the stricter of the two wins.
|
|
386
|
+
|
|
387
|
+
Like a single-lane bridge with a traffic light: however many recipes arrive, each car waits for the one ahead
|
|
388
|
+
to be far enough across.
|
|
389
|
+
|
|
390
|
+
From the command line, `--host-delay <ms>` and `--host-concurrency <n>` set the top-level defaults (over the
|
|
391
|
+
file's). From code: `createCrawler({ throttle: { delayMs: 500, domains: { … } } })`.
|
|
392
|
+
|
|
393
|
+
## Persistent browser profiles
|
|
394
|
+
|
|
395
|
+
A fresh browser context forgets everything when it closes. Some sites judge you by what you have been: a
|
|
396
|
+
login that lasts weeks, a consent choice, a trust score built over visits, a device check passed once.
|
|
397
|
+
`session.browserProfile` runs a recipe in a **profile that persists**: a real browser user-data directory
|
|
398
|
+
(cookies, local storage, IndexedDB, cache, service workers), reopened on every run. The browser version of a
|
|
399
|
+
user who never clears their history.
|
|
400
|
+
|
|
401
|
+
```json
|
|
402
|
+
"session": {
|
|
403
|
+
"browserProfile": "shop",
|
|
404
|
+
"bootstrap": { "keep": ["cookies"], "steps": [ … the login … ] }
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
- **Web recipes** run in the profile. The bootstrap runs in the same browser as the crawl, and what both leave
|
|
409
|
+
behind stays for the next run.
|
|
410
|
+
- **Api recipes** take the profile's cookies and storage as their starting state: after the bootstrap when
|
|
411
|
+
there is one, else as the profile holds them. A login a web recipe made yesterday feeds today's api recipe.
|
|
412
|
+
- **Where:** `profilesDir` (`createCrawler({ profilesDir })`, CLI `--profiles <dir>`, `OPENCRAW_PROFILES`
|
|
413
|
+
for the CLI and the MCP server), one directory per name. Default: `.opencraw/profiles`. Profile names are
|
|
414
|
+
letters, digits, hyphens and underscores.
|
|
415
|
+
- **One browser at a time.** A second recipe of the same crawler that wants the profile waits for the first to
|
|
416
|
+
finish. Another crawler holding it (another process, or another crawler in this one) makes the recipe fail
|
|
417
|
+
with `browser profile "shop" is open in another browser`. A `.opencraw.lock` file in the profile names the
|
|
418
|
+
process that holds it; one left by a run that crashed is taken over.
|
|
419
|
+
- **The access lease still applies:** the profile's browser is launched through the recipe's proxy, and a
|
|
420
|
+
rotation relaunches it on the new one. A remote browser (`cdp`) cannot use a local profile: that combination
|
|
421
|
+
is refused.
|
|
422
|
+
|
|
423
|
+
What a profile keeps is what a real browser keeps. **Session cookies**, those without an expiry, end when the
|
|
424
|
+
browser closes, profile or not. A login that must outlast a run needs the site's "remember me", or a saved
|
|
425
|
+
`storageStatePath`.
|
|
426
|
+
|
|
427
|
+
A bootstrap that runs every time would log in again every time. To log in only when needed, check first:
|
|
428
|
+
|
|
429
|
+
```json
|
|
430
|
+
"steps": [
|
|
431
|
+
{ "type": "goto", "url": "https://shop.example/account" },
|
|
432
|
+
{ "type": "extract", "id": "me", "selector": "#logged-in", "kind": "css", "many": true },
|
|
433
|
+
{ "type": "if", "test": "{{ len(me) == 0 }}", "steps": [ … the login … ] }
|
|
434
|
+
]
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Certificates
|
|
438
|
+
|
|
439
|
+
A custom CA certificate is not a profile option. Chromium reads its own certificate store, so use
|
|
440
|
+
`ignoreHTTPSErrors`, or set `NODE_EXTRA_CA_CERTS` for the HTTP side when a provider gives you a CA.
|