@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.
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 +449 -6
  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,1503 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Authoring a recipe: the reference
6
+
7
+ A crawl is described by JSON files, never code (you may still generate that JSON from code: see [programmatic-authoring.md](./programmatic-authoring.md)). **One output recipe** declares the records you want.
8
+ **One or more input recipes** declare how to get them from a site. The engine runs the inputs one after
9
+ another and writes records that all match the output. Anything the JSON cannot express goes into a
10
+ **hook**, a named function you register in code.
11
+
12
+ The mental model: every step fills one named cell (its `id`), later steps read earlier cells by name, and the
13
+ mapping at the end says which cells become which output fields. Navigation is the order of `goto` /
14
+ `request` steps; the link between two pages is an id rendered into the next URL.
15
+
16
+ Point `$schema` at `packages/core/schemas/output-recipe.schema.json` or `input-recipe.schema.json` and the
17
+ editor validates and completes as you type. Every recipe is validated on load; unknown keys are errors, so a
18
+ typo never silently does nothing.
19
+
20
+ ## File names
21
+
22
+ Name a recipe file for its kind: `<id>.ocw-in.json` for an input recipe and `<id>.ocw-out.json` for an
23
+ output recipe (`shop-web.ocw-in.json`, `shop.ocw-out.json`). They stay plain JSON, so every editor
24
+ and tool still reads them, and the VS Code extension validates each against its schema with no `$schema`
25
+ line. The name is a hint that has a rule behind it: `kind` inside the file is what the engine goes by, and a
26
+ file named for one kind that holds the other is an error.
27
+
28
+ A folder that holds any `.ocw-in.json` or `.ocw-out.json` file is read for those, plus any other `.json` or
29
+ `.jsonl` file whose `kind` is `input` or `output`; everything else beside them (`package.json`, data) is left
30
+ alone. A folder with none is read as it always was: every `.json` and `.jsonl` file is a recipe. Existing
31
+ `*.input.json` and `*.output.json` names keep working.
32
+
33
+ This page says what each key means. [How OpenCraw works](../how-it-works/README.md) runs each of them on real
34
+ sites, APIs and documents and shows what the engine did: the scope, the mapping steps, the trace and the record.
35
+
36
+ ---
37
+
38
+ ## 1. The output recipe
39
+
40
+ ```json
41
+ {
42
+ "$schema": "../../packages/core/schemas/output-recipe.schema.json",
43
+ "kind": "output",
44
+ "id": "filmography",
45
+ "version": 1,
46
+ "description": "One film per record from the lead actor's filmography.",
47
+ "onMissing": "null",
48
+ "fields": {
49
+ "actor": { "type": "string", "required": true, "key": true },
50
+ "title": { "type": "string", "required": true, "key": true },
51
+ "year": { "type": "integer", "nullable": true, "min": 1880, "max": 2100 },
52
+ "role": { "type": "string", "nullable": true },
53
+ "filmUrl": { "type": "url", "required": true },
54
+ "source": { "type": "string", "generated": "recipeId", "key": true },
55
+ "scrapedAt": { "type": "datetime", "generated": "now" }
56
+ }
57
+ }
58
+ ```
59
+
60
+ | Key | Meaning |
61
+ |---|---|
62
+ | `id` | Lowercase letters, digits, hyphens. Input recipes name it in their `output`. |
63
+ | `version` | A positive integer you bump when the shape changes. |
64
+ | `fields` | Name → field spec. A name is letters (either case), digits, `_` and `-`, not starting with a digit. Names have no dots; nesting is expressed with `type: "object"`, whose member names follow the same rule. A dotted name fails loading with `fields["stock.count"]: a field name has no dots`. |
65
+ | `onMissing` | Recipe-wide default for a missing value: `fail`, `skip-record` or `null`. See §7. |
66
+
67
+ ### 1.1 Field spec
68
+
69
+ | Key | Meaning |
70
+ |---|---|
71
+ | `type` | `string`, `number`, `integer`, `boolean`, `date`, `datetime`, `currency`, `url`, `enum`, `array`, `object`, `json`. |
72
+ | `required` | Missing value fails the recipe (unless a policy says otherwise). Default `false`. |
73
+ | `nullable` | Allows `null` on a `required` field when the resolved policy is `null` (§7). A field that is not required is `null` when missing anyway. |
74
+ | `default` | Used when the value is missing and the resolved policy is `default` (it is, automatically, when a default exists and nothing else is said). Coerced and validated like a mapped value when the recipes load, so `"default": "0"` on a `number` field is `0`, and a default that can't be the field's type is a load error. |
75
+ | `onMissing` | Per-field policy: `fail`, `skip-record`, `null`, `default`. `default` on a field without a `default` is a load error. |
76
+ | `key` | Part of the record identity. All `key` fields together form the key; repeated keys are dropped as duplicates. |
77
+ | `generated` | `now` (ISO instant), `uuid`, `sourceUrl` (the page the record was emitted from), `recipeId`. Engine-supplied; mapping it is a binding error. |
78
+ | `format` | Input format for `date` / `datetime` (tokens `YYYY MM DD HH mm ss`); output is always ISO 8601. |
79
+ | `currency` | ISO 4217 code for `currency` fields. When set, it overrides any code or symbol in the value. |
80
+ | `values` | Allowed values for `enum`. |
81
+ | `items` | Element spec for `array`. |
82
+ | `fields` | Member specs for `object`, named like the top-level fields (no dots). A `json` field takes none. |
83
+ | `min`, `max` | Numeric bounds. |
84
+ | `minLength`, `maxLength` | String length or array length bounds. |
85
+ | `pattern` | A regular expression the string must match. |
86
+
87
+ ### 1.2 What each type accepts (coercion)
88
+
89
+ After the transform chain, the value is converted to the field's type. Conversion is forgiving where a site's
90
+ text is predictable, strict where a wrong value would poison the data.
91
+
92
+ | Type | Accepts | Produces |
93
+ |---|---|---|
94
+ | `string` | text, numbers, booleans | text. A list is an error: extract one value or `join` it. |
95
+ | `number` | text holding **one** number, with a sign, a currency symbol or code, words and thousands separators around it (`"1.299,00 €"`, `"$1,299"`, `"In stock (22 available)"`), numbers | a number. Text with two numbers or more (`"2 for 10,00"`, an id like `"a897fe39b1053632"`) is an error that names the text: pick the one you want with a `regex` transform first. Without a `number` transform's `locale` the decimal separator is guessed: of two kinds of separator, the last one (`1.299,50` → 1299.5); a single separator is a thousands separator only when exactly three digits follow it and one to three digits other than a lone `0` precede it (`1.299` → 1299, but `0.125` → 0.125 and `10,00` → 10); a repeated one groups thousands (`1,234,567`). Separators that make no number (`1.2.3`) are an error. |
96
+ | `integer` | as `number` | truncated. Errors name `integer`. |
97
+ | `boolean` | booleans, numbers, text | `true` when the whole text is `true`, `yes`, `y`, `1` or `on`, or it contains the words `in stock` or `available` (case-insensitive). So `"none"`, `"2021"` and `"unavailable"` are `false`. Negation is not read: `"not available"` is `true`. For other phrases, use the `boolean` transform with `truthy`. |
98
+ | `date` | ISO text, `Date.parse`-able text, epoch ms, or text matching `format` | `YYYY-MM-DD` (UTC). |
99
+ | `datetime` | same | ISO 8601 instant. Text that names no zone (`Z`, `+02:00`, `GMT`) is read as UTC, or in the `date` transform's `timezone`: never in the zone of the machine running the crawl. |
100
+ | `currency` | text with **one** amount and a symbol or code, a number, or `{ amount, currency }` | `{ "amount": 1299, "currency": "EUR" }`. The code comes from the field, else the transform, else an ISO 4217 code next to the amount (`USD 12.50`), else a symbol, else an ISO 4217 code anywhere in the text; three capitals that are not an ISO 4217 code (`OTR £34,000`) are ignored. No code at all is an error. |
101
+ | `url` | an absolute URL string | normalised. A relative link is an error: use the `absoluteUrl` transform first. |
102
+ | `enum` | text among `values` | the text. |
103
+ | `array` | a list, or a single value (wrapped) | a list; each item coerced and validated by `items`. The members of an object item (an `each` rule's) get the missing-value policy, `default` and validation like any field, reported at their index (`variants[1].size`); a missing scalar item is `null`. |
104
+ | `object` | an object | only the declared `fields`, each coerced. **Undeclared keys are dropped.** |
105
+ | `json` | any JSON value: an object of any shape, a list, text, a number, a boolean | the value, **verbatim**: every key kept, however nested, nothing coerced. Only what JSON cannot hold (`NaN`, `Infinity`, a `Date`, a function) is an error. |
106
+
107
+ Blank text (`""`, or only spaces) in a field of any type but `string` and `json` is no value rather than a
108
+ value that fails to convert: it follows the missing-value policy (§7), so `default` and `null` apply. In a
109
+ `string` or `json` field, `""` is missing too.
110
+
111
+ `object` is for a shape you know: it validates it and keeps nothing else. `json` is for a payload whose
112
+ shape you don't know or don't want to fix: archival, a schema that evolves, "land it now, parse it later".
113
+ It still takes `required`, `nullable`, `onMissing`, `default` and `key` like any other field, and it takes
114
+ no `fields` or `items`. Landing a whole API response as one field:
115
+
116
+ ```json
117
+ { "kind": "output", "id": "configuration-raw", "version": 1, "fields": {
118
+ "configurationId": { "type": "string", "key": true, "required": true },
119
+ "fetchedFrom": { "type": "url", "generated": "sourceUrl" },
120
+ "payload": { "type": "json", "required": true }
121
+ }}
122
+ ```
123
+
124
+ ```json
125
+ "steps": [
126
+ { "type": "request", "id": "configuration", "url": "{{start.url}}", "as": "json" },
127
+ { "type": "emit" }
128
+ ],
129
+ "mapping": {
130
+ "configurationId": { "from": "configuration.id" },
131
+ "payload": { "from": "configuration" }
132
+ }
133
+ ```
134
+
135
+ ### 1.3 Keys and duplicates
136
+
137
+ The key is the JSON of the key fields' values in declaration order. De-duplication scope is a run option:
138
+ `run` (default: across every input recipe, first record wins), `recipe`, or `off`. A record without key
139
+ fields is never de-duplicated. Duplicates are reported (`record:duplicate`) and counted, not written.
140
+
141
+ ---
142
+
143
+ ## 2. The input recipe
144
+
145
+ ```json
146
+ {
147
+ "$schema": "../../packages/core/schemas/input-recipe.schema.json",
148
+ "kind": "input",
149
+ "id": "tmdb",
150
+ "output": "filmography",
151
+ "mode": "api",
152
+ "description": "why this route works on this site",
153
+ "start": [{ "url": "https://www.themoviedb.org/movie/949-heat" }],
154
+ "vars": { "lang": "en" },
155
+ "session": { "userAgent": "...", "headers": { "accept-language": "en-US,en;q=0.9" } },
156
+ "limits": { "maxRecords": 10, "delayMs": 1000, "timeoutMs": 30000 },
157
+ "onError": { "policy": "fail" },
158
+ "steps": [ ... ],
159
+ "mapping": { ... }
160
+ }
161
+ ```
162
+
163
+ | Key | Meaning |
164
+ |---|---|
165
+ | `id` | Lowercase letters, digits, hyphens; unique in a run. Also the value of `generated: "recipeId"`. |
166
+ | `output` | The output recipe id this recipe feeds. Loading fails if it does not match. |
167
+ | `mode` | `web`: a real browser page (Playwright). `api`: HTTP requests through Playwright's request context, no browser. See §2.1. |
168
+ | `start` | One or more start points `{ url, vars? }`. Each runs the whole step list from a fresh scope with `start.url` and its `vars`. A start `url` must be absolute (`https://…`, or a `file:` URL, §3.2): there is no page before it to resolve a relative one against, so `./listino.csv` fails loading with a message saying so. |
169
+ | `vars` | Values templates read as `{{vars.name}}`. Start-point `vars` override recipe `vars`. |
170
+ | `window` | Worker mode only ([worker-mode.md](./worker-mode.md)): `{ check?, maxItems? }`. `check` is an element a reused window's page must still show before an item runs on it, else the window is replaced; after `maxItems` items the window is replaced anyway. |
171
+ | `matrix` | Runs the recipe once per set of vars, each run with its own report. An object of lists runs every combination, the first var slowest: `{ "state": ["Delhi", "Goa"], "year": [2025, 2026] }` is four runs. A list of objects runs those sets as given: `[{ "state": "Delhi" }, { "state": "Goa", "year": 2026 }]`. Each set overrides `vars`, so every name must be declared in `vars` (with the value a run without the matrix uses). The runs of one recipe go one after the other; `parallel` spreads different recipes. Reports and the trace name each run's vars (`▶ report [state=Delhi, year=2025] (web)`). |
172
+ | `session` | §2.2. |
173
+ | `limits` | `maxRecords` stops the walk after that many records, exactly, whatever runs in parallel. `delayMs` is the minimum interval between two request starts across the recipe. `timeoutMs` bounds navigations, requests and `wait` steps. `concurrency` (default `1`) is how many `forEach` iterations over a list may run at once: requests in `api` mode, tabs in `web` mode (§3.9). `retry` sends a request that fails in passing again (§7; on by default). |
174
+ | `onError` | The default policy for every step. §7. |
175
+ | `steps` | The acquisition recipe. §3. |
176
+ | `mapping` | Output field → mapping rule. §5. |
177
+ | `description` | Free text. Write down *why the route works*: which page is public, which element is the lead actor, which table is the acting one. Future you will thank you when the site changes. |
178
+
179
+ ### 2.1 Choosing the mode
180
+
181
+ Try `api` first. It is faster, needs no browser, and most sites still serve their data in the HTML
182
+ (server-rendered markup, `<script type="application/ld+json">`, inline JSON). Use `web` when the content is
183
+ built by JavaScript after load, when you must click or scroll to reveal it, or when the site challenges plain
184
+ HTTP clients (bot walls). A recipe cannot mix modes in its steps, but an `api` recipe can run **browser
185
+ steps in its bootstrap** to obtain a session (§2.2).
186
+
187
+ ### 2.2 Session
188
+
189
+ | Key | Meaning |
190
+ |---|---|
191
+ | `headers` | Extra HTTP headers, both modes. |
192
+ | `userAgent` | Sent as the user agent, both modes. Sites answer a Chrome UA more readily than a bare one. |
193
+ | `cookies` | Cookies to start with (Playwright's shape: `name`, `value`, `domain`, `path?`, ...). |
194
+ | `viewport` | `{ width, height }`, web mode. |
195
+ | `storageStatePath` | A file saved by a previous bootstrap (`saveTo`); the recipe starts from it and skips the bootstrap. |
196
+ | `bootstrap` | `{ steps, keep, saveTo? }`. Runs `steps` in a browser **before** the crawl, then captures what `keep` lists (`cookies`, `localStorage`). A `web` recipe starts its page from that state; an `api` recipe sends those cookies with every request. Bootstrap steps run in a browser, with the steps a `web` recipe allows, and never emit. |
197
+ | `access` | `{ profile?, country?, sticky? }`: what the site needs from the network. `profile` names a profile of the runner's access config (its default when omitted), `country` is a two-letter code for profiles that target by country, `sticky: false` lets the provider change IP per request. Never credentials: those live in the access config. See [access.md](./access.md). |
198
+ | `blockedWhen` | `{ status?, header?, text? }`: what counts as the site refusing the crawl, checked on every navigation and request. Default: 403, 429, or an AWS WAF challenge header. A block fails the step with the URL and reason instead of a later selector miss. |
199
+ | `onBlock` | `{ rotate?, solve?, attempts? }`. `rotate: true`: on a block, take a new access lease (a new IP), reopen the session (re-running the bootstrap) and retry the step, up to `attempts` times per run (default 2). `solve: true` (web mode, needs `captcha`): a block page showing a captcha is solved on the spot; rotation, if on, applies only when solving fails. |
200
+ | `browserProfile` | A persistent browser profile of the runner (cookies, storage, cache kept between runs): web recipes and bootstraps run in it, api recipes start from its cookies. See [access.md](access.md#persistent-browser-profiles). |
201
+ | `captcha` | `{ solver, detect?, verify?, attempts?, timeoutMs?, maxSolves? }`, web mode and bootstraps: after every navigation, click and key press, a visible challenge is solved by the named solver before the next step runs. §6.1. |
202
+
203
+ A login looks like this and works for both modes:
204
+
205
+ ```json
206
+ "session": { "bootstrap": { "keep": ["cookies"], "saveTo": "state/shop.json", "steps": [
207
+ { "type": "goto", "url": "https://shop.example/login" },
208
+ { "type": "fill", "selector": "#user", "value": "{{vars.user}}" },
209
+ { "type": "fill", "selector": "#pass", "value": "{{vars.pass}}" },
210
+ { "type": "click", "selector": "button[type=submit]" },
211
+ { "type": "wait", "selector": "#logged-in" }
212
+ ]}}
213
+ ```
214
+
215
+ ---
216
+
217
+ ## 3. Steps
218
+
219
+ Every step has `type`, and may have:
220
+
221
+ | Key | Meaning |
222
+ |---|---|
223
+ | `id` | The name of the value the step produces. A word: letters (either case), digits and underscores, not starting with a digit. Unique along any path, together with the `as` of the enclosing `forEach` loops; never `page`, `start` or `vars`. |
224
+ | `onError` | `{ "policy": "fail" }`, `{ "policy": "skip" }` or `{ "policy": "retry", "attempts": 3, "backoffMs": 500 }`. §7. |
225
+ | `when` | A template; the step runs only when it renders truthy (§3.4). |
226
+ | `keep` | Worker mode only ([worker-mode.md](./worker-mode.md)): the window remembers the step as it last ran it and skips it on the next item while it would do the same. A kept step that runs again makes the window forget the kept steps after it. Top-level page actions (`goto`, `click`, `fill`, `press`, `select`, `scroll`, `wait`, `evaluate`) without an `id`. |
227
+
228
+ ### 3.1 Web steps (browser page)
229
+
230
+ | Step | Fields | Notes |
231
+ |---|---|---|
232
+ | `goto` | `url` (template), `waitUntil?` (`load`, `domcontentloaded`, `networkidle`, `commit`), `ready?` | Relative URLs resolve against the current page. Records `page.url`. `ready: { selector, timeoutMs?, reloads? }` is an element the page must show: a site that sometimes serves its shell without the content is loaded again, up to `reloads` times (default 2), each reload reported as `request:retry`. |
233
+ | `click` | `selector` or `target`, `optional?`, `download?` | First match. With `optional: true` a missing element is skipped after a 2 s wait. With `download: { as?, saveTo?, encoding?, delimiter?, timeoutMs? }` the click downloads a file (an "Export to Excel" button): it is read like a fetched document (CSV, spreadsheet, PDF, Word, JSON…, by `as`, else the file's name, else its bytes), becomes the current document, and the step `id` holds it; `saveTo` (a template) keeps a copy. Read it with `from: <id>` (a `table` extract without `from` reads the page's HTML). |
234
+ | `fill` | `selector` or `target`, `value` (template) | |
235
+ | `press` | `key`, `selector?` or `target?` | A key on an element, or on the page. |
236
+ | `select` | `selector` or `target`, one of `value`, `label`, `index`, `values`; `multiple?`, `force?`, `ignoreCase?`, `timeoutMs?` | Picks an option of a `<select>`; `value` and `label` are templates. Fires the page's `change` handlers. `values` picks several, each matched by value or label (an item rendering a list, `{{ split(vars.states) }}`, adds each); `multiple` adds to what is chosen. `force` sets the options on the element itself, visible or not, and fires `input` and `change`: a hidden `<select multiple>` behind a script-built widget, when the page listens to the select (it usually does: that is how the widget's choice reaches the form). The step waits up to `timeoutMs` (`limits.timeoutMs`, else 30 s) for options the page loads after another pick, then names what matched nothing. `values` that render to nothing (a blank filter var) leave the control alone. `clear: true` makes `values` that render to nothing clear the control instead (a worker window reused for the next item must not keep the last item's filter). `search: { input, open?, close? }` is for a widget that loads its options as you type (a maker list of thousands): each value missing from the options is typed into `input` key by key (after clicking `open` when the box is hidden), picked once the page lists it, and the picks add up; `close` is clicked at the end. A search usually replaces the widget's list, so check that earlier picks survive a later search on the site at hand. |
237
+ | `scroll` | `to` (`bottom` or a selector), `times?`, `untilStable?`, `maxScrolls?`, `settleMs?` | Scrolls `times` times (default 1), waiting `settleMs` (default 300) after each for the page to load more. `untilStable` keeps scrolling until the page stops growing: infinite lists. It stops at `maxScrolls` scrolls (default 50) on a feed that never ends, without failing: a `warning` event (`!` in the trace) says so. Raise `settleMs` for a site whose next batch takes longer to arrive: `untilStable` measures the page, and a slow API makes it stop early. |
238
+ | `wait` | one of `selector`, `ms`, `state: "networkidle"`; `timeoutMs?` | `selector` waits for visibility. Put a `wait` after `goto` on script-heavy pages before extracting. `timeoutMs` bounds the `selector` and `state` forms; default `limits.timeoutMs`, else 30 s. Give a long one to wait for a slow report, or for a person in a headed run. |
239
+ | `evaluate` | `script`, `args?` | JavaScript evaluated in the page; the result is bound under `id`. `script` is a template. With `args`, `script` is a function expression called with them, each string rendered (a lone placeholder keeps its type): `{ "script": "(a) => a.states.length", "args": { "states": "{{ split(vars.state) }}" } }`. **Trusted recipes only.** |
240
+ | `screenshot` | `path` (template) | Full page. A debugging aid. |
241
+ | `captcha` | `solver?`, `selector?`, `verify?`, `attempts?`, `timeoutMs?`; a form captcha: `image`, `refresh?`, `field?`, `submit?` | Solves the challenge on the page, if there is one (none is fine), reCAPTCHA v3 included. Missing fields come from `session.captcha`. With `image`, a captcha checked when its form is posted: the solver fills `field`, `submit` posts the form, `verify.selector` / `verify.failure` say yes or no. §6.1. |
242
+
243
+ Clicks and key presses can navigate; the engine re-reads the page URL after every web step. A `click`,
244
+ `press` or `select` that navigates the page is checked like a `goto`: the engine waits for the new page to
245
+ load, retries a load that fails in passing (`limits.retry`: it loads the URL the step navigated to again; a
246
+ form the step posted is never sent twice), checks the answer against `session.blockedWhen`, and reports the
247
+ page as `page:visit` with its status, so it counts in `pages`. A navigation that fails, or still answers a
248
+ retried status (a 503) once the tries are spent, fails the step with its cause (`net::ERR_… at <url>`,
249
+ `HTTP 503 at <url>`): the browser's error page is never taken for the page. A click that only changes the
250
+ page in place counts nothing; one whose script changes the URL (`history.pushState`) counts a page without
251
+ a status. A key press on the page itself and a `select` do not wait for a navigation their handlers start;
252
+ they give it 150 ms to begin.
253
+
254
+ `target` is a template instead of a selector: it renders either to a **live element** (the variable of a
255
+ `forEach` over `selector`, §3.7) or to a selector string. Give one of `selector` and `target`, never both.
256
+ A plain `selector` is never rendered, so a recipe with `{{` in one is refused at load time: put that selector
257
+ in `target`.
258
+
259
+ ### 3.2 Requests (HTTP)
260
+
261
+ `request` runs in both modes. In `api` mode it goes through the recipe's request context. In `web` mode it goes
262
+ through **the page's own session** (its cookies: a login, a consent, a solved captcha), the way a page's scripts
263
+ call their JSON endpoints: a report whose table the page reads from an API is read the same way, with no
264
+ `evaluate` and `fetch` code.
265
+
266
+ | Step | Fields | Notes |
267
+ |---|---|---|
268
+ | `request` | `url` (template), `method?`, `query?`, `headers?`, `body?` or `form?`, `as?` (`json`, `jsonl`, `html`, `text`, `pdf`, `csv`, `xlsx`, `pptx`, `yaml`, `markdown`, `xml`, `docx`), `encoding?`, `delimiter?`, `scalars?` | The response becomes the current document and, if `id` is set, the id holds the parsed JSON, the read PDF, workbook or deck, the markup or the text. Relative URLs resolve against the current page; one with no page to resolve against fails with a message saying so. Without `as`, the content type decides (`application/pdf` is a PDF, `text/csv` a CSV, a spreadsheet type a workbook, a presentation type a deck, `application/yaml` YAML, `application/x-ndjson` JSON Lines, `text/markdown` Markdown, an XML type XML: §4.12, a Word type a document read as HTML: §4.13); a type that says nothing (below) is sniffed. A `file:` URL reads a local file, its kind from `as`, else the extension (`.pdf`, `.csv`, `.tsv`, `.xlsx`, `.xlsm`, `.pptx`, `.yaml`, `.yml`, `.json`, `.jsonl`, `.ndjson`, `.md`, `.html`, `.xml`, `.rss`, `.atom`, `.xml.gz`, `.docx`), else its bytes; a relative one (`file:data/listino.csv`) is read from the recipe file's folder (below). 4xx/5xx fail the step. |
269
+
270
+ **A body served as anything** (`application/octet-stream`, `binary/octet-stream`, `text/plain`, `application/zip`
271
+ and the like, or no content type: a file behind a bucket or a CDN, a download link, a raw file) is read by what it
272
+ is. Its first bytes decide: `%PDF-` is a PDF; a zip whose parts are under `xl/`, `ppt/` or `word/` is a workbook,
273
+ a deck or a Word document; gzip is a sitemap when the URL ends `.xml.gz`. Otherwise the URL path's extension
274
+ decides, as for a `file:` URL, and only then is it text. So a GitHub raw `.csv` or `.json` (served as
275
+ `text/plain`) reads as a CSV or JSON. An error page (4xx/5xx) is not sniffed by its URL's extension. `as` wins
276
+ over all of it; a specific content type wins over the bytes. A local file or a download with no known extension is
277
+ sniffed the same way.
278
+
279
+ **Local files.** `file:///srv/listini/listino.csv` reads that file. A relative `file:` URL
280
+ (`file:data/listino.csv`, `file:./x.pdf`, `file:../shared/x.xlsx`) is read from **the folder of the recipe file**
281
+ that names it, when the recipe was loaded from a file (`loadRecipeSet`, `loadRecipes`, the cli), so a recipe and
282
+ its documents move together. That holds for start URLs, start and recipe `vars`, `matrix` values, and every step's
283
+ `url` (a template keeps its placeholders: `file:data/{{vars.month}}.csv`). A recipe given as an object or text,
284
+ and a URL that only a template makes relative, read from the process working directory. `allowedHosts` refuses
285
+ every `file:` URL, relative or not (§8).
286
+
287
+ Text bodies are decoded from, in order: a byte-order mark, `encoding` (any WHATWG label: `windows-1252`,
288
+ `iso-8859-15`, `shift_jis`), the charset the server declares, UTF-8, and Windows-1252 for text that is not
289
+ UTF-8 (the usual European export). `delimiter` (one character) overrides a CSV's detected delimiter (§4.7).
290
+
291
+ **A body from a form** (web mode): `form: { selector, omit?, set? }` posts the page's form as the browser would
292
+ (its `FormData`: every chosen option of a multi-select, no disabled field, no unticked box), url-encoded, without
293
+ the `omit` fields, with each `set` field (a template) replacing or adding one. The form's values are sent as
294
+ read, never rendered. A report endpoint paged with a cursor:
295
+
296
+ ```json
297
+ { "type": "paginate", "next": { "jsonpath": "$.next", "as": "cursor" }, "until": "{{ !page_rows.hasMore }}", "steps": [
298
+ { "type": "request", "id": "page_rows", "url": "/report/rows", "method": "POST",
299
+ "form": { "selector": "#reportForm", "omit": ["captcha"], "set": { "pageSize": "25", "after": "{{ default(cursor, '') }}" } } },
300
+ { "type": "extract", "id": "rows", "selector": "$.rows[*]", "kind": "jsonpath", "take": "json", "many": true },
301
+ { "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [] }
302
+ ] }
303
+ ```
304
+
305
+ `until` stops on the endpoint's own "no more" flag: some keep returning a cursor on the last page.
306
+
307
+ In web mode, a `jsonpath` extract reads the JSON the last `request` fetched (the live page is read by `css` and
308
+ `xpath`), and `next.jsonpath` pages on it.
309
+
310
+ `body` is templated **all the way down**: a string body is one template, and in an object or list body
311
+ every string inside it is one, at any depth. A string that is exactly one placeholder keeps the value's type
312
+ (`"limit": "{{vars.limit}}"` sends a number). Numbers, booleans and `null` go as they are. An object or list
313
+ body is sent as JSON:
314
+
315
+ ```json
316
+ { "type": "request", "id": "token", "url": "https://auth.example/oauth2/token", "method": "POST", "as": "json",
317
+ "body": { "grant_type": "client_credentials", "client_id": "{{vars.clientId}}", "client_secret": "{{vars.clientSecret}}" } }
318
+ ```
319
+
320
+ ### 3.3 Steps of both modes
321
+
322
+ | Step | Fields | Notes |
323
+ |---|---|---|
324
+ | `extract` | `selector`, `kind` (`css`, `xpath`, `jsonpath`, `regex`, `table`, `region`), `take?`, `many?`, `from?`; `namespaces?`, `ignoreNamespaces?` (`xpath` on XML) | §4. |
325
+ | `set` | `value` | A literal, or a template when it is a string. In an object or list every string, at any depth, is a template (a lone placeholder keeps its type), as in a request `body`. |
326
+ | `collect` | `into` (an id), `value` | Appends `value` (a literal, or a template when it is a string, rendered all the way down like `set`) to the list `into` holds, in whichever enclosing scope binds it; a list value is appended item by item, a missing one adds nothing. `into` must be bound first, usually `{ "type": "set", "id": "all", "value": [] }` before the loop: the binding validator checks. The way to carry values out of `forEach` iterations or `paginate` pages (§3.6). |
327
+ | `forEach` | `over` (a list id) **or** `selector` (web), `as` (variable), `steps`, `emit?` (`true`, or `{ output }` naming this recipe's output) | Runs `steps` once per item in a fresh child scope with the item bound as `as`. `as` follows the rules of a step `id`: it may not name `page`, `start`, `vars` or an id already bound on the path (§3.5). `emit: true` produces one record per iteration. `over` may name a single value; it is treated as a one-item list. `selector` iterates the live elements it matches (§3.7). |
328
+ | `if` | `test` (template), `steps`, `else?` | Runs `steps` when `test` renders truthy, otherwise `else`, **in the current scope**: ids bound in a branch are visible after it. §3.8. |
329
+ | `paginate` | `next`, `until?` (template), `maxPages?`, `steps` | Runs `steps` per page in a fresh child scope, then follows `next`. §3.6. |
330
+ | `emit` | `output?` (must be this recipe's output id) | Produces a record from everything in scope. |
331
+ | `hook` | `name`, `args?` | Calls the registered hook; `args` strings are templates. The result is bound under `id`. |
332
+
333
+ ### 3.4 Templates
334
+
335
+ `{{path}}` reads: any id in scope, the current `forEach` variable and paths into it (`{{item.url}}`),
336
+ `{{vars.name}}`, `{{start.url}}`, `{{page.url}}`, `{{page.number}}`. Dotted paths walk into objects and
337
+ lists (`{{item.images[0]}}`). A template that is **exactly one placeholder** yields the value with its type
338
+ (a list stays a list); inside longer text values are stringified, missing ones as empty text.
339
+
340
+ A placeholder may also be an **expression** over such paths:
341
+
342
+ | Kind | Syntax |
343
+ |---|---|
344
+ | literals | `12`, `1.5`, `'text'`, `"text"`, `true`, `false`, `null` |
345
+ | arithmetic | `+ - * / %`, parentheses. `+` adds two numbers and concatenates anything else (`'p-' + id`). Division by zero and arithmetic on non-numbers give a missing value. Numeric text counts as a number (`qty * price` with `qty` = `"3"`). |
346
+ | comparison | `== != < <= > >=`. `==` is loose (`3 == '3'`, `null == missing`); ordering is numeric when both sides are numbers, else textual. |
347
+ | logic | `&& || !` with recipe truthiness (below); `a ?? b` gives `b` only when `a` is missing (`null` / undefined) |
348
+ | choice | `test ? a : b` |
349
+ | functions | `upper(s)`, `lower(s)`, `trim(s)`, `len(list or text)`, `default(v, fallback)` (missing or `''`), `round(n, digits?)`, `number(text)`, `join(list, sep?)`, `first(list)`, `last(list)`, `replace(s, pattern, replacement)` (a regular expression, global), `contains(list or text, needle)`, `split(text, sep?)` (pieces trimmed, blank ones dropped: a blank text gives `[]`, so a `forEach` over it runs no iteration), `urlEncode(s)` (percent-encodes one URL component: `{{start.url}}/config/{{urlEncode(id)}}` turns `#` into `%23`, a space into `%20`), `entries(object)` (`[{ key, value }]` in key order: rows keyed by name become a list `forEach` walks; a list gives its indexes), `keys(object)`, `values(object)` |
350
+
351
+ ```json
352
+ { "type": "set", "id": "next_url", "value": "{{ start.url }}?page={{ page.number + 1 }}" },
353
+ { "type": "set", "id": "label", "value": "{{ stock > 0 ? 'in stock' : 'sold out' }}" },
354
+ { "type": "if", "test": "{{ len(links) > 0 && !wall }}", "steps": [] }
355
+ ```
356
+
357
+ Rules that keep this safe and predictable:
358
+
359
+ - A placeholder made only of path characters (`item.display-name`, `ld.@type`, `price-1`) is a **path**,
360
+ never an expression: write `{{ price - 1 }}` with spaces for subtraction.
361
+ - Expressions never run code. There is no member call, no `new`, no assignment; a value that is a function
362
+ (a hook could bind one) reads as missing, and paths read own data only, so `constructor`, `__proto__`,
363
+ `toString` and the like are missing too. Unknown functions and stray characters fail at parse time with
364
+ the position.
365
+ - Deep nesting and very long expressions are refused (64 levels, 512 tokens).
366
+
367
+ Truthiness for `when`, `until`, `test` and the logical operators: `false`, `0`, `null`, `undefined`, an empty list,
368
+ and text that is, trimmed and in any case, `""`, `"false"`, `"0"`, `"null"` or `"undefined"` are false; everything else
369
+ is true.
370
+
371
+ ### 3.5 Scope: what a step can see
372
+
373
+ - Ids are bound in the scope where the step ran. Reads fall through to parent scopes.
374
+ - `forEach` opens a **fresh child scope per iteration**; `paginate` opens one **per page**. A child scope is
375
+ dropped when its iteration or page ends, so nothing from page 1 is visible on page 2 and a row without a
376
+ value cannot inherit the previous row's.
377
+ - `emit` snapshots the whole chain, child values shadowing parents. That is why a record produced inside a
378
+ `forEach` sees its own row's values *and* the actor name extracted outside the loop.
379
+ - A name is bound once along a path: a step `id` or a `forEach`'s `as` that repeats `page`, `start`, `vars` or
380
+ an id bound earlier on the path (an outer step, an enclosing loop's `as`) fails binding with
381
+ `id "item" is already bound on this path`. So a loop over tags inside a loop over products needs its own
382
+ name (`as: "tag"`, not a second `as: "item"`). Sibling loops, and the two branches of an `if`, are separate
383
+ paths and may reuse a name.
384
+ - `page.url` / `page.number` and the current document are scope state, bound in the innermost scope that
385
+ navigated. In `web` mode `page.url` is the real page URL; in `api` mode it is the final URL of the nearest
386
+ `request`, and `start.url` before any request.
387
+ - A value found inside a child scope dies with it, unless a `collect` step appends it to a list bound in an
388
+ enclosing scope (§3.6).
389
+ - Exactly **one emitting construct per path**: an emitting `forEach` may not contain an `emit` or another
390
+ emitting `forEach`. The binding validator rejects it.
391
+ - `limits.maxRecords` stops the walk cleanly, wherever it is in the tree.
392
+
393
+ ### 3.6 Pagination
394
+
395
+ `next` is evaluated **after** the page body ran:
396
+
397
+ | `next` | Mode | Behaviour |
398
+ |---|---|---|
399
+ | `{ "selector": "a.next" }` | web | Clicks it. If the body navigated away (a `forEach` visiting every item), the engine returns to the listing page first. No visible element within 2 s means no next page. The navigation the click causes is checked like a `goto`'s: a load that fails in passing is retried (`limits.retry`) by loading the URL the click led to again, not by clicking again; the answer is checked against the block rule; a failure fails the `paginate` step with its cause (`HTTP 503 at …`, `net::ERR_… at …`), never running the body on the browser's error page. A next that swaps the content in place (no navigation) still works: the click gets 500 ms to start one, and the page counts either way. |
400
+ | `{ "url": "{{start.url}}?page={{ page.number + 1 }}" }` | both | The rendered value is the next `page.url` (web mode navigates to it). Empty means no next page. |
401
+ | `{ "jsonpath": "$.nextPage" }` | api, web (on a `request`'s JSON) | Evaluated on the current document; the value is the next URL, relative allowed. `null`, `false` or empty means no next page. |
402
+ | `{ "jsonpath": "$.cursor", "as": "cursor" }` | api, web (on a `request`'s JSON) | The value is bound under `cursor` in the next page's scope and the body builds the URL itself (`?cursor={{cursor}}`); on page 1 it is unset. |
403
+
404
+ Pagination also stops when `until` renders truthy or at `maxPages`. `page.number` increments per page.
405
+
406
+ Two shapes cover most sites:
407
+
408
+ ```json
409
+ { "type": "paginate", "next": { "jsonpath": "$.nextPage" }, "steps": [
410
+ { "type": "request", "id": "list", "url": "{{page.url}}", "as": "json" },
411
+ { "type": "extract", "id": "items", "from": "list", "selector": "$.items[*]", "kind": "jsonpath", "take": "json", "many": true },
412
+ { "type": "forEach", "over": "items", "as": "item", "emit": true, "steps": [] }
413
+ ]}
414
+ ```
415
+
416
+ ```json
417
+ { "type": "goto", "url": "{{start.url}}" },
418
+ { "type": "paginate", "next": { "selector": "a.next" }, "maxPages": 3, "steps": [
419
+ { "type": "extract", "id": "links", "selector": "a.product", "kind": "css", "take": "attr:href", "many": true },
420
+ { "type": "forEach", "over": "links", "as": "link", "emit": true, "steps": [
421
+ { "type": "goto", "url": "{{link}}" },
422
+ { "type": "extract", "id": "raw_title", "selector": "h1", "kind": "css" }
423
+ ]}
424
+ ]}
425
+ ```
426
+
427
+ **Collect first, then fan out.** Some sources enumerate ids page by page and only then fetch one detail
428
+ per id. Page scopes are dropped, so the ids have to be carried out: bind an empty list before the
429
+ `paginate`, `collect` each page's ids into it, and loop over it once pagination has finished:
430
+
431
+ ```json
432
+ { "type": "set", "id": "allIds", "value": [] },
433
+ { "type": "paginate", "next": { "jsonpath": "$.responseDetails.next", "as": "cursor" }, "until": "{{ !list.responseDetails.existsMore }}", "steps": [
434
+ { "type": "request", "id": "list", "url": "{{start.url}}?cursor={{cursor}}", "as": "json" },
435
+ { "type": "extract", "id": "ids", "from": "list", "selector": "$.items[*].id", "kind": "jsonpath", "take": "json", "many": true },
436
+ { "type": "collect", "into": "allIds", "value": "{{ids}}" }
437
+ ]},
438
+ { "type": "forEach", "over": "allIds", "as": "id", "emit": true, "steps": [
439
+ { "type": "request", "id": "detail", "url": "{{start.url}}/{{urlEncode(id)}}", "as": "json" }
440
+ ]}
441
+ ```
442
+
443
+ The list is held in memory until the loop ends: fine for thousands of ids. For millions, or when records
444
+ should start flowing before the enumeration ends, **fan out per page** instead: put the `forEach` inside
445
+ the `paginate` body, as in the first shape above, so each page's details are fetched and emitted before the
446
+ next page is read.
447
+
448
+ ### 3.7 Live elements: driving a configurator
449
+
450
+ `forEach` with `selector` instead of `over` runs its body once per element the selector matches **on the
451
+ current page**, in web mode only. Each iteration binds a snapshot of the element under `as`:
452
+
453
+ | Path | Holds |
454
+ |---|---|
455
+ | `{{option.text}}` | text content, whitespace collapsed |
456
+ | `{{option.html}}` | inner HTML |
457
+ | `{{option.attrs.value}}`, `{{option.attrs.data-id}}` | any attribute |
458
+ | `{{option.value}}` | the `value` of an input, option or select |
459
+ | `{{option.selector}}`, `{{option.index}}` | where to find it again |
460
+
461
+ The snapshot is taken once, when the loop starts, and the engine never holds on to the element itself: a
462
+ `target: "{{option}}"` on `click`, `fill`, `press` or `select` re-resolves the element by selector and
463
+ index at that moment. That is what makes the loop survive a page that re-renders after every interaction,
464
+ which is what a car configurator, a size picker or a tabbed spec sheet does.
465
+
466
+ The shape for "one record per option of a `<select>`":
467
+
468
+ ```json
469
+ { "type": "goto", "url": "{{start.url}}" },
470
+ { "type": "extract", "id": "model", "selector": "h1", "kind": "css" },
471
+ { "type": "forEach", "selector": "select#trim option", "as": "option", "emit": true, "steps": [
472
+ { "type": "select", "selector": "select#trim", "value": "{{option.attrs.value}}" },
473
+ { "type": "extract", "id": "trim", "selector": ".trim", "kind": "css" },
474
+ { "type": "extract", "id": "price", "selector": ".price", "kind": "css" }
475
+ ]}
476
+ ```
477
+
478
+ For a row of buttons or tabs use `{ "type": "click", "target": "{{option}}" }` instead of `select`. Put a
479
+ `wait` after the interaction when the page fetches the new state instead of rewriting it in place.
480
+
481
+ Limits: the loop iterates the matches present when it started; elements a later interaction adds are not
482
+ visited (nest a second `forEach` for that). Navigating away inside the body and relying on `target` on the
483
+ next iteration fails, because the selector no longer matches on the new page.
484
+
485
+ ### 3.8 Decisions
486
+
487
+ `when` skips one step. `if` chooses between two step lists and is the tool for "do this only on the
488
+ first page", "the layout differs for sold-out items", "dismiss the wall if it is up":
489
+
490
+ ```json
491
+ { "type": "extract", "id": "wall", "selector": "#consent", "kind": "css", "take": "html", "many": true },
492
+ { "type": "if", "test": "{{wall}}", "steps": [{ "type": "click", "selector": "#accept" }] }
493
+ ```
494
+
495
+ `test` uses the same truthiness as `when` (§3.4); an extract with `many: true` never fails, so its empty
496
+ list is the clean way to test for presence. Both branches run in the scope of the `if`, not a child: a
497
+ value set in `steps` or `else` is visible to the steps after it, and each branch may bind the same id (they
498
+ never both run). A branch may `emit`; the one-emit-per-path rule treats the branches as separate paths.
499
+ The trace shows the branch taken as `⑂ steps.N then`.
500
+
501
+ ### 3.9 Concurrency
502
+
503
+ Two dials, one inside a recipe and one across recipes.
504
+
505
+ **Inside a recipe**, `limits.concurrency: 3` lets a `forEach` over a list run three iterations at once: a
506
+ listing whose body fetches every item's page fans out, and records come out in completion order rather than
507
+ list order.
508
+
509
+ - **api mode:** three requests in flight on one HTTP session.
510
+ - **web mode:** three **tabs** of the recipe's browser context, one per iteration: they share cookies (the
511
+ login), each has its own page, and each closes when its iteration ends. A tab opens blank, so the body must
512
+ start with a `goto` (`{{item.url}}`). A `forEach` over `selector` stays sequential: its live elements
513
+ belong to one page.
514
+ - The limit is **per recipe run**, not per loop: nested loops share it. The outermost concurrent loop takes
515
+ the permits; a loop inside one of its iterations runs its body sequentially, so nesting never multiplies
516
+ the number of requests in flight.
517
+ - `delayMs` is a **rate**, not a per-request pause: the minimum time between two request starts across
518
+ every iteration. `concurrency: 4, delayMs: 250` means at most four requests per second, in flight or not.
519
+ - `maxRecords` is exact: once reached, iterations still in flight finish but emit nothing more.
520
+ - Politeness towards a **site** across recipes is the runner's `throttle` ([access.md](access.md#throttling-per-site)),
521
+ on top of these per-recipe limits.
522
+ - A failing step under the `fail` policy stops new iterations; the ones in flight settle, then the recipe
523
+ fails as usual.
524
+ - A block under `onBlock.rotate` rotates once for the whole recipe; every tab reopens on the new lease.
525
+
526
+ **Across recipes**, `createCrawler({ parallel: 3 })` (CLI `--parallel 3`, MCP `run` with `parallel: 3`) runs
527
+ three input recipes of a set at once, each in its own browser context or HTTP session. They share the browser,
528
+ the sink and the per-site throttle. Reports keep the set's order. Under `onRecipeError: 'stop'`, a failure stops
529
+ the recipes not started yet. De-duplication under `dedupe: 'recipe'` stays per recipe; under `run`, the recipe
530
+ that emits a key first keeps it.
531
+
532
+ Parallel is only faster if the site lets it be. Pair it with the per-site `throttle`
533
+ ([access.md](access.md#throttling-per-site)), which bounds what all of this adds up to on one site.
534
+
535
+ ---
536
+
537
+ ## 4. `extract` in depth
538
+
539
+ `extract` reads **a document** with **a selector** and **takes** something from each match.
540
+
541
+ ### 4.1 Which document
542
+
543
+ | `from` | Web mode | Api mode |
544
+ |---|---|---|
545
+ | absent | the live page | the last `request`'s response (nearest scope that has one) |
546
+ | an id holding **text** | with `css`: the text as HTML (a fragment such as a `<tr>` is parsed as a fragment, so cells survive); with `table`: the HTML's `<table>`s (a Word or Markdown document, a page a `request` fetched: §4.11); with `jsonpath`: the text parsed as JSON | same |
547
+ | an id holding a **list of texts** | with `jsonpath`: every entry that parses as JSON becomes one element of an array and the path runs over the array; with `table`: the tables of every fragment (`take: "html"`, `many: true`) | same |
548
+ | an id holding **data** (an object, a list of objects) | with `jsonpath` | same |
549
+ | an id holding a **read PDF** (a `request` with `as: "pdf"`) | with `table`, `region`, `regex` or `jsonpath` (§4.6) | same |
550
+ | an id holding a **read workbook** (a spreadsheet or a CSV) | with `table`, `regex` or `jsonpath` (§4.7) | same |
551
+ | an id holding a **read deck** (a presentation) | with `table`, `regex` or `jsonpath` (§4.8) | same |
552
+
553
+ **JSON Lines** (NDJSON: one JSON value per line, as bulk exports and Shopify bulk operations give) read with
554
+ `as: "jsonl"`, or by the `application/x-ndjson` / `application/jsonl` content types or the `.jsonl` / `.ndjson`
555
+ extensions, into an **array** of the lines' values: `$[*]` walks them. A line that does not parse fails the step
556
+ with its number. JSON Lines served as `application/json` fail with a hint to use `as: "jsonl"`.
557
+
558
+ **Wrapped JSON** is unwrapped, both in a response read as JSON and in text a `jsonpath` extract parses (`from`):
559
+
560
+ - comment guards around JSON-LD: `/* <![CDATA[ */ … /* ]]> */`, `<!-- … -->`;
561
+ - anti-hijacking prefixes: `)]}'` (with or without a comma), `while(1);`, `for(;;);`;
562
+ - JSONP: `callback({…});`, so an endpoint served as `text/javascript` reads with `as: "json"`;
563
+ - an assignment in an inline script: `window.__INITIAL_STATE__ = {…};` (with `var`, `let` or `const`, too).
564
+
565
+ Valid JSON is always read as it is: a wrapper is only removed when the text does not parse, and what is inside
566
+ must be strict JSON. Nothing is evaluated, so a JavaScript literal (`{ a: undefined }`, unquoted keys) still
567
+ fails: read it with `regex`, or a hook. The common JSON-LD pattern, which both example sites use:
568
+
569
+ ```json
570
+ { "type": "extract", "id": "ld", "selector": "script[type=\"application/ld+json\"]", "kind": "css", "take": "text", "many": true },
571
+ { "type": "extract", "id": "name", "from": "ld", "selector": "$[?(@['@type']=='Movie')].name", "kind": "jsonpath", "take": "json" },
572
+ { "type": "extract", "id": "actors", "from": "ld", "selector": "$[*].actors[*].name", "kind": "jsonpath", "take": "json", "many": true }
573
+ ```
574
+
575
+ ### 4.2 Which selector
576
+
577
+ | `kind` | Works on | Notes |
578
+ |---|---|---|
579
+ | `css` | live page, fetched HTML, HTML fragments | Standard CSS through Playwright (live) or cheerio (static). |
580
+ | `xpath` | live page, fetched XML, fetched HTML, XML or HTML fragments | XPath 1.0. Fetched HTML is parsed the way a browser parses it (`<tbody>` added), so a query that works in the browser works on the fetched page. On namespaced XML, see §4.12. |
581
+ | `jsonpath` | JSON data, JSON text, lists of JSON texts | jsonpath-plus syntax: `$.items[*].url`, `$[?(@.actors)]`, `$[?(@['@type']=='Movie')].name`. |
582
+ | `regex` | any document as text: markup, text, JSON re-serialised, a list of texts joined by newlines | A JavaScript regular expression (flags `gs`); group 1 is taken when the pattern has one, else the whole match. For values that live in inline scripts (`"carPath":"([^"]+)"`), attributes, or table prose (`Boot capacity</td>\\s*<td>([^<]+)`). |
583
+
584
+ **Several keys into one list.** To merge same-shaped arrays kept under different keys
585
+ (`availableExteriors`, `availableInteriors`, `availableWheels`...), name them in an **unquoted** union, or
586
+ match the key names with a filter on `@property`:
587
+
588
+ ```json
589
+ { "type": "extract", "id": "options", "from": "config", "kind": "jsonpath", "take": "json", "many": true,
590
+ "selector": "$.model[availableExteriors,availableInteriors,availableWheels,standardEquipments][*]" }
591
+ ```
592
+
593
+ `$.model[?(@property.startsWith('available'))][*]` takes every key that starts with `available`, and
594
+ `$..[?(@property==='availableExteriors' || @property==='availableWheels')][*]` finds them at any depth. The
595
+ quoted union `$.model['availableExteriors','availableInteriors'][*]` matches **nothing** in jsonpath-plus:
596
+ leave the names unquoted.
597
+
598
+ Every selector is a template: `{{ }}` placeholders render against the scope first, so one step can pick the
599
+ colour set of the current trim (`$[?(@.trimname=='{{trim}}')].colors[*].displayName`) or the row of the
600
+ current item.
601
+
602
+ ### 4.3 What to take
603
+
604
+ | `take` | From an element | From a JSON node |
605
+ |---|---|---|
606
+ | `text` (default) | text content with whitespace collapsed | scalars as text, objects as JSON text |
607
+ | `html` | inner HTML | – |
608
+ | `json` | outer HTML (the element itself, for a later `from`) | the node as data |
609
+ | `value` | an input's value | – |
610
+ | `attr:<name>` | the attribute, `undefined` when absent | – |
611
+
612
+ Take `html` (inner) when you will extract *inside* the element again; take `json` (outer) when the
613
+ information sits on the element's own attributes (`<li title="...">`).
614
+
615
+ ### 4.4 One or many
616
+
617
+ `many: true` binds a list, empty when nothing matches. Without it the **first** match is bound, and **no match
618
+ fails the step**. That is deliberate: a missing element is either a site change (let it fail) or expected
619
+ (say so with `onError: { "policy": "skip" }`, and the id stays unset for the missing-value policy).
620
+
621
+ ### 4.5 Nested markup: the shifted-field trap
622
+
623
+ A selector that matches an ancestor first returns the ancestor's whole inner HTML, which contains every
624
+ descendant. Extracting "the first title" from that wrapper gives the right title and "the first role" gives
625
+ the *next* row's role: a shifted field, no error. When a site nests tables or lists, select the innermost
626
+ repeating element (`table.credit_group tr`, not `tr`) and check the first record by hand.
627
+
628
+ ### 4.6 PDFs
629
+
630
+ `request` with `as: "pdf"` (or a response served as `application/pdf`, or a local `file:…pdf`) reads the
631
+ PDF's text layer with pdf.js into pages of **rows**: text that sits side by side becomes a cell, cells whose
632
+ vertical extents overlap become a row. A scanned page has no text layer and gives no rows; a PDF where no page
633
+ has one fails the step (no OCR). Five extract kinds read it:
634
+
635
+ | `kind` | Reads | Use for |
636
+ |---|---|---|
637
+ | `table` | tables, found by their header row | price lists, discount sheets, spec tables |
638
+ | `region` | the text inside a box on a page: `page=1 x=72..252 y=640..664` (points from the page's bottom-left corner; `page=*` for every page) | a title, a date, a total — anything that sits at the same place on every issue of a document; what the studio's PDF canvas writes when you click a line |
639
+ | `section` | the text between a heading and the next one, wherever they sit (below) | a description, a list of terms, a price block: content under a title |
640
+ | `regex` | the text: one line per row, cells separated by a tab, pages by a blank line | a value next to a label: `Valid until\t(\S+)` |
641
+ | `jsonpath` | the structure: `{ kind: "pdf", pages: [{ number, width, height, rows: [{ text, top, bottom, cells: [{ x, y, width, height, text }] }] }] }` | positions, a given page |
642
+
643
+ **Prefer structure to position.** A `region` is a place on the page: it breaks when a description is longer, a
644
+ table has one more row or a column is added. Two sheets of the same layout and different content are read with
645
+ `section` (the text between two headings) and `regex` (the value after a label), which find their text wherever it
646
+ sits. The studio writes those whenever the line you click has a heading above it or a label before it, and a
647
+ `region` only for a box you draw.
648
+
649
+ A **section** reads the text under a heading: the selector matches the heading row (case-insensitive, the cells
650
+ of the row joined by a space), the text runs to the row `until` matches or, without `until`, to the next row set
651
+ in text as large as the heading (the next heading of its level), or to the end of the document, over page breaks.
652
+ `many: true` gives one text per matching heading.
653
+
654
+ ```json
655
+ { "type": "extract", "id": "description", "kind": "section", "selector": "^Details$", "until": "^Price$" },
656
+ { "type": "extract", "id": "price", "kind": "section", "selector": "^Price$" },
657
+ { "type": "extract", "id": "terms", "kind": "section", "selector": "^Terms$", "ignoreHeadersFooters": true }
658
+ ```
659
+
660
+ The text is read the way a person reads it: the lines of a paragraph are joined with a space (a word broken at
661
+ a hyphen with none), paragraphs are separated by a newline, a numbered or bulleted item starts a paragraph, and
662
+ a page laid out in columns is read one column after the other, left to right.
663
+
664
+ A section can be narrowed to part of its text. `line` takes one printed line of it (1 is the first, -1 the last;
665
+ the lines of a wrapped paragraph count apart), and `match` keeps the part a pattern matches (its first group,
666
+ else the whole match; case-insensitive), after `line` when both are given. A section the pattern does not match
667
+ in is no match:
668
+
669
+ ```json
670
+ { "type": "extract", "id": "date", "kind": "section", "selector": "^SUBSCRIPTION SERVICES$", "until": "^COMMERCIAL TERMS$", "line": 1 },
671
+ { "type": "extract", "id": "email", "kind": "section", "selector": "^Customer Details$", "match": "([\\w.]+@[\\w.]+)" }
672
+ ```
673
+
674
+ `ignoreHeadersFooters: true` (kinds `section` and `table`) leaves out the running headers and footers: rows in
675
+ the top or bottom 15% of a page whose text, digits aside (`Page 1 of 2`), shows at the same height on more than
676
+ one page. Without it they are content like any other row, which is what a `regex` or a `region` over them wants.
677
+ A section that runs over a page break needs it, or the footer lands in the middle of the sentence.
678
+
679
+ A **region** reads every cell at least half inside its box, row by row: a row's cells joined by a space, rows by
680
+ a newline — so a box around a wrapped paragraph gives its lines. A page with nothing in the box is no match (the
681
+ step fails without `many`); with `page=*` and `many: true` you get one text per page that has something there, a
682
+ running footer for instance. The box is in PDF points, which a `jsonpath` extract of a cell's `x`/`y`/`width`/
683
+ `height` shows you — or the studio draws it for you.
684
+
685
+ A **table** extract takes the header row's pattern as its `selector`, and returns one table per match, `many`
686
+ for all of them:
687
+
688
+ ```json
689
+ { "type": "request", "id": "sheet", "url": "{{start.url}}", "as": "pdf" },
690
+ { "type": "extract", "id": "tables", "selector": "^MODELLI", "kind": "table", "many": true,
691
+ "until": "^(NOTA BENE|N\\.B\\.)",
692
+ "columns": { "model": "^MODELLI", "discount": "^Sconto", "excluded": "^Versioni", "extra": "^Azion" } },
693
+ { "type": "forEach", "over": "tables", "as": "table", "steps": [
694
+ { "type": "set", "id": "rows", "value": "{{table.rows}}" },
695
+ { "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [] }
696
+ ]}
697
+ ```
698
+
699
+ Each table is `{ page, title, header, rows }`: `title` is the first header cell (`MODELLI FIAT`, a brand), and
700
+ each row an object keyed by `columns` (output key → a pattern for that column's header cell), or by the header
701
+ texts without `columns`. A cell's lines are joined by spaces. All patterns are case-insensitive.
702
+
703
+ | Field | Meaning |
704
+ |---|---|
705
+ | `selector` | Matches a table's header row, its cells joined by spaces. |
706
+ | `until` | Matches the row that ends a table: a footnote, a "Note:" line. A table also ends at the next row the `selector` matches, and at the page's end. With a narrow `selector` (one table among several), set `until` to the start of the next one. |
707
+ | `columns` | Output key → header pattern. Unmatched columns are dropped. |
708
+ | `align` | How a row's values sit against a cell wrapped over several lines: `top`, `center`, `bottom`, or `auto` (default). |
709
+
710
+ How it reads a table, which is what makes real-world sheets work:
711
+
712
+ - **Columns come from the body.** A header is often centred over a column whose cells are left-aligned, so the
713
+ left edges of the body's cells cluster into bands, and bands map to header cells left to right. A header
714
+ spanning two columns reads both.
715
+ - **Rows are regrouped.** A long name wraps over several lines, a note runs onto a second line, a list of
716
+ versions sits above and below its row. A line with a name and a value, or with a value in the first value
717
+ column, anchors a row; the other lines join it. With `auto`, a table that centres values beside wrapped
718
+ names shares the wrapped lines evenly around each anchor; otherwise a line joins the nearest anchor, and a
719
+ line halfway between two joins the one below.
720
+
721
+ Find the selectors with `opencraw probe <url or file.pdf>`: for a PDF it lists the rows and every row that
722
+ looks like a table header, with a ready `selector`. The Stellantis example
723
+ (`examples/stellantis-it-discounts/`) reads eight monthly discount sheets this way.
724
+
725
+ `fillDown` (below, §4.7) works on PDF tables too.
726
+
727
+ ### 4.7 Spreadsheets and CSV
728
+
729
+ Both read into a **workbook**: sheets of cells that the same three extract kinds read.
730
+
731
+ **Spreadsheets.** `request` with `as: "xlsx"` (or a response served with a spreadsheet content type, or a
732
+ local `file:…xlsx` / `…xlsm`) reads every worksheet with
733
+ [`@opencraw/office-reader`](../../packages/office-reader). Numbers and booleans keep their type, so
734
+ `13955.625` is a number that no locale guess can misread. Dates become ISO text (`2026-06-01`,
735
+ `2026-06-01T09:30:00`), errors their text (`#DIV/0!`), and empty cells `''`. Formulas give their cached
736
+ value; nothing is evaluated, and display formats are not applied (a percentage stays `0.125`). Hidden sheets
737
+ and rows are read, and `table` skips them unless `includeHidden`. Merged ranges are recorded, and `table`
738
+ fills them. A legacy `.xls`, a password-protected file or an `.ods` fails the step, with what to do.
739
+
740
+ **CSV.** `request` with `as: "csv"` (or a response served as `text/csv`, `application/csv` or
741
+ `text/tab-separated-values`, or a local `file:…csv` / `…tsv`) reads the file into a **workbook**: a CSV is a
742
+ workbook with one sheet, named after the file (`…/prezzo_alle_8.csv` → `prezzo_alle_8`). Every cell stays
743
+ text; `number` with a `locale` and `date` with a `format` convert them in the mapping.
744
+
745
+ - **Encoding** as in §3.2: a Windows-1252 export with no charset reads right without `encoding`.
746
+ - **Delimiter** is detected among `,` `;` tab `|`: the one that splits the first lines into the most
747
+ consistent number of fields, so a title line above the header does not mislead it, and `;` with decimal
748
+ commas (`Panda;15.950,00`) is read as `;`. Set `delimiter` on the `request` when detection gets it wrong.
749
+ - **Quotes**: a quoted field may hold the delimiter, line breaks and `""`; a quote inside an unquoted field is
750
+ kept as is (`1.0 Hybrid "Cross"`). Rows are kept ragged, nothing is trimmed.
751
+
752
+ The same three extract kinds read it:
753
+
754
+ | `kind` | Reads | Use for |
755
+ |---|---|---|
756
+ | `table` | tables, found by their header row | the list itself, below any title lines |
757
+ | `regex` | the text: cells separated by a tab, rows by a newline, sheets by a blank line | a date in a title line: `Estrazione del (\S+)` |
758
+ | `jsonpath` | the structure: `{ kind: "workbook", sheets: [{ name, rows: [["cell", …], …] }], csv: { encoding, delimiter } }` | a file with no header row: `$.sheets[0].rows[*]`; one cell by position, a title or a date: `$.sheets[?(@.name=='Prices')].rows[0][0]` (a rectangle: `rows[1:3][0:2]`, read row by row — what OpenCraw Studio writes from a click on the grid); `merges`, `hidden` and `hiddenRows` too, for a spreadsheet |
759
+
760
+ A grid needs no geometry, so a workbook table is simpler than a PDF one: column *i* of a row belongs to header
761
+ cell *i*. The `selector` matches the header row (its non-empty cells joined by spaces, whitespace collapsed),
762
+ and each table is `{ sheet, title, header, rows }`. Before reading, every merged range's value is copied
763
+ into the cells it covers: a brand merged down its models reads on every row, and a group header merged
764
+ across its sub-columns names each of them.
765
+
766
+ ```json
767
+ { "type": "request", "url": "{{start.url}}" },
768
+ { "type": "extract", "id": "table", "selector": "^Marca Modello", "kind": "table", "until": "^Totale",
769
+ "fillDown": ["brand", "model"],
770
+ "columns": { "brand": "^Marca$", "model": "^Modello$", "version": "^Versione$", "price": "^Prezzo" } },
771
+ { "type": "set", "id": "rows", "value": "{{table.rows}}" },
772
+ { "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [] }
773
+ ```
774
+
775
+ | Field | Meaning |
776
+ |---|---|
777
+ | `selector` | Matches the header row. Title lines above it and empty rows below it are skipped. |
778
+ | `until` | Matches the row that ends a table. A table also ends at the next header and at the sheet's end. `"^$"` ends it at the first empty row: several tables on one sheet, separated by blank rows. |
779
+ | `columns` | Output key → header pattern. Without it the header texts are the keys; a column with data but no header text is keyed by its letter (`A`, `B`…), and a repeated header gets a counter (`Price 2`). |
780
+ | `fillDown` | Output keys whose empty cells take the value of the row above: exports that write a brand or a model once over its versions. |
781
+ | `headerRows` | How many rows the header spans (default 1): a column's key joins its header texts (`Total` over `August` → `Total August`). |
782
+ | `sheet` | A pattern for the names of the sheets to read (default: all). |
783
+ | `includeHidden` | Read hidden sheets and rows too. |
784
+
785
+ A spreadsheet table under a two-row merged header (the German KBA registration statistics):
786
+
787
+ ```json
788
+ { "type": "extract", "id": "table", "kind": "table", "sheet": "^FZ 10\\.1$", "selector": "^Marke Modellreihe",
789
+ "headerRows": 2, "until": "^NEUZULASSUNGEN INSGESAMT",
790
+ "columns": { "brand": "^Marke$", "model": "^Modellreihe$", "month": "^Insgesamt August", "bev": "^mit Elektroantrieb \\(BEV\\) August" } }
791
+ ```
792
+
793
+ `Insgesamt` is merged over *August 2026 · Jan.–August 2026 · Anteil in %*, so the three columns read as
794
+ `Insgesamt August 2026` and so on, and the brand merged down its models' rows fills every row.
795
+
796
+ `opencraw probe <url or file>` lists a workbook's sheets, the first rows, and every row that looks like a
797
+ header, with a ready `selector` (and a `headerRows` hint when the header has merged cells). For a CSV it
798
+ also reports the encoding and the delimiter it used.
799
+
800
+ ### 4.8 Presentations
801
+
802
+ `request` with `as: "pptx"` (or a response served with a presentation content type, or a local
803
+ `file:…pptx`) reads the deck with [`@opencraw/office-reader`](../../packages/office-reader) into slides:
804
+
805
+ ```
806
+ { kind: "deck", width, height, slides: [{ number, title, hidden,
807
+ shapes: [{ x, y, width, height, text, placeholder }], // points from the top-left corner, reading order
808
+ tables: [{ name, rows, merges }], // native tables, as sheets
809
+ charts: [{ type, title, series: [{ name, categories, values }] }],
810
+ notes }] }
811
+ ```
812
+
813
+ Charts are read from the data the chart caches, the 2016 kinds too (waterfall, treemap, sunburst, histogram, box
814
+ and whisker, funnel, region map), so the embedded workbook is never opened. A title placeholder that PowerPoint places through the slide's layout gets the layout's position, and boxes
815
+ inside a group are placed through its scaling. Slide numbers, dates and footers are left out. A legacy `.ppt`,
816
+ a password-protected file or an `.odp` fails the step, with what to do.
817
+
818
+ | `kind` | Reads | Use for |
819
+ |---|---|---|
820
+ | `table` | native tables, or with `shapes: true` text boxes laid out as a table | price and incentive tables |
821
+ | `regex` | per visible slide: its text boxes, its tables' rows (cells separated by a tab), then `Notes: …`; slides separated by a blank line | a validity date in the notes: `Notes: .*fino al (\d+ \w+)` |
822
+ | `jsonpath` | the structure above | chart data: `$.slides[?(@.title=='Vendite')].charts[*].series[*]` |
823
+ | `region` | the text boxes at least half inside a box on one slide (`slide=3 x=60..900 y=30..90`, points from the top-left corner) or on every visible slide (`slide=*`), in reading order, one line per box | a source line under a chart, a title in a fixed place on every slide |
824
+
825
+ A `region` on a deck is the PDF one (§4.6) with `slide=` instead of `page=` and y counted down from the top,
826
+ as the deck's shapes are: `slide=*` reads the same box off every visible slide, one value per slide that has
827
+ something in it. OpenCraw Studio writes the selector from a click on the slide.
828
+
829
+ A **native table** reads like a spreadsheet table (§4.7). Merged cells are filled, `headerRows` joins a header
830
+ spread over several rows, and `slide` (a pattern on slide titles) picks the slides:
831
+
832
+ ```json
833
+ { "type": "extract", "id": "table", "kind": "table", "slide": "^Incentivi", "selector": "^Modello Prezzo", "headerRows": 2,
834
+ "columns": { "model": "^Modello$", "list": "^Prezzo Listino$", "discount": "^Sconto$" } }
835
+ ```
836
+
837
+ **Text boxes laid out as a table** read with `shapes: true`, through the PDF table reader (§4.6): each box is a
838
+ cell, boxes whose heights overlap form a row, and columns come from where the body's boxes start (`align`
839
+ applies). A very tall box can pull two rows together.
840
+
841
+ ```json
842
+ { "type": "extract", "id": "table", "kind": "table", "shapes": true, "slide": "^Griglia", "selector": "^Modello Prezzo",
843
+ "columns": { "model": "^Modello", "price": "^Prezzo" } }
844
+ ```
845
+
846
+ Each table is `{ slide, slideTitle, title, header, rows }`, and hidden slides are skipped unless
847
+ `includeHidden`. `opencraw probe <url or file.pptx>` lists the slides, each native table's header with a ready
848
+ `selector`, the charts' series, and the slides whose short text boxes look like a table.
849
+
850
+ ### 4.9 YAML
851
+
852
+ `request` with `as: "yaml"` (or a response served as `application/yaml`, `text/yaml` or their `x-` forms, or a
853
+ local `file:…yaml` / `…yml`) parses the YAML into **JSON data**: `jsonpath`, `forEach` and `json` fields work
854
+ exactly as on a JSON response. Several documents (`---`) become an array of them.
855
+
856
+ - **YAML 1.2, always.** Under YAML 1.1, `country: NO` is `false`, `y` is `true` and `0123` is octal `83`. The
857
+ parser is pinned to 1.2 even when the file declares `%YAML 1.1`, so `NO` stays `"NO"`.
858
+ - **`scalars: "text"`** on the `request` keeps every scalar as written. Even 1.2 reads `zip: 0123` as `123` and
859
+ `version: 1.10` as `1.1`; with `"text"`, they stay `"0123"` and `"1.10"`, and transforms convert what needs
860
+ converting.
861
+ - **Merge keys** (`<<: *defaults`) are applied, **anchors** expand with a cap (a "billion laughs" file fails),
862
+ and **duplicate keys** fail the step with their line.
863
+ - **Custom tags** (`!!js/function`, `!custom`) never build values: the tagged value is read as a plain one, and
864
+ the trace shows a warning.
865
+
866
+ ### 4.10 Markdown
867
+
868
+ `request` with `as: "markdown"` (or a response served as `text/markdown`, or a local `file:…md`) renders
869
+ GitHub-flavoured Markdown (tables, task lists, strikethrough, autolinks) to an **HTML document**, so every
870
+ `css` selector works on it. GitHub raw and most CDNs serve Markdown as `text/plain`: set `as`. Two additions
871
+ make it easier to aim at:
872
+
873
+ - **Sections.** Each heading and everything up to the next heading of the same or a higher level is wrapped
874
+ in `<section data-heading="…" data-level="…">`, sections nesting: the table under *Prezzi* is
875
+ `section[data-heading='Prezzi' i] table`. Headings get GitHub's slug ids (`<h2 id="prezzi">`).
876
+ - **Front matter.** A leading `---` YAML block is parsed (YAML 1.2, as §4.9) and put in the head as
877
+ `<script type="application/json" data-front-matter>`: read it the way JSON-LD is read.
878
+
879
+ ```json
880
+ { "type": "request", "url": "{{start.url}}", "as": "markdown" },
881
+ { "type": "extract", "id": "front", "selector": "script[data-front-matter]", "kind": "css", "take": "text" },
882
+ { "type": "extract", "id": "updated", "from": "front", "selector": "$.updated", "kind": "jsonpath" },
883
+ { "type": "extract", "id": "table", "selector": "^Modello Versione", "kind": "table", "columns": { "model": "^Modello$", "price": "^Prezzo$" } }
884
+ ```
885
+
886
+ In a GFM table a `|` inside a code span still splits the cell: the Markdown must write it `\|`. Raw HTML in the
887
+ Markdown is kept and selectable, and never runs (it is parsed, not loaded in a browser). To run a `regex` over
888
+ the Markdown source instead, request it with `as: "text"`.
889
+
890
+ ### 4.11 HTML tables
891
+
892
+ `table` also reads an HTML document's `<table>`s: a fetched page, rendered Markdown, a Word document, or, in web
893
+ mode, the live page. With `from`, it reads the HTML an id holds: a Word or Markdown document fetched earlier, or,
894
+ in web mode, a page a `request` returned (without `from`, a web recipe's `table` reads the live page). Every table
895
+ becomes a grid: `thead`, `tbody` and `tfoot` rows in order, `th` and `td` alike, cell text with whitespace
896
+ collapsed (a `<br>` or a paragraph inside a cell reads as a space: `2659<br>$560` is `2659 $560`), and `colspan` /
897
+ `rowspan` as merged ranges. From there it is the grid table of §4.7: the `selector` matches the header row, merged
898
+ cells are filled, `headerRows` joins a header over two rows, `fillDown` and `columns` work the same. A table
899
+ inside a table is read on its own.
900
+
901
+ ```json
902
+ { "type": "extract", "id": "table", "kind": "table", "selector": "^Model Version", "headerRows": 2,
903
+ "columns": { "model": "^Model$", "version": "^Version$", "urban": "^Consumption Urban$" } }
904
+ ```
905
+
906
+ A model merged down its versions (`rowspan`) reads on every row, and *Consumption* over *Urban · Mixed*
907
+ (`colspan`) names both columns. It replaces a `css` extract per `tr` and a `take` per `td`. `probe` lists
908
+ every table's header row with a ready `selector`.
909
+
910
+ ### 4.12 XML: feeds, sitemaps, open data
911
+
912
+ `application/xml`, `text/xml`, any `+xml` type (`application/atom+xml`, `application/rss+xml`), `.xml`, `.rss`,
913
+ `.atom` files and gzipped `.xml.gz` sitemaps are read as XML (`as: "xml"` forces it). Query them with `xpath`:
914
+
915
+ ```json
916
+ { "type": "request", "url": "https://shop.example/feed.xml" },
917
+ { "type": "extract", "id": "entries", "selector": "//a:entry", "kind": "xpath", "namespaces": { "a": "http://www.w3.org/2005/Atom" }, "take": "json", "many": true },
918
+ { "type": "forEach", "over": "entries", "as": "entry", "emit": true, "steps": [
919
+ { "type": "extract", "id": "title", "from": "entry", "selector": "/a:entry/a:title", "kind": "xpath", "namespaces": { "a": "http://www.w3.org/2005/Atom" } },
920
+ { "type": "extract", "id": "link", "from": "entry", "selector": "/entry/link/@href", "kind": "xpath", "ignoreNamespaces": true, "take": "value" }
921
+ ] }
922
+ ```
923
+
924
+ - **Namespaces.** An element in a namespace only matches a prefixed name. Prefixes the root element declares
925
+ (`xmlns:media="…"`) are known already. A **default** namespace (`<feed xmlns="http://www.w3.org/2005/Atom">`) has
926
+ no prefix, so give it one in `namespaces`, or set `ignoreNamespaces: true` and write plain names. `probe` prints
927
+ the namespaces and the line to paste. `//*[local-name()='entry']` also works, but reads worse.
928
+ - **Take.** `text` collapses whitespace, `value` keeps a node's text as is (an attribute: `@href` with `take:
929
+ "value"`), `attr:<name>` reads an element's attribute, `html` is the element's inner markup, `json` its outer
930
+ markup: an entry to query again with `from`, its namespace declarations included. A function result
931
+ (`count(//a:entry)`, `string(/a:feed/a:title)`) is taken as it is.
932
+ - **CDATA and entities.** `<![CDATA[600e <La Prima>]]>` and `&amp;` read as text. Entities a DOCTYPE declares are
933
+ never expanded and nothing external is fetched: an XML bomb or an XXE stays inert text.
934
+ - **Sitemaps.** `//loc` with `ignoreNamespaces: true` lists a sitemap's pages; a `forEach` over them fetches each.
935
+ A sitemap index lists sitemaps: nest a second `forEach`.
936
+ - **Other kinds on XML.** `css` works too (tag names keep their case; escape a prefix: `media\\:thumbnail`),
937
+ `regex` reads the markup; `jsonpath` and `table` do not.
938
+
939
+ `xpath` also reads **fetched HTML** in api mode, parsed like a browser does: `//table[@class='variants']/tbody/tr`
940
+ matches a page whose markup has no `<tbody>`.
941
+
942
+ ### 4.13 Word documents
943
+
944
+ A `.docx` (a Word content type, `.docx`/`.docm`/`.dotx`, or `as: "docx"`) is read by `@opencraw/office-reader`
945
+ and handed to the recipe as **HTML**, built like rendered Markdown (§4.10), so every tool for pages works on it:
946
+
947
+ - headings (`Heading 1`, a localized `Titolo 2`, `Title`) become `<h1>`…`<h6>`, each in a
948
+ `<section data-heading="…" data-level="…">` with what follows it: `section[data-heading='Prezzi' i] table`;
949
+ - bullets and numbering become nested `<ul>` / `<ol>`;
950
+ - tables become `<table>`s with merged cells as `colspan` / `rowspan`, so a `table` extract reads a two-row
951
+ header (`headerRows: 2`) the way it reads a spreadsheet's (§4.11);
952
+ - links become `<a href>`, a paragraph's Word style is `data-style` (`p[data-style='Prezzo']`);
953
+ - page headers come before the body, footers and notes after it: `header[data-part=header]`, `footer[data-part=footer]`,
954
+ `aside[data-part=notes] li#footnote-1`; the document's title is `<title>`.
955
+
956
+ ```json
957
+ { "type": "request", "url": "https://dealer.example/circolare.docx" },
958
+ { "type": "extract", "id": "prices", "selector": "^Modello", "kind": "table", "headerRows": 2,
959
+ "columns": { "model": "^Modello$", "list": "Listino", "net": "Netto" } },
960
+ { "type": "extract", "id": "conditions", "selector": "section[data-heading='Condizioni'] li", "kind": "css", "many": true }
961
+ ```
962
+
963
+ Its `id` holds the HTML, so `table` with `from: <id>` reads its tables after another `request` has replaced the
964
+ current document. Tracked changes read as accepted (insertions in, deletions out). A legacy `.doc` is refused with
965
+ what to do: save it as `.docx`, or export it as PDF (§4.6). `probe` lists a document's sections and tables.
966
+
967
+ ## 5. Mapping
968
+
969
+ ```json
970
+ "mapping": {
971
+ "actor": { "from": "actor_name", "transform": [{ "op": "trim" }] },
972
+ "year": { "from": "year", "transform": [{ "op": "regex", "pattern": "(\\d{4})" }, { "op": "integer" }] },
973
+ "price": { "from": ["price_int", "price_cents"], "transform": [{ "op": "join", "separator": "." }, { "op": "currency" }] },
974
+ "seller.name": { "from": "seller" },
975
+ "variants": { "each": "variant_rows", "fields": {
976
+ "size": { "from": ".", "transform": [{ "op": "regex", "pattern": "<td class=\"size\">([^<]+)" }] }
977
+ }}
978
+ }
979
+ ```
980
+
981
+ - The key is an output field, dotted for members of `object` fields.
982
+ - `from` is an id or a path into one (`item.href`, `page.url`). Several sources give the chain a **list** of
983
+ values (then `join`, `coalesce`, `sum`...).
984
+ - `each` builds an `array` of `object` from a list id; inside `fields`, `from` is relative to each item and
985
+ `.` is the item itself. Each key of `fields` names a field of the items (a typo is a load error), and each
986
+ member gets its rule's `onMissing`, its field's policy, `default` and validation, reported at its index
987
+ (`mapping failed: variants[1].size: missing`).
988
+ - `onMissing` on a rule overrides the field's policy for this recipe. `onMissing: "default"` needs a `default`
989
+ on the field.
990
+ - Generated fields are not mapped; required fields, and required members of an `each` item, must be mapped,
991
+ defaulted or generated. The binding validator enforces these at load time.
992
+
993
+ ### 5.1 Transforms
994
+
995
+ Applied in order. A **scalar** op applied to a list runs on every item; a **list** op runs on the list. A
996
+ missing value (`undefined`, `null`) passes through every op except `default`, `template` and `hook`, so it
997
+ reaches the missing-value policy untouched.
998
+
999
+ | Op | Args | Kind | Does |
1000
+ |---|---|---|---|
1001
+ | `trim`, `lowercase`, `uppercase` | – | scalar | text ops |
1002
+ | `replace` | `pattern`, `replacement`, `flags?` (default `g`) | scalar | regular-expression replace |
1003
+ | `regex` | `pattern`, `group?`, `flags?` | scalar | the first match; group 1 by default when the pattern has one, else the whole match; **no match gives a missing value** |
1004
+ | `split` | `separator` | scalar | text → list |
1005
+ | `join`, `concat` | `separator` | list | list → text (`concat` defaults to no separator) |
1006
+ | `first`, `last` | – | list | one item |
1007
+ | `nth` | `index` (negative from the end) | list | one item |
1008
+ | `slice` | `start`, `end?` | list | a sub-list |
1009
+ | `coalesce` | – | list | the first item that is not missing or blank |
1010
+ | `default` | `value` | list | replaces `undefined`, `null` or `''` |
1011
+ | `number` | `locale?` | scalar | reads the one number in the text (`"1.299,00"` with `de-DE` → 1299, `"£51.77"` → 51.77); two numbers or more is an error, so `regex` first. Separators as for the `number` type (§1.2) |
1012
+ | `integer` | – | scalar | as `number`, truncated |
1013
+ | `boolean` | `truthy?` | scalar | `truthy`: `true` when the text contains a phrase (substring, case-insensitive). Without it, the field type's default phrases (§1.2). |
1014
+ | `toString` | – | scalar | coerces any value to its plain string form (`String(value)`), unlike the text ops above which reject anything but text, a number, or a boolean |
1015
+ | `padStart`, `padEnd` | `length`, `char?` (default a space, exactly one character) | scalar | pads text to `length` from the left or right |
1016
+ | `currency` | `locale?`, `currency?` | scalar | `{ amount, currency }`; the amount as `number` reads it; the code from the arg, else an ISO 4217 code next to the amount, else a symbol, else an ISO 4217 code anywhere in the text |
1017
+ | `date` | `format?`, `timezone?` | scalar | a Date; `format` tokens `YYYY MM DD HH mm ss`; `timezone` an IANA zone for text without an offset, UTC without one (never the host's zone); a date alone (`2026-03-04`) is midnight UTC |
1018
+ | `absoluteUrl` | `base?` | scalar | resolve against `base`, else `page.url` |
1019
+ | `urlEncode` | – | scalar | percent-encodes one URL component (`encodeURIComponent`): `#` → `%23`, space → `%20`, `&` → `%26`, `+` → `%2B`, `é` → `%C3%A9` |
1020
+ | `flatten`, `unique` | – | list | nested lists flattened; duplicates removed |
1021
+ | `sum`, `count` | – | list | numbers |
1022
+ | `template` | `value` | list | renders a template against the scope, ignoring the input |
1023
+ | `jsonpath` | `path` | list | runs a JSONPath on the input data |
1024
+ | `lookup` | `in` (an id or path in scope), `key`, `pick?` | scalar | finds the first item of the table `in` whose `key` path equals the value (compared as text) and yields `pick` from it, or the whole item; no match gives a missing value. The table may be data, JSON text or a list of JSON texts (one `data-*` attribute per element): §5.2 |
1025
+ | `group` | `by` | list | `[{ key, items }]` in first-seen order; items without the path group under `null` |
1026
+ | `hook` | `name`, `args?` | list | calls a registered hook with the value so far |
1027
+
1028
+ ### 5.2 Joining two extractions
1029
+
1030
+ Sites split one record across places: the price table lists versions, a colour picker elsewhere on the
1031
+ page carries a JSON per trim. `lookup` joins them at mapping time, so the body of the loop stays a plain
1032
+ row read:
1033
+
1034
+ ```json
1035
+ { "type": "extract", "id": "colour_data", "selector": "[data-vrdata]", "kind": "css", "take": "attr:data-vrdata", "many": true },
1036
+ { "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [
1037
+ { "type": "extract", "id": "trim_base", "from": "label", "selector": "^'(GT-Line S|GT-Line|Air)", "kind": "regex" }
1038
+ ]}
1039
+ ```
1040
+
1041
+ ```json
1042
+ "colours": { "from": "trim_base", "transform": [
1043
+ { "op": "lookup", "in": "colour_data", "key": "trimname", "pick": "colors" },
1044
+ { "op": "jsonpath", "path": "$[*].displayName" }
1045
+ ]}
1046
+ ```
1047
+
1048
+ `in` is resolved against the scope of the record like a template path, so `colour_data` extracted outside
1049
+ the loop is visible. Inside an `each` rule's `fields` the same holds: `in` (and a `template` transform's
1050
+ paths) look in the current item first, then in the record, so one table extracted once per record can
1051
+ annotate every row of a nested list:
1052
+
1053
+ ```json
1054
+ "options": { "each": "option_rows", "fields": {
1055
+ "code": { "from": "code" },
1056
+ "content": { "from": "code", "transform": [{ "op": "lookup", "in": "componentsInfo", "key": "id", "pick": "text" }] }
1057
+ }}
1058
+ ```
1059
+
1060
+ An item key with the same name as a record id wins. `from` inside `fields` stays relative to the item. Applied to a list, `lookup` runs per item. `group` goes the other way: one list of
1061
+ rows becomes one `{ key, items }` per distinct key. Map the result straight into an `array` of `object` field
1062
+ (members `key` and `items`) or a `json` field: `each` can't walk it, since `each` reads a list bound in the
1063
+ scope, not a transform's output.
1064
+
1065
+ ---
1066
+
1067
+ ## 6. Hooks
1068
+
1069
+ The one extension point. Register functions when creating the crawler and reference them by name:
1070
+
1071
+ ```ts
1072
+ const crawler = createCrawler({ hooks: {
1073
+ positive: (input) => Number(input) > 0,
1074
+ geocode: async (input, args, context) => { context.log('info', 'geocoding', { input }); return await lookup(String(input), args.country) },
1075
+ }})
1076
+ ```
1077
+
1078
+ `(input, args, context) => value`, sync or async. From a `hook` **step**, `input` is `undefined` and the
1079
+ result is bound under the step's `id`; from a `hook` **transform**, `input` is the value so far. `context`
1080
+ gives `recipeId`, the scope snapshot and a `log`. A recipe naming an unregistered hook fails the run before
1081
+ its first request: `crawler.run` and `crawler.work` reject with `UnknownHookError`, naming the recipe and where
1082
+ it calls the hook (`shop mapping.inStock.transform.1: unknown hook "positive"`).
1083
+
1084
+ **From the cli and the MCP server**, hooks come from a JavaScript module whose default export is the map
1085
+ (named function exports work too):
1086
+
1087
+ ```js
1088
+ // hooks.mjs
1089
+ export default {
1090
+ positive: (input) => Number(input) > 0,
1091
+ }
1092
+ ```
1093
+
1094
+ ```sh
1095
+ opencraw run recipes/ --hooks hooks.mjs # or OPENCRAW_HOOKS=hooks.mjs
1096
+ ```
1097
+
1098
+ The same file becomes a **plugins module** when it names its exports: `hooks` (the map), `accessPlugins`
1099
+ ([access.md](access.md#plugins)) and `captchaSolvers` (§6.1). `--plugins` / `OPENCRAW_PLUGINS` are the same
1100
+ option under their own name.
1101
+
1102
+ ```js
1103
+ // plugins.mjs
1104
+ export const hooks = { positive: input => Number(input) > 0 }
1105
+ export const accessPlugins = [myProxyList]
1106
+ export const captchaSolvers = [capsolver({ apiKey: process.env.CAPSOLVER_KEY })]
1107
+ ```
1108
+
1109
+ The MCP server reads `OPENCRAW_PLUGINS` / `OPENCRAW_HOOKS` from its own environment, never from a tool call: an agent can run
1110
+ recipes that call your hooks but cannot make the server load a module of its choosing. The module runs as
1111
+ your code, with your privileges, like anything you `import`: load only files you trust.
1112
+
1113
+ **In the Studio**, start it with the same option: `opencraw studio recipes/ --hooks hooks.mjs` (or
1114
+ `OPENCRAW_HOOKS`). The command loads the file, so the Studio runs sample runs and login snapshots with your
1115
+ hooks, access plugins and captcha solvers, and shows a bar naming the file and the hooks it provides. Started
1116
+ without it, a run that calls a hook stops with a message saying so. No request or recipe can make the Studio
1117
+ load a module; only the command line that started it can.
1118
+
1119
+ The editor uses the loaded names. **Add step → Hook** adds a `hook` step named after the first loaded hook, and
1120
+ its form has a list of the hooks to pick from. In a field's transform chain, **+ transform → hook (your code)**
1121
+ adds a hook transform, and clicking it opens the same list. A name the recipe already has that is not loaded
1122
+ stays selectable and is marked. With no hooks loaded the name is a text field (a new hook is called `myHook`).
1123
+
1124
+ **Hooks that call out, in the Studio.** A hook made with `commandHook` or `httpHook`, a solver made with
1125
+ `commandCaptchaSolver` or `httpCaptchaSolver`, and a plugin made with `commandAccessPlugin` or
1126
+ `httpAccessPlugin` call outside the process: a sample run waits for them and they may cost. The bar under the
1127
+ toolbar lists them with what they call, a sample run's trace says `☎ hook "price" calls POST https://…` for each
1128
+ call, and each hook has **stub with**: a JSON value (`{"amount": 9}`, `"abc"`, `null`) the run answers with
1129
+ instead of calling it. A stub lasts for the session and is never written to a recipe; a value that is not JSON
1130
+ is ignored, so the hook is called. Solvers and plugins are listed but cannot be stubbed.
1131
+
1132
+ **TypeScript:** the module may be a `.ts` file on Node 22.18 or newer (Node 24 included), which strips the
1133
+ types as it loads; only syntax that is just types works (no `enum`, no parameter properties). On older Node,
1134
+ compile it first.
1135
+
1136
+ **Hooks in any language, or behind an API.** A hook does not have to be JavaScript. `commandHook` runs a
1137
+ program, and `httpHook` calls a service; both answer to the same request and response, so the recipe still
1138
+ says `{ "op": "hook", "name": "slug" }` and the module decides where the code runs.
1139
+
1140
+ ```js
1141
+ // hooks.mjs
1142
+ import { commandHook, httpHook } from '@opencraw/core'
1143
+
1144
+ export const hooks = {
1145
+ slug: commandHook('slug', ['python3', 'hooks/slug.py']),
1146
+ price: httpHook('price', 'https://rates.example.com/price', { tokenEnv: 'RATES_TOKEN', allowedHosts: ['rates.example.com'] }),
1147
+ }
1148
+ ```
1149
+
1150
+ The program (or the service) gets one JSON request and answers with one JSON response. The shapes are published
1151
+ as `schemas/callout-request.schema.json` and `schemas/callout-response.schema.json`:
1152
+
1153
+ ```json
1154
+ { "kind": "hook", "name": "slug", "input": "Hello World", "args": {}, "context": { "recipeId": "books", "scope": {} }, "idempotencyKey": "9f2c…" }
1155
+ ```
1156
+
1157
+ ```json
1158
+ { "status": "ok", "output": "hello-world" }
1159
+ ```
1160
+
1161
+ ```python
1162
+ # hooks/slug.py: the request is on stdin, the answer goes to stdout
1163
+ import json, re, sys
1164
+
1165
+ request = json.load(sys.stdin)
1166
+ slug = re.sub(r"[^a-z0-9]+", "-", str(request["input"]).lower()).strip("-")
1167
+ print(json.dumps({"status": "ok", "output": slug}))
1168
+ ```
1169
+
1170
+ `{ "status": "error", "error": "why" }` fails the step with that reason. Anything the program writes to stderr is
1171
+ logged at `debug`. `idempotencyKey` is the same for the same call, so a service can answer a repeat with the result
1172
+ it already has. A command is an argument list, never a shell string; both kinds time out (30 s by default). An
1173
+ endpoint is named in the module, not in a recipe: `tokenEnv` and `signingKeyEnv` name environment variables (a
1174
+ bearer token, and a secret that signs the body as `x-opencraw-signature: sha256=…`), and `allowedHosts` refuses an
1175
+ endpoint outside the hosts you list.
1176
+
1177
+ **Work that takes a while.** A handler that is not done answers `{ "status": "pending", "retryAfterMs": 5000 }`.
1178
+ OpenCraw waits that long (a second when it says nothing) and asks again with the same request and the same
1179
+ `idempotencyKey`, until the handler answers `ok` or `error`. `maxWaitMs` bounds the whole wait (120 s by default) and
1180
+ the call fails with "still pending" when it passes. The handler starts the work on the first request and looks it
1181
+ up by the key on later ones, which is what makes the repeat safe:
1182
+
1183
+ ```js
1184
+ price: httpHook('price', 'https://rates.example.com/price', { maxWaitMs: 300_000 }),
1185
+ ```
1186
+
1187
+ ```python
1188
+ from opencraw import HookPending, serve_hook
1189
+
1190
+ def price(value, args, request):
1191
+ job = jobs.get(request["idempotencyKey"]) or jobs.start(value)
1192
+ if not job.done:
1193
+ raise HookPending(retry_after_ms=2000)
1194
+ return job.result
1195
+
1196
+ serve_hook(price)
1197
+ ```
1198
+
1199
+ **Pushing the result back.** On a host that can receive it (the durable host), the request also carries
1200
+ `callback: { "url": "…" }`, a URL valid for that one call. A handler that answers `pending` may then post
1201
+ its result there instead of being asked again: the same `{ "status": "ok", "output": … }` or
1202
+ `{ "status": "error", "error": … }` (published as `schemas/callout-resolution.schema.json`), never `pending`
1203
+ again. The durable host takes it with its `callouts` option (see
1204
+ [azure-durable.md](azure-durable.md#8-hooks-that-take-a-while)); captcha solvers and access plugins behind the
1205
+ same schema are tracked in issue #201.
1206
+
1207
+ ### 6.1 Captcha solvers
1208
+
1209
+ A captcha solver gets past one challenge on the live page. The engine does the rest: finding challenges,
1210
+ checking the page afterwards, retrying, rotating, and capping what a run spends.
1211
+
1212
+ ```ts
1213
+ import type { CaptchaSolver } from '@opencraw/core'
1214
+
1215
+ const solver: CaptchaSolver = {
1216
+ name: 'my-solver',
1217
+ solve: async (challenge, { page, lease, attempt, signal, log }) => {
1218
+ // challenge: { kind, url, siteKey?, action?, selector?, field?, refresh? }
1219
+ const token = await myService.solve(challenge.kind, challenge.siteKey, challenge.url, { signal })
1220
+ await page.locator('[name="g-recaptcha-response"]').evaluate((field, value) => { field.value = value }, token)
1221
+ await page.locator('form').first().evaluate(form => form.submit())
1222
+ return { status: 'solved' } // or { status: 'failed', reason: 'balance is zero' }
1223
+ },
1224
+ }
1225
+ const crawler = createCrawler({ captchaSolvers: [solver] })
1226
+ ```
1227
+
1228
+ A recipe names it in `session.captcha` (checked after every navigation, click and key press) or in a
1229
+ `captcha` step (one known point, such as a login form):
1230
+
1231
+ ```json
1232
+ "session": { "captcha": { "solver": "my-solver", "verify": { "selector": "#results" }, "maxSolves": 5 },
1233
+ "onBlock": { "solve": true, "rotate": true } }
1234
+ ```
1235
+
1236
+ | Key | Default | Meaning |
1237
+ |---|---|---|
1238
+ | `solver` | | A registered solver's name. An unknown name fails the recipe before its first page. |
1239
+ | `detect.selector` | the common widgets | Where challenges are: visible `.g-recaptcha`, `.h-captcha`, `.cf-turnstile`, or their iframes. |
1240
+ | `verify` | `{ gone: true }` | How a solve is confirmed: the challenge is `gone` and/or an element (`selector`) appears; a `failure` element showing refuses it at once; `timeoutMs` (10000) is how long the page has. The solver's `solved` is only a claim. |
1241
+ | `attempts` | 3 | Solves tried per challenge. |
1242
+ | `timeoutMs` | 120000 | Time one solve may take; then `signal` aborts and the attempt fails. |
1243
+ | `maxSolves` | 10 | Solves the whole run may spend, rotations and bootstrap included. `0` detects without paying. |
1244
+
1245
+ What happens:
1246
+
1247
+ 1. **Detect.** The first visible match gives `kind` (`recaptcha-v2`, `hcaptcha`, `turnstile`, `image`,
1248
+ `unknown`; `recaptcha-v3` in a `captcha` step only), the `siteKey` (`data-sitekey`, or the iframe's `k` /
1249
+ `sitekey`) and a `selector` for the widget.
1250
+ 2. **Solve.** Each attempt spends one solve of `maxSolves`. The solver applies its answer on the page
1251
+ (injects a token and submits, types a text). A throw or a timeout is a failed attempt.
1252
+ 3. **Verify.** Up to 10 s for the page to confirm. A failed attempt re-detects (a widget re-renders after a
1253
+ wrong answer), or reloads when the widget is gone without confirming.
1254
+ 4. **Give up.** After `attempts`, or when `maxSolves` is spent, the step fails with a `CaptchaError`. It is a
1255
+ block, so `onBlock.rotate` retries the step on a new IP (often an easier challenge, or none), then the step's
1256
+ `onError` applies.
1257
+
1258
+ **Form captchas.** A `captcha` step with `image` is a captcha the site checks only when its form is posted
1259
+ (an image code, a field, the form's button). `refresh` and `field` go to the solver with the challenge;
1260
+ `submit` (clicks, fills, key presses, selects, waits, scripts) posts the form after the solver on every
1261
+ attempt; `verify.selector` is required, and `verify.failure` names the refusal message. The solver fills the
1262
+ field and returns `solved` (or `{ status: 'solved', submitted: true }` when it, or a person, posted the form);
1263
+ its optional `verdict` hook hears the page's answer. `"solver": "manual"` is built in: a person solves it in a
1264
+ headed browser. See [captcha.md](captcha.md#form-captchas-checked-when-the-form-is-posted).
1265
+
1266
+ Solvers get the access `lease`: token services solve faster, and more often correctly, through the same proxy
1267
+ as the browser. Api recipes cannot solve (there is no page): with `session.captcha`, a fetched page showing a
1268
+ widget fails as a block that says so. Solve it in `session.bootstrap` and keep the cookies.
1269
+
1270
+ Solving captchas can break a site's terms of service. [captcha.md](captcha.md) has a working solver for
1271
+ CapSolver, the costs, and when not to.
1272
+
1273
+ ---
1274
+
1275
+ ## 7. Policies: what happens when something is missing or fails
1276
+
1277
+ **A step fails** (selector without match, HTTP 404, timeout, hook throws). The policy is the step's
1278
+ `onError`, else the recipe's, else `fail`:
1279
+
1280
+ | Policy | Effect |
1281
+ |---|---|
1282
+ | `fail` | The recipe stops with a `StepFailure` naming the step path. The next input recipe still runs unless the crawler was created with `onRecipeError: 'stop'`. |
1283
+ | `skip` | The id stays unset, the walk continues, a `step:skip` event is reported. |
1284
+ | `retry` | The step is tried up to `attempts` times in all, the first included, waiting `backoffMs`, then 2 × `backoffMs`, and so on (linear) between tries; then it is treated as `fail`. |
1285
+
1286
+ **A request fails in passing** (a dropped connection, a timeout, a 503, a 429). Before any of the above, the
1287
+ engine sends the same request again: that is `limits.retry`, and it is **on by default**.
1288
+
1289
+ ```json
1290
+ "limits": { "retry": { "attempts": 4, "backoffMs": 500, "maxDelayMs": 20000, "statuses": [429, 502, 503, 504] } }
1291
+ ```
1292
+
1293
+ | Key | Default | Meaning |
1294
+ |---|---|---|
1295
+ | `attempts` | `3` | Tries per request, the first included. `1` turns it off. |
1296
+ | `backoffMs` | `1000` | The first pause. It doubles on each retry, give or take 25% so parallel requests do not retry in step. |
1297
+ | `maxDelayMs` | `30000` | The longest pause. A server whose `Retry-After` asks for longer is not retried: it means "come back much later". |
1298
+ | `statuses` | `[408, 425, 429, 500, 502, 503, 504]` | Answers retried. Connection failures always are: resets, refusals, timeouts, a DNS lookup that could not run, a proxy that dropped the tunnel. A host that does not exist is not. |
1299
+ | `forMs` | none | A time budget: keep retrying until this long after the first try. Without `attempts`, tries are not counted; with it, whichever runs out first. No pause runs past the budget. |
1300
+
1301
+ - It covers every `goto`, `request` and `next.url` page, and the page a `next.selector`, `click`, `press` or
1302
+ `select` navigates to (retried by loading that URL again; a form post is not retried). A `Retry-After` (seconds or a date) is honoured, and it
1303
+ holds back **every** request to that site, not only the one that got it.
1304
+ - A retry is the same request again, on the same access lease. It does not spend the step's `onError` retries,
1305
+ and each one is a `request:retry` event (`↺` in the trace).
1306
+ - When the tries run out, the last answer counts. A 429 or 403 that is still there becomes a block
1307
+ (`session.blockedWhen`), so `onBlock.rotate` takes over; a 503 fails the step, and the step's `onError` applies.
1308
+ - The crawler sets the default for every recipe: `createCrawler({ retry: { attempts: 5 } })`, CLI `--retries <n>`.
1309
+ A recipe's `limits.retry` wins over it.
1310
+
1311
+ Like redialling a busy number: wait a moment, dial again, give up after a few tries. If the other end said "call
1312
+ back in a minute", wait that minute.
1313
+
1314
+ **A site that goes down for a while.** Some sites fail every request for minutes at a time, then come back. For
1315
+ an unattended run, give the retry a time budget instead of a number of tries. This waits out up to 30 minutes of
1316
+ 404s, trying about once a minute:
1317
+
1318
+ ```json
1319
+ "limits": { "retry": { "forMs": 1800000, "backoffMs": 5000, "maxDelayMs": 60000, "statuses": [404, 429, 500, 502, 503, 504] } }
1320
+ ```
1321
+
1322
+ Retry 404 only for a site known to answer it for an outage: on most sites a 404 means the page is not there.
1323
+
1324
+ **A mapped value is missing** (`undefined`, `null`, `""`, and in a field that is not `string` or `json` text
1325
+ with nothing but spaces; an empty list is a value). This holds for every field type: an empty table cell
1326
+ mapped to an `integer` is missing, not a number that failed to parse. The policy is the
1327
+ mapping rule's `onMissing`, else the field's, else `default` when the field has a `default`, else the
1328
+ recipe's, else `fail` for required fields and `null` otherwise:
1329
+
1330
+ | Policy | Effect |
1331
+ |---|---|
1332
+ | `fail` | The recipe stops (`MappingFailedError`). |
1333
+ | `skip-record` | This record is dropped and reported (`record:reject`); the walk continues. |
1334
+ | `null` | The field is `null` (the field must be `nullable` or not `required`). |
1335
+ | `default` | The field's `default`, coerced to the field's type. |
1336
+
1337
+ **A value cannot be coerced** (`"call us"` into a `number`, a relative link into a `url`) follows the same
1338
+ missing-value policy: `skip-record` drops the record, anything else stops the recipe. The message names the
1339
+ field and its type, not a transform the rule doesn't have: `mapping failed: price: number field: no number in
1340
+ "call us"`.
1341
+
1342
+ **A transform throws** (a `number` op on `"OTR £"`, a hook error): the recipe stops, unless the mapping rule
1343
+ itself says `onMissing: "skip-record"`, which reads as "without this field the record is worthless" and
1344
+ drops just that record. Use it on the price of a table whose header row you cannot select away.
1345
+
1346
+ ---
1347
+
1348
+ ## 8. Loading, binding and running
1349
+
1350
+ ```ts
1351
+ import { createCrawler, jsonLinesSink, loadRecipeSet, traceLine } from '@opencraw/core'
1352
+
1353
+ const recipes = await loadRecipeSet({ output: 'recipes/movie.output.json', inputs: ['recipes/'] })
1354
+ const crawler = createCrawler({
1355
+ sink: jsonLinesSink('out/movies.jsonl'), // default: memorySink()
1356
+ hooks: { positive: input => Number(input) > 0 },
1357
+ dedupe: 'run', // 'run' | 'recipe' | 'off'; worker mode defaults to 'recipe' (per item)
1358
+ onRecipeError: 'continue', // or 'stop'
1359
+ browser: { headless: true }, // Playwright launch settings
1360
+ onEvent: event => { const line = traceLine(event); if (line) console.log(line) },
1361
+ })
1362
+ const report = await crawler.run(recipes)
1363
+ await crawler.close()
1364
+ ```
1365
+
1366
+ `loadRecipeSet` takes the output recipe apart from the inputs; `loadRecipes(source)` takes one source holding
1367
+ all of them and finds the output recipe by its `kind`. Either reads recipes from anywhere, not only files:
1368
+ a path to a `.json` or `.jsonl` file or a directory of them, JSON or JSON Lines text, the same as bytes
1369
+ (`Buffer`, `ArrayBuffer`, `Blob`, `File`, a stream), decoded objects, or an array mixing these. A server
1370
+ holding uploaded recipes in memory calls `loadRecipes(blob)` and never writes a file. The cli takes paths
1371
+ only.
1372
+
1373
+ `loadRecipeSet` parses every file against its schema (`RecipeValidationError` lists every problem with its
1374
+ JSON path) and then **binds** the inputs to the output (`RecipeBindingError`): every mapping key names an
1375
+ output field and every `each` field a field of the items, every `from` starts with a known id, required fields
1376
+ are covered, every `default` coerces to its field's type and passes its rules, `onMissing: "default"` has a
1377
+ `default`, web steps stay in web recipes, `next.selector` only in web mode, one emitting construct per path, and
1378
+ no step `id` or `forEach` `as` rebinds a name already bound on its path. Hook names are checked when the crawler
1379
+ runs the set, before any request (§6).
1380
+
1381
+ The report gives, per recipe: `emitted`, `rejected`, `duplicates`, `skipped` (records a resumed run already
1382
+ had), `stepsSkipped` (steps whose `onError: skip` swallowed a failure: a check that found nothing), `pages`,
1383
+ `durationMs`, and `error` when the recipe stopped. `pages` counts every `page:visit`: each page a `goto`, a
1384
+ `next` or a navigating `click`, `press` or `select` reached, and each `request` that got an answer. An answer
1385
+ that fails the step still counts (a 404 is a page that was fetched); a request or navigation that got no
1386
+ answer at all (a dropped connection, a refused one) does not. The sink summary says how many records were written and where.
1387
+
1388
+ **Resuming.** A long crawl that dies halfway does not have to start over. Open the sink in append mode and
1389
+ ask the crawler to resume:
1390
+
1391
+ ```ts
1392
+ const crawler = createCrawler({ sink: jsonLinesSink('out/movies.jsonl', { append: true }), resume: true })
1393
+ ```
1394
+
1395
+ In append mode every line carries the record key as `_key`, and `open` reads the keys already in the file.
1396
+ With `resume`, a record whose key the sink has is not written again: it is counted as `skipped` and
1397
+ reported as `record:skipped`. The steps still run (the engine has to reach the record to know its key), so
1398
+ a resumed run costs the requests but not the duplicates. Any sink can support this by implementing `has(key)`;
1399
+ `memorySink` does. `resume` with a sink that cannot answer throws at `createCrawler`.
1400
+
1401
+ **Running recipes you don't trust.** A service that runs other people's recipes must not become a way into
1402
+ its own network or files. `allowedHosts` limits every request the crawler makes:
1403
+
1404
+ ```ts
1405
+ const crawler = createCrawler({ allowedHosts: ['example.com', '*.shop.example', 'api.example.org:8443'] })
1406
+ ```
1407
+
1408
+ - A pattern is a host, `*.host` (its subdomains and the host itself), `host:port`, or `*` for any host.
1409
+ - It holds wherever a request leaves: navigations, the page's own requests (a script's `fetch`, images,
1410
+ frames), web sockets, `request` steps, and every redirect hop, which is checked before it is followed.
1411
+ - `file:` is refused whatever the list says, a relative `file:` URL too (after it resolves); `data:`, `blob:` and
1412
+ `about:` never leave the page and pass.
1413
+ - Service workers are blocked, since their requests would bypass the check. A remote browser's own service
1414
+ workers (`cdp` profiles) can't be blocked.
1415
+ - A refused request fails the step: `HostNotAllowedError` in api mode, `net::ERR_BLOCKED_BY_CLIENT` in a
1416
+ browser, both reported as `errorKind: 'host'`.
1417
+ - The check is by name. A public name that resolves to a private address is not caught, so run the service
1418
+ in a network that can't reach anything it shouldn't.
1419
+
1420
+ ### 8.1 Change detection
1421
+
1422
+ A price list crawled every week answers "what is there", but the question is usually "what changed". Records
1423
+ have keys, so two runs compare **record by record**, like a spreadsheet's "compare versions" rather than a text
1424
+ diff: a reordered file is no change, and a changed price shows as that field, before and after.
1425
+
1426
+ ```sh
1427
+ opencraw run recipes/ --out prices.jsonl --diff prices.jsonl --changes changes.jsonl
1428
+ ```
1429
+
1430
+ reads the previous `prices.jsonl` before crawling, overwrites it, and reports `added` / `removed` / `changed`
1431
+ (nested fields dotted: `price.amount: 24950 → 23950`), ignoring the fields the engine stamps (`generated:
1432
+ now`, `uuid`). From code: `diffRecords(previous, current, diffOptionsFor(output))`; from the MCP server, the
1433
+ `diff` tool.
1434
+
1435
+ It also watches the recipe: a run with under half the previous run's records is flagged. A site that changed
1436
+ its markup rarely makes a recipe fail; it makes it find less.
1437
+
1438
+ ### 8.2 Events and the trace
1439
+
1440
+ Everything the engine does is an event: `recipe:start` / `recipe:finish`, `page:visit` (with the HTTP status; one per page counted in `pages`),
1441
+ `access:lease` / `access:blocked` / `access:rotate`, `request:retry`, `captcha:detected` / `captcha:solve` / `captcha:solved` /
1442
+ `captcha:failed` / `captcha:budget`, `step:start` /
1443
+ `step:finish` / `step:retry` / `step:skip` (with the step type, its id and its path such as
1444
+ `steps.8.steps.2`), `step:branch`, `step:kept`, in worker mode `window:open` / `window:close` / `windows:change` /
1445
+ `browser:restart` / `item:finish`, `record:emit` / `record:reject` / `record:duplicate` / `record:skipped`,
1446
+ `warning`, `error`. `traceLine`
1447
+ turns an event into one indented line, so a recipe's route reads as a tree:
1448
+
1449
+ ```text
1450
+ ▶ tmdb (api)
1451
+ ⇢ page 1 https://www.themoviedb.org/movie/949-heat
1452
+ · steps.0 request entry 312 ms
1453
+ · steps.1 extract entry_ld 4 ms
1454
+ · steps.3 extract actor_name 2 ms
1455
+ ⇢ page 1 https://www.themoviedb.org/person/1158-al-pacino
1456
+ · steps.5 request actor_page 401 ms
1457
+ · steps.8.steps.0 extract title 1 ms
1458
+ ↷ steps.8.steps.2 extract year skipped: no match for td.year
1459
+ ✚ record ["Al Pacino","St. Vincent","tmdb"]
1460
+ ■ tmdb: 10 emitted, 0 rejected, 0 duplicates, 2 pages, 3006 ms
1461
+ ```
1462
+
1463
+ The example runners print it with `--trace`.
1464
+
1465
+ ---
1466
+
1467
+ ## 9. Method: how to write a recipe for a new site
1468
+
1469
+ A quick way to see where a site's data lives before writing selectors by hand: `@opencraw/cli`'s
1470
+ `opencraw probe <url>` (add `--browser` to also watch the JSON a script fetches after load) lists JSON-LD
1471
+ blocks, inline JSON objects, `.json` URLs, script hosts and API-looking links found in the page.
1472
+ `@opencraw/mcp` gives an agent the same `probe`, plus `validate`, `run`, `list_recipes` and `diff`, as MCP tools
1473
+ instead of a terminal command.
1474
+
1475
+ 1. **Find the data before the markup.** Fetch the page with a browser user agent and look for
1476
+ `application/ld+json`, inline JSON (`__NEXT_DATA__`, `window.__STATE__`) or an XHR the page calls. Data
1477
+ beats selectors: it does not move when the design changes.
1478
+ 2. **Decide the mode.** Data in the HTML → `api`. Data rendered by scripts, interactions needed, or a bot
1479
+ wall on plain HTTP → `web`.
1480
+ 3. **Map the route.** Listing → detail, or entry → link → list. Each hop is a `goto` / `request` whose URL
1481
+ is a template over an id extracted on the previous page.
1482
+ 4. **Write the output first.** What is required, what is a key, what may be null.
1483
+ 5. **Extract with ids named after what they hold**, not where they come from (`actor_href`, not `link3`).
1484
+ 6. **Say what is optional.** `onError: skip` on extracts that legitimately miss; `nullable` on their fields.
1485
+ Leave everything else strict so a site change fails loudly.
1486
+ 7. **Run with `maxRecords: 3` and `--trace`.** Check the first record by hand against the page. Then lift
1487
+ the limit.
1488
+ 8. **Write the `description`.** The route, the reason it works, the element that is the pivot.
1489
+
1490
+ ---
1491
+
1492
+ ## 10. Troubleshooting
1493
+
1494
+ | Symptom | Cause | Fix |
1495
+ |---|---|---|
1496
+ | `no match for <selector>` on the first extract of a page | wrong selector, or the page is a challenge / login wall | print `page:visit` URLs; fetch the page and look; on a bot wall switch to `web` or run through a proxy ([access.md](./access.md)) |
1497
+ | `none of the N texts bound to "x" is JSON` | the scripts are not JSON-LD, or hold JavaScript | check the block; `evaluate` in web mode is the fallback |
1498
+ | a field carries the *next* row's value | the row selector matched a wrapper element first | select the innermost repeating element (§4.5) |
1499
+ | `"…" is not an absolute URL, and there is no page to resolve it against` | a relative URL before any navigation, or a start URL that is a plain path | make the first step `goto` / `request` with `{{start.url}}`; give a start point a full URL, or `file:` for a local file (§3.2) |
1500
+ | `mapping.x.from: "y" does not start with a known id` | typo in an id, or the id is bound only in a bootstrap | ids are per recipe; bootstraps produce a session, not ids |
1501
+ | `record rejected: title: missing` on every record | the id is bound in a sibling scope, not the emitting one | extract inside the `forEach` body, or before it |
1502
+ | the crawl stops after page 1 in web mode | the body navigated away and `next.selector` is not on the page | the engine returns to the listing page; if the listing is itself reached by clicking, use `next.url` |
1503
+ | `HTTP 202` with an empty body | AWS WAF challenge | a real browser from a non-flagged IP: a residential proxy profile ([access.md](./access.md)) |