@opencraw/mcp 0.1.45 → 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.
Files changed (60) hide show
  1. package/README.md +27 -1
  2. package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
  3. package/dist/diff-tool/index.d.ts +3 -0
  4. package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
  5. package/dist/docs-tool/docs-root.client.d.ts +8 -0
  6. package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
  7. package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
  8. package/dist/docs-tool/index.d.ts +3 -0
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.esm.js +430 -1
  11. package/dist/list-tool/index.d.ts +3 -0
  12. package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
  13. package/dist/main.d.ts +7 -0
  14. package/dist/probe-tool/index.d.ts +2 -0
  15. package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
  16. package/dist/recipe-source/index.d.ts +3 -0
  17. package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
  18. package/dist/run-tool/index.d.ts +3 -0
  19. package/dist/run-tool/run-tool.handler.d.ts +51 -0
  20. package/dist/server/create-server.use-case.d.ts +11 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
  23. package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
  24. package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
  25. package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
  26. package/dist/src/docs-tool/index.d.ts +3 -0
  27. package/dist/src/server/create-server.use-case.d.ts +1 -1
  28. package/dist/src/studio-tool/index.d.ts +2 -0
  29. package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
  30. package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
  31. package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
  32. package/dist/studio-tool/index.d.ts +2 -0
  33. package/dist/studio-tool/studio-command.client.d.ts +11 -0
  34. package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
  35. package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
  36. package/dist/validate-tool/index.d.ts +3 -0
  37. package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
  38. package/docs/how-it-works/01-recipes.md +698 -0
  39. package/docs/how-it-works/02-page-steps.md +672 -0
  40. package/docs/how-it-works/03-data-steps.md +813 -0
  41. package/docs/how-it-works/04-flow-steps.md +738 -0
  42. package/docs/how-it-works/05-templates-and-scope.md +428 -0
  43. package/docs/how-it-works/06-documents.md +1413 -0
  44. package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
  45. package/docs/how-it-works/08-policies.md +405 -0
  46. package/docs/how-it-works/09-running.md +804 -0
  47. package/docs/how-it-works/README.md +225 -0
  48. package/docs/recipes/README.md +22 -0
  49. package/docs/recipes/access.md +440 -0
  50. package/docs/recipes/authoring.md +1503 -0
  51. package/docs/recipes/azure-durable.md +180 -0
  52. package/docs/recipes/captcha.md +204 -0
  53. package/docs/recipes/movies-example.md +12 -0
  54. package/docs/recipes/programmatic-authoring.md +101 -0
  55. package/docs/recipes/quick-guide.md +157 -0
  56. package/docs/recipes/vehicles-example.md +9 -0
  57. package/docs/recipes/worker-mode.md +221 -0
  58. package/docs/studio/manual.md +312 -0
  59. package/docs/studio/pdf-agreements.md +150 -0
  60. 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.