@opencraw/mcp 0.1.45 → 0.1.48

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 +28 -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 +517 -1
  11. package/dist/list-tool/index.d.ts +3 -0
  12. package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
  13. package/dist/main.d.ts +7 -0
  14. package/dist/probe-tool/index.d.ts +2 -0
  15. package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
  16. package/dist/recipe-source/index.d.ts +3 -0
  17. package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
  18. package/dist/run-tool/index.d.ts +3 -0
  19. package/dist/run-tool/run-tool.handler.d.ts +51 -0
  20. package/dist/server/create-server.use-case.d.ts +11 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
  23. package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
  24. package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
  25. package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
  26. package/dist/src/docs-tool/index.d.ts +3 -0
  27. package/dist/src/server/create-server.use-case.d.ts +1 -1
  28. package/dist/src/studio-tool/index.d.ts +2 -0
  29. package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
  30. package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
  31. package/dist/src/studio-tool/studio-tools.handler.d.ts +68 -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 +68 -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 +314 -0
  59. package/docs/studio/pdf-agreements.md +150 -0
  60. package/package.json +29 -4
@@ -0,0 +1,698 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Recipes
6
+
7
+ [← How OpenCraw works](README.md) · Next: [3. Page steps](02-page-steps.md)
8
+
9
+ A crawl is a set of JSON files. **One output recipe** says what a record is: its fields, their types, which
10
+ fields identify it. **One or more input recipes** say where the data is and how to get it: web or api mode, where
11
+ to start, which steps to run, and how the values the steps found map to the output's fields. This page follows
12
+ both kinds of file through the engine: what each type does to a real value, how keys drop duplicates across
13
+ recipes, what changes between web and api mode, and what the loader checks before anything runs.
14
+
15
+ [authoring.md §1–2](../recipes/authoring.md#1-the-output-recipe) is the reference for every key named here, and
16
+ [§8](../recipes/authoring.md#8-loading-binding-and-running) for loading.
17
+
18
+ ## The output recipe: what a record is
19
+
20
+ An output recipe is a list of fields. Each field has a `type`, and optionally says whether it's `required`,
21
+ whether `null` is acceptable (`nullable`), what to use when the value is missing (`default`), whether it's part
22
+ of the record's identity (`key`), or that the engine fills it in (`generated`). The input recipes never say what
23
+ type a value is; they only say where it comes from. The output decides the type, and every input recipe that
24
+ feeds it gets the same conversion.
25
+
26
+ The engine applies the output recipe last. After the steps have run and a record is emitted, the mapping reads
27
+ each field's value from the scope and runs its transforms, and only then does the output recipe act on it,
28
+ field by field:
29
+
30
+ 1. **coerce** the value to the field's type, or fail;
31
+ 2. **validate** it against `min`, `max`, `pattern`, `minLength` or `maxLength`, if the field has any;
32
+ 3. if the value is **missing** (`undefined`, `null`, or `""`), apply the missing-value policy instead. Blank
33
+ text (empty once trimmed) is missing in every type except `string` and `json`, which keep text as it is: a
34
+ `" "` in a `number` or `date` field gets the field's `default`, `null` or its policy, not a coercion error;
35
+ 4. add the **generated** fields, and compute the **key**.
36
+
37
+ Only the fields the output declares make it into the record. A mapping can't add a field the output doesn't
38
+ have: the loader refuses it (see [binding](#binding-do-the-recipes-fit-together)).
39
+
40
+ ### Every type, on one real page
41
+
42
+ The scene: one book on [books.toscrape.com](https://books.toscrape.com), read in api mode (the HTML is fetched,
43
+ no browser). Every `extract` takes text as the page shows it, and the output
44
+ ([`book-detail.output.json`](recipes/recipes-types/book-detail.output.json)) gives each field a type:
45
+
46
+ ```json
47
+ "upc": { "type": "string", "required": true, "key": true },
48
+ "price": { "type": "currency", "required": true },
49
+ "priceValue": { "type": "number" },
50
+ "prices": { "type": "object", "fields": { "excl": { "type": "currency" }, "incl": { "type": "currency" }, "tax": { "type": "currency" } } },
51
+ "inStock": { "type": "boolean" },
52
+ "available": { "type": "integer", "min": 0 },
53
+ "productType": { "type": "enum", "values": ["Books", "E-books"] },
54
+ "image": { "type": "url" },
55
+ "breadcrumb": { "type": "array", "items": { "type": "string" } },
56
+ "rows": { "type": "json" },
57
+ ```
58
+
59
+ <!-- capture:recipes-types screenshot alt=A_Light_in_the_Attic:_the_values_the_extracts_read -->
60
+ ![A Light in the Attic: the values the extracts read](../assets/how-it-works/recipes-types.png)
61
+ <!-- /capture -->
62
+
63
+ The input recipe ([`attic-api.input.json`](recipes/recipes-types/attic-api.input.json)) maps most fields
64
+ straight from an extract, with no transform, so the table shows what coercion alone does. `read` is the value the
65
+ mapping read from the scope, and **field** is the value after coercion:
66
+
67
+ <!-- capture:recipes-types mapping fields=upc,title,price,priceValue,prices.excl,prices.tax,inStock,available,reviews,productType,image,breadcrumb,rows -->
68
+ | Field | Step | Value |
69
+ |---|---|---|
70
+ | `upc` | read | `"a897fe39b1053632"` |
71
+ | | **field** | `"a897fe39b1053632"` |
72
+ | `title` | read | `"A Light in the Attic"` |
73
+ | | **field** | `"A Light in the Attic"` |
74
+ | `price` | read | `"£51.77"` |
75
+ | | **field** | `{"amount":51.77,"currency":"GBP"}` |
76
+ | `priceValue` | read | `"£51.77"` |
77
+ | | **field** | `51.77` |
78
+ | `prices.excl` | read | `"£51.77"` |
79
+ | | **field** | `{"amount":51.77,"currency":"GBP"}` |
80
+ | `prices.tax` | read | `"£0.00"` |
81
+ | | **field** | `{"amount":0,"currency":"GBP"}` |
82
+ | `inStock` | read | `"In stock (22 available)"` |
83
+ | | **field** | `true` |
84
+ | `available` | read | `"In stock (22 available)"` |
85
+ | | **field** | `22` |
86
+ | `reviews` | read | `"0"` |
87
+ | | **field** | `0` |
88
+ | `productType` | read | `"Books"` |
89
+ | | **field** | `"Books"` |
90
+ | `image` | read | `"../../media/cache/fe/72/fe72f0532301ec28892ae79a629a293c.jpg"` |
91
+ | | `absoluteUrl` | `"https://books.toscrape.com/media/cache/fe/72/fe72f0532301ec28892ae79a629a293c.jpg"` |
92
+ | | **field** | `"https://books.toscrape.com/media/cache/fe/72/fe72f0532301ec28892ae79a629a293c.jpg"` |
93
+ | `breadcrumb` | read | `["Home","Books","Poetry"]` |
94
+ | | **field** | `["Home","Books","Poetry"]` |
95
+ | `rows` | read | `["UPCa897fe39b1053632","Product TypeBooks","Price (excl. tax)£51.77","… 4 more"]` |
96
+ | | **field** | `["UPCa897fe39b1053632","Product TypeBooks","Price (excl. tax)£51.77","… 4 more"]` |
97
+ <!-- /capture -->
98
+
99
+ The same text, `"£51.77"`, becomes an object in a `currency` field and a plain number in a `number` field. The
100
+ same `"In stock (22 available)"` becomes `true` in a `boolean` field and `22` in an `integer` field. What each
101
+ type does:
102
+
103
+ | Type | What it accepts | What it refuses | Seen here |
104
+ |---|---|---|---|
105
+ | `string` | text as it is, with no trimming; a number or a boolean becomes its text | a list or an object | `"a897fe39b1053632"` stays as it is |
106
+ | `number` | a number, or text holding exactly one number: a sign, currency symbols and words around it are ignored, and `.` or `,` is read as a decimal or a thousands separator (see [below](#the-same-text-read-as-a-number)) | text with no number, with two or more (`"2 for 10,00"`, the UPC), or with separators that make no sense (`1.2.3`) | `"£51.77"` → `51.77` |
107
+ | `integer` | the same as `number`, then cut toward zero (truncated, not rounded) | the same as `number` | `"In stock (22 available)"` → `22` |
108
+ | `boolean` | anything. `true` when the whole text is `true`, `yes`, `y`, `1` or `on`, or it contains the words `in stock` or `available`; a number is `true` unless it's `0` | nothing: whatever isn't `true` is `false` | `"In stock (22 available)"` → `true` |
109
+ | `date` | a `Date`, milliseconds since 1970, ISO text (or other text JavaScript's `Date` reads), or text matching the field's `format` (`DD/MM/YYYY`…); gives the day in UTC. Text that names no zone (no `Z`, offset or `GMT`) is read as UTC, whatever the machine's time zone (the `date` transform's `timezone` names another) | text that doesn't read as a date | `now` → `"2026-09-27"` (the generated `scrapedOn`) |
110
+ | `datetime` | the same as `date` | the same | `now` → `"2026-09-27T17:…Z"` |
111
+ | `currency` | text with one amount and a symbol or an ISO 4217 code (a code written next to the amount wins, and three capitals that aren't a currency, like `OTR`, are ignored), a number, or `{ amount, currency }`. The field's own `currency`, when set, replaces any code in the value | a value with no code anywhere (not in the text, the transform or the field); text with no amount or two | `"£51.77"` → `{"amount":51.77,"currency":"GBP"}` |
112
+ | `url` | an absolute URL, trimmed and normalised | anything relative | the image, after `absoluteUrl` |
113
+ | `enum` | text that is exactly one of `values`, case included | anything else | `"Books"` |
114
+ | `array` | a list, or a single value (wrapped in a list); each item is coerced with `items` | an item its `items` refuses | the breadcrumb |
115
+ | `object` | an object; only the declared `fields` are kept, each coerced | a list, or text | `prices`, built from three dotted targets (`prices.excl`…) |
116
+ | `json` | any JSON value, kept exactly as it is | what JSON can't hold: `NaN`, a `Date`, a function | the seven table rows, as a list |
117
+
118
+ `object` and `json` both hold structure, but they do opposite things. `object` is for a shape you know: it keeps
119
+ the declared members and drops the rest. `json` is for a shape you don't want to fix: it keeps everything and
120
+ checks nothing.
121
+
122
+ The record, with the generated fields at the end:
123
+
124
+ <!-- capture:recipes-types record -->
125
+ ```json
126
+ {
127
+ "upc": "a897fe39b1053632",
128
+ "title": "A Light in the Attic",
129
+ "price": {
130
+ "amount": 51.77,
131
+ "currency": "GBP"
132
+ },
133
+ "priceValue": 51.77,
134
+ "prices": {
135
+ "excl": {
136
+ "amount": 51.77,
137
+ "currency": "GBP"
138
+ },
139
+ "incl": {
140
+ "amount": 51.77,
141
+ "currency": "GBP"
142
+ },
143
+ "tax": {
144
+ "amount": 0,
145
+ "currency": "GBP"
146
+ }
147
+ },
148
+ "inStock": true,
149
+ "available": 22,
150
+ "reviews": 0,
151
+ "productType": "Books",
152
+ "image": "https://books.toscrape.com/media/cache/fe/72/fe72f0532301ec28892ae79a629a293c.jpg",
153
+ "breadcrumb": [
154
+ "Home",
155
+ "Books",
156
+ "Poetry"
157
+ ],
158
+ "rows": [
159
+ "UPCa897fe39b1053632",
160
+ "Product TypeBooks",
161
+ "Price (excl. tax)£51.77",
162
+ "… 4 more"
163
+ ],
164
+ "oldPrice": null,
165
+ "onSale": false,
166
+ "subtitle": null,
167
+ "scrapedAt": "2026-09-27T21:45:03.948Z",
168
+ "scrapedOn": "2026-09-27",
169
+ "sourceUrl": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
170
+ "recipe": "attic-api"
171
+ }
172
+ ```
173
+ <!-- /capture -->
174
+
175
+ Two details in the `rows` field: `take: "text"` on a table row joins the cells' text with no space
176
+ (`"UPCa897fe39b1053632"`), because there's no whitespace between the `<th>` and the `<td>` in the page's HTML.
177
+ And the record prints four of the seven rows: the guide cuts long lists for reading, and the field holds all
178
+ seven.
179
+
180
+ ### The same text, read as a number
181
+
182
+ A number type looks for **the one number** in a text: digits with `.` or `,` between them, a sign in front, and
183
+ anything around it (a currency symbol, words, brackets) ignored. A text with no number, or with two or more,
184
+ is refused rather than guessed at. The scene
185
+ [`recipes-number`](recipes/recipes-number/attic-rows.input.json) reads every row of the product table and maps
186
+ the same cell into a `number`, an `integer` and a `boolean` field:
187
+
188
+ <!-- capture:recipes-number records n=7 -->
189
+ ```json
190
+ {"rejected":{"field":"number","reason":"number field: \"a897fe39b1053632\" holds 3 numbers (897, 39, 1053632): pick one with a \"regex\" transform first"}}
191
+ {"rejected":{"field":"number","reason":"number field: no number in \"Books\""}}
192
+ {"row":"Price (excl. tax)","text":"£51.77","number":51.77,"integer":51,"boolean":false}
193
+ {"row":"Price (incl. tax)","text":"£51.77","number":51.77,"integer":51,"boolean":false}
194
+ {"row":"Tax","text":"£0.00","number":0,"integer":0,"boolean":false}
195
+ {"row":"Availability","text":"In stock (22 available)","number":22,"integer":22,"boolean":true}
196
+ {"row":"Number of reviews","text":"0","number":0,"integer":0,"boolean":false}
197
+ ```
198
+ <!-- /capture -->
199
+
200
+ - The UPC `a897fe39b1053632` holds three numbers (`897`, `39` and `1053632`), so the `number` field refuses it
201
+ and names them. The same goes for `"2 for 10,00"` or a date such as `"March 14, 1879"`: when a text holds more
202
+ than one number, pick the one you want with a `regex` transform first. The mapping rule says
203
+ `"onMissing": "skip-record"`, so the record is rejected and the run goes on (next section).
204
+ - `"Books"` has no number at all, so it's refused the same way. Both reasons name the `number` field: it's the
205
+ first of the two number fields, and one refusal is enough to reject the record. The message names the field's
206
+ type, not a `number` transform the rule doesn't have.
207
+ - Without a `locale`, the separators are guessed. Of two kinds (`1.299,50 €`) the last one is the decimal point,
208
+ so that's `1299.5`. A single `.` or `,` groups thousands only when exactly three digits follow it and one to
209
+ three digits other than a lone `0` precede it: `"1.299"` is `1299`, while `"0.125"` is `0.125` and
210
+ `"10,00"` is `10`. A repeated one groups thousands (`1,234,567`), and separators that make no sense
211
+ (`1.2.3`) are refused. Use the `number` transform with `locale` when a site's format is known.
212
+ - `integer` truncates: `51.77` becomes `51`.
213
+ - `boolean` never fails. Of the five records emitted only `"In stock (22 available)"` is `true`, and `"0"` is
214
+ `false`.
215
+
216
+ ### What coercion refuses
217
+
218
+ The scene [`recipes-rejects`](recipes/recipes-rejects) runs six small input recipes against the same page.
219
+ Five each map one real value into a field that can't take it, with `"onMissing": "skip-record"` on the rule, so
220
+ the record is rejected, with the field and the reason apart:
221
+
222
+ <!-- capture:recipes-rejects records n=5 -->
223
+ ```json
224
+ {"rejected":{"field":"amount","reason":"no currency: set \"currency\" on the field or use the currency transform"}}
225
+ {"rejected":{"field":"availableOn","reason":"date field: cannot read \"In stock (22 available)\" as a date"}}
226
+ {"rejected":{"field":"kind","reason":"\"Books\" is not one of book, ebook"}}
227
+ {"rejected":{"field":"section","reason":"expected text, got a list"}}
228
+ {"rejected":{"field":"image","reason":"\"../../media/cache/fe/72/fe72f0532301ec28892ae79a629a293c.jpg\" is not an absolute URL (use the absoluteUrl transform)"}}
229
+ ```
230
+ <!-- /capture -->
231
+
232
+ In order: `"0"` in a `currency` field with no code; the availability text in a `date` field; `"Books"` against
233
+ lowercase `values` (enums are case-sensitive); the breadcrumb (`many: true`, a list) in a `string` field; the
234
+ relative image link in a `url` field.
235
+
236
+ The sixth recipe, `url-without-policy`, maps the same image link with no policy. A coercion failure isn't a
237
+ missing value (blank text is, [above](#the-output-recipe-what-a-record-is)), so `default` and `null` can't rescue
238
+ it. Only `skip-record` turns it into a rejected record;
239
+ anything else fails the step that emitted, and with the default step policy the recipe stops:
240
+
241
+ <!-- capture:recipes-rejects summary -->
242
+ ```text
243
+ reject-currency: 0 emitted, 1 rejected, 0 duplicates, 1 pages
244
+ reject-date: 0 emitted, 1 rejected, 0 duplicates, 1 pages
245
+ reject-enum: 0 emitted, 1 rejected, 0 duplicates, 1 pages
246
+ reject-string: 0 emitted, 1 rejected, 0 duplicates, 1 pages
247
+ reject-url: 0 emitted, 1 rejected, 0 duplicates, 1 pages
248
+ url-without-policy: 0 emitted, 0 rejected, 0 duplicates, 1 pages, stopped: step steps.2 (emit) failed: mapping failed: image: "../../media/cache/fe/72/fe72f0532301ec28892ae79a629a293c.jpg" is not an absolute URL (use the absoluteUrl transform)
249
+ ```
250
+ <!-- /capture -->
251
+
252
+ Only that recipe stopped: the others had already finished, and a failed recipe doesn't stop the rest of the set
253
+ (`onRecipeError` is `continue` by default). [Part 9, Policies](08-policies.md) follows every case: missing
254
+ values, coercion, validation and transform failures, and which policy applies where.
255
+
256
+ ### `required`, `nullable`, `default`
257
+
258
+ The product page has no old price, no sale badge and no subtitle. The recipe extracts all three with
259
+ `"onError": { "policy": "skip" }`, so each `extract` that finds nothing is skipped and its id stays unbound:
260
+
261
+ <!-- capture:recipes-types trace grep=↷ -->
262
+ ```text
263
+ ↷ steps.13 extract oldPrice skipped: no match for .product_main .price_old
264
+ ↷ steps.14 extract saleBadge skipped: no match for .product_main .sale
265
+ ↷ steps.15 extract subtitle skipped: no match for .product_main h2
266
+ ```
267
+ <!-- /capture -->
268
+
269
+ Each of the three fields then shows a different rule for a missing value:
270
+
271
+ | Field | Spec | Record | Why |
272
+ |---|---|---|---|
273
+ | `oldPrice` | `{ "type": "currency", "currency": "GBP" }` | `null` | Not required, so missing becomes `null`. |
274
+ | `onSale` | `{ "type": "boolean", "default": false }` | `false` | A field with a `default` uses it when the value is missing. |
275
+ | `subtitle` | `{ "type": "string", "required": true, "nullable": true, "onMissing": "null" }` | `null` | Required, but its policy is `null` and it's `nullable`. |
276
+
277
+ - `required` alone makes a missing value fail the recipe. `nullable` doesn't change that on its own: it only
278
+ lets a required field be `null` when the policy that applies is `null` (from the field, the rule or the output
279
+ recipe's `onMissing`). Without the `onMissing`, `subtitle` would stop the run.
280
+ - A `default` is coerced and validated like a mapped value, and the loader does it: `"default": "0"` on a
281
+ `number` field is `0`, while `"default": "none"` on it (or `onMissing: "default"` on a field with no default)
282
+ is a `RecipeBindingError` before anything runs ([Part 9](08-policies.md#the-precedence) shows one).
283
+ - An empty list is not missing. `many: true` with no match binds `[]`, and `[]` goes to coercion like any value
284
+ (a `string` field refuses it).
285
+
286
+ ### Generated fields
287
+
288
+ `generated` fields are filled in by the engine, after the mapping: `now` (the time the record was mapped),
289
+ `uuid`, `sourceUrl` (the page the record was emitted from) and `recipeId`. They're coerced like any other field,
290
+ so `now` gives an instant in the `datetime` field `scrapedAt` and a day in the `date` field `scrapedOn` (see the
291
+ record above). A generated field can't be mapped, and can't be marked `required`: it's always there. The loader
292
+ refuses both.
293
+
294
+ ## Keys and duplicates
295
+
296
+ Every field marked `key: true` is part of the record's identity. The key is the JSON list of those fields'
297
+ values, in the order the output declares them, after coercion: the book above has the key
298
+ `["a897fe39b1053632"]`, which the trace prints on its record line:
299
+
300
+ <!-- capture:recipes-types trace grep=record -->
301
+ ```text
302
+ ✚ record ["a897fe39b1053632"]
303
+ ```
304
+ <!-- /capture -->
305
+
306
+ A missing key field counts as `null` in the key, and an output with no key field gives every record the key
307
+ `null`: those records are never checked for duplicates.
308
+
309
+ Before a record reaches the sink, the engine checks whether its key has been seen. A record whose key has been
310
+ seen is counted as a **duplicate** and not written; the first record with a key wins. The scene
311
+ [`recipes-dedupe`](recipes/recipes-web-vs-api) runs two recipes that feed the same `quote` output, keyed by the
312
+ quote's text: `quotes-api` reads pages 1 and 2 of the site's JSON API, and `quotes-web` reads page 1 of its
313
+ JavaScript-rendered page. The set runs its input recipes in the order they were loaded (here, the files'
314
+ alphabetical order), so the api recipe runs first:
315
+
316
+ <!-- capture:recipes-dedupe summary -->
317
+ ```text
318
+ quotes-api: 20 emitted, 0 rejected, 0 duplicates, 2 pages
319
+ quotes-web: 0 emitted, 0 rejected, 10 duplicates, 1 pages
320
+ ```
321
+ <!-- /capture -->
322
+
323
+ <!-- capture:recipes-dedupe trace grep=▶|■|≡ lines=4 -->
324
+ ```text
325
+ ▶ quotes-api (api)
326
+ ■ quotes-api: 20 emitted, 0 rejected, 0 duplicates, 2 pages, … ms
327
+ ▶ quotes-web (web)
328
+ ≡ duplicate ["“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"]
329
+ …
330
+ ```
331
+ <!-- /capture -->
332
+
333
+ The web recipe did all its work and every record it built was already written. Things to know:
334
+
335
+ - **The scope is the whole run by default.** `createCrawler({ dedupe })` takes `run` (one set of keys shared by
336
+ every recipe of a `run()` call, the default), `recipe` (each recipe run, each matrix variant and each worker
337
+ item on its own) or `off`. [Worker mode](../recipes/worker-mode.md) defaults to `recipe`.
338
+ - **The comparison is exact.** The key is built from coerced values, and a `string` field isn't trimmed, so
339
+ `"Albert Einstein"` and `"Albert Einstein "` are two keys. Two recipes meant to overlap must produce the same
340
+ text: trim, or build URLs with `absoluteUrl`, in both.
341
+ - **Which record wins depends on order.** With `parallel`, it's whichever recipe emits first.
342
+ - **Duplicates are counted, not hidden:** `record:duplicate` events, and `duplicates` in the report.
343
+ - Resuming a crawl reads keys back from the sink and skips records it already has: see
344
+ [part 10](09-running.md).
345
+
346
+ ## The input recipe: where the data is and how to get it
347
+
348
+ An input recipe names the output it feeds (`output`), then says how to reach the data:
349
+
350
+ | Key | What it does |
351
+ |---|---|
352
+ | `mode` | `web` runs the steps in a browser page; `api` sends HTTP requests with no browser. |
353
+ | `start` | One or more start points `{ url, vars? }`. Each runs the whole step list from a fresh scope. |
354
+ | `vars` | Values the steps and the mapping read as `vars.name`. |
355
+ | `limits` | `maxRecords`, `delayMs`, `timeoutMs`, `concurrency`, `retry`. |
356
+ | `session` | Headers, cookies, user agent, a login (`bootstrap`), access, block handling, captchas. |
357
+ | `onError` | The default error policy for every step. |
358
+ | `steps` | What to do: navigate, extract, loop, emit ([parts 3 to 5](02-page-steps.md)). |
359
+ | `mapping` | Output field → where its value comes from and how it's transformed ([part 8](07-mapping-and-transforms.md)). |
360
+
361
+ ### Web or api: the same data read both ways
362
+
363
+ [quotes.toscrape.com](https://quotes.toscrape.com) serves the same quotes twice: `/js/` builds them in the
364
+ browser with a script (the HTML the server sends has no `div.quote` in it, only the script and its data), and
365
+ `/api/quotes?page=N` returns them as JSON. The scene [`recipes-web-vs-api`](recipes/recipes-web-vs-api) reads
366
+ both into one output, with de-duplication off so both sets of records are kept.
367
+
368
+ <!-- capture:recipes-web-vs-api screenshot alt=The_JavaScript-rendered_page,_with_what_the_web_recipe_selects -->
369
+ ![The JavaScript-rendered page, with what the web recipe selects](../assets/how-it-works/recipes-web-vs-api.png)
370
+ <!-- /capture -->
371
+
372
+ The two recipes' steps:
373
+
374
+ ```json
375
+ "mode": "web",
376
+ "steps": [
377
+ { "type": "goto", "url": "{{start.url}}" },
378
+ { "type": "extract", "id": "quotes", "selector": "div.quote", "kind": "css", "take": "html", "many": true },
379
+ { "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "steps": [
380
+ { "type": "extract", "id": "text", "from": "quote", "selector": "span.text", "kind": "css" },
381
+ { "type": "extract", "id": "author", "from": "quote", "selector": "small.author", "kind": "css" },
382
+ { "type": "extract", "id": "tags", "from": "quote", "selector": "a.tag", "kind": "css", "many": true }
383
+ ]}
384
+ ]
385
+ ```
386
+
387
+ ```json
388
+ "mode": "api",
389
+ "steps": [
390
+ { "type": "request", "url": "{{start.url}}" },
391
+ { "type": "extract", "id": "quotes", "selector": "$.quotes[*]", "kind": "jsonpath", "take": "json", "many": true },
392
+ { "type": "forEach", "over": "quotes", "as": "item", "emit": true, "steps": [] }
393
+ ]
394
+ ```
395
+
396
+ The web recipe has to take the values out of markup, one `extract` per value. The api recipe's items are
397
+ already data, so its loop has no steps: the mapping reads straight into each item (`item.author.name`). The
398
+ scopes the two emits hand to the mapping, for the first quote:
399
+
400
+ <!-- capture:recipes-web-vs-api scope ids=text,author,tags,page,vars record=20 -->
401
+ ```json
402
+ {
403
+ "text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
404
+ "author": "Albert Einstein",
405
+ "tags": [
406
+ "change",
407
+ "deep-thoughts",
408
+ "thinking",
409
+ "world"
410
+ ],
411
+ "page": {
412
+ "url": "https://quotes.toscrape.com/js/",
413
+ "number": 1
414
+ },
415
+ "vars": {
416
+ "via": "web"
417
+ }
418
+ }
419
+ ```
420
+ <!-- /capture -->
421
+
422
+ <!-- capture:recipes-web-vs-api scope ids=item,page,vars record=0 -->
423
+ ```json
424
+ {
425
+ "item": {
426
+ "author": {
427
+ "goodreads_link": "/author/show/9810.Albert_Einstein",
428
+ "name": "Albert Einstein",
429
+ "slug": "Albert-Einstein"
430
+ },
431
+ "tags": [
432
+ "change",
433
+ "deep-thoughts",
434
+ "thinking",
435
+ "world"
436
+ ],
437
+ "text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"
438
+ },
439
+ "page": {
440
+ "url": "https://quotes.toscrape.com/api/quotes?page=1",
441
+ "number": 1
442
+ },
443
+ "vars": {
444
+ "via": "api"
445
+ }
446
+ }
447
+ ```
448
+ <!-- /capture -->
449
+
450
+ And the two records, the web one first. They're the same record, except for `via`, which each recipe sets in its
451
+ `vars`:
452
+
453
+ <!-- capture:recipes-web-vs-api record record=20 -->
454
+ ```json
455
+ {
456
+ "text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
457
+ "author": "Albert Einstein",
458
+ "tags": [
459
+ "change",
460
+ "deep-thoughts",
461
+ "thinking",
462
+ "world"
463
+ ],
464
+ "via": "web"
465
+ }
466
+ ```
467
+ <!-- /capture -->
468
+
469
+ <!-- capture:recipes-web-vs-api record record=0 -->
470
+ ```json
471
+ {
472
+ "text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
473
+ "author": "Albert Einstein",
474
+ "tags": [
475
+ "change",
476
+ "deep-thoughts",
477
+ "thinking",
478
+ "world"
479
+ ],
480
+ "via": "api"
481
+ }
482
+ ```
483
+ <!-- /capture -->
484
+
485
+ What each mode opens, and what it allows:
486
+
487
+ | | `web` | `api` |
488
+ |---|---|---|
489
+ | Session | a new browser context and one page, on a shared Chromium | Playwright's HTTP request context: no browser, no JavaScript |
490
+ | Steps | every step; `request` shares the page's cookies | `request`, `extract` and the flow steps (`forEach`, `paginate`, `if`, `set`, `collect`, `emit`, `hook`) |
491
+ | `page.url` | the browser's real URL, updated after every step | the final URL of the nearest `request` |
492
+ | Refused at load | nothing mode-specific | page steps (`goto`, `click`, `fill`…), `forEach` over a `selector`, `paginate` with `next.selector`, a `request` with `form` |
493
+
494
+ Try `api` first: it's faster, lighter, and most sites send their data in the HTML or a JSON endpoint. Use `web`
495
+ when a script builds the content (as on `/js/`), when a value only appears after a click or a scroll, or when the
496
+ site refuses plain HTTP clients. An api recipe can still log in with a browser, in its session bootstrap
497
+ ([below](#session-in-outline)).
498
+
499
+ ### `start` and `vars`
500
+
501
+ Each start point runs the whole step list in a fresh scope, where `start.url` is its URL and `page` starts as
502
+ `{ url: start.url, number: 1 }`. The api recipe has two start points, one per API page, so its trace shows two
503
+ first pages:
504
+
505
+ <!-- capture:recipes-dedupe trace grep=▶|⇢|■ -->
506
+ ```text
507
+ ▶ quotes-api (api)
508
+ ⇢ page 1 https://quotes.toscrape.com/api/quotes?page=1
509
+ ⇢ page 1 https://quotes.toscrape.com/api/quotes?page=2
510
+ ■ quotes-api: 20 emitted, 0 rejected, 0 duplicates, 2 pages, … ms
511
+ ▶ quotes-web (web)
512
+ ⇢ page 1 https://quotes.toscrape.com/js/
513
+ ■ quotes-web: 0 emitted, 0 rejected, 10 duplicates, 1 pages, … ms
514
+ ```
515
+ <!-- /capture -->
516
+
517
+ Both lines say `page 1`: `page.number` counts pages within one start point, and only `paginate` moves it on.
518
+ The report's `pages` counts every page visit of the run, so the api recipe reports 2.
519
+
520
+ `vars` are the recipe's parameters. The steps read them in templates (`{{vars.tag}}`), and the mapping reads
521
+ them like any other value (`"via": { "from": "vars.via" }`). A run's vars are built in this order, each layer
522
+ overriding the one before: the recipe's `vars`, then the `matrix` combination or the worker item's vars, then
523
+ the start point's own `vars`. The start point wins, even over a matrix.
524
+
525
+ ### `limits`
526
+
527
+ | Limit | What it bounds |
528
+ |---|---|
529
+ | `maxRecords` | Stops the whole walk once that many records were written. Rejected and duplicate records don't count. Exact, even with `concurrency`. |
530
+ | `delayMs` | The shortest time between two request starts in this recipe run: every `goto`, `request` and next-page click, across all loops. Not the page's own images and scripts. |
531
+ | `timeoutMs` | Navigations, requests, `wait`, `select`, downloads. Not clicks or fills: those take the browser's timeout. |
532
+ | `concurrency` | How many iterations of a `forEach` over a list run at once: tabs in web mode, requests in api mode. Default 1. |
533
+ | `retry` | How a request that fails in passing is sent again. On by default (three tries). |
534
+
535
+ The recipes on this page all set `delayMs: 300`, to be polite to the practice sites. The session scene below
536
+ also sets `maxRecords: 3` and `timeoutMs: 60000`. [Part 10](09-running.md) covers throttling, retries and
537
+ concurrency in depth.
538
+
539
+ ### `session`, in outline
540
+
541
+ `session` holds what the site needs from the client: `headers`, `userAgent`, `cookies`, `viewport`, and three
542
+ bigger pieces:
543
+
544
+ - **`bootstrap`**: steps run in a browser **before** the crawl, typically a login. Afterwards the engine keeps
545
+ what `keep` lists (`cookies`, `localStorage`, or both), and every page or request of the crawl starts with it.
546
+ `saveTo` writes that state to a file, and `storageStatePath` starts from a saved file instead of running the
547
+ bootstrap (the file must exist).
548
+ - **`browserProfile`**: a named browser profile kept between runs (cookies, storage, cache). Web recipes run in
549
+ it; api recipes start from its cookies.
550
+ - **`access`, `blockedWhen`, `onBlock`, `captcha`**: proxies, what counts as being blocked, what to do then, and
551
+ captcha solving. All in [part 10](09-running.md).
552
+
553
+ The scene [`recipes-session`](recipes/recipes-session/quotes-signed-in.input.json) is an **api** recipe whose
554
+ bootstrap logs in with a browser. quotes.toscrape.com accepts any user name and password, and once signed in
555
+ shows each author's Goodreads link:
556
+
557
+ ```json
558
+ "mode": "api",
559
+ "session": { "bootstrap": { "keep": ["cookies"], "steps": [
560
+ { "type": "goto", "url": "https://quotes.toscrape.com/login" },
561
+ { "type": "fill", "selector": "#username", "value": "{{vars.user}}" },
562
+ { "type": "fill", "selector": "#password", "value": "{{vars.password}}" },
563
+ { "type": "click", "selector": "input[type=submit]" },
564
+ { "type": "wait", "selector": "a[href='/logout']" }
565
+ ]}}
566
+ ```
567
+
568
+ <!-- capture:recipes-session screenshot alt=Signed_in:_the_Logout_link_and_the_Goodreads_links_the_recipe_reads -->
569
+ ![Signed in: the Logout link and the Goodreads links the recipe reads](../assets/how-it-works/recipes-session.png)
570
+ <!-- /capture -->
571
+
572
+ The bootstrap's steps run first, with paths under `session.bootstrap.steps`. Then the recipe's `request` goes
573
+ out with the session cookie:
574
+
575
+ <!-- capture:recipes-session trace lines=13 -->
576
+ ```text
577
+ ▶ quotes-signed-in (api)
578
+ ⇄ access direct (direct)
579
+ ⇢ page 1 https://quotes.toscrape.com/login
580
+ · session.bootstrap.steps.0 goto … ms
581
+ · session.bootstrap.steps.1 fill … ms
582
+ · session.bootstrap.steps.2 fill … ms
583
+ ⇢ page 1 https://quotes.toscrape.com/
584
+ · session.bootstrap.steps.3 click … ms
585
+ · session.bootstrap.steps.4 wait … ms
586
+ ⇢ page 1 https://quotes.toscrape.com/
587
+ · steps.0 request … ms
588
+ · steps.1 extract menu … ms
589
+ · steps.2 extract quotes … ms
590
+ …
591
+ ```
592
+ <!-- /capture -->
593
+
594
+ <!-- capture:recipes-session records n=3 -->
595
+ ```json
596
+ {"text":"“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”","author":"Albert Einstein","goodreads":"http://goodreads.com/author/show/9810.Albert_Einstein","menu":"Logout"}
597
+ {"text":"“It is our choices, Harry, that show what we truly are, far more than our abilities.”","author":"J.K. Rowling","goodreads":"http://goodreads.com/author/show/1077326.J_K_Rowling","menu":"Logout"}
598
+ {"text":"“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”","author":"Albert Einstein","goodreads":"http://goodreads.com/author/show/9810.Albert_Einstein","menu":"Logout"}
599
+ ```
600
+ <!-- /capture -->
601
+
602
+ The menu reads `Logout` and every quote has its Goodreads link, so the HTTP client was signed in. Without the
603
+ bootstrap, the menu would read `Login` and the `goodreads` extract would find nothing. Three records, because
604
+ `maxRecords` is 3. Some details:
605
+
606
+ - The bootstrap always runs in a browser, even for an api recipe, with the same proxy lease as the crawl, so the
607
+ login and the crawl come from the same address.
608
+ - It sees the recipe's `vars`, but not a start point's own `vars`, and it can't emit records.
609
+ - It runs again whenever the session is reopened: after a rotation to a new proxy, for example.
610
+ - The bootstrap's page visits count in the report's `pages`, the page the login click lands on included: this
611
+ run reports 3.
612
+
613
+ ## Loading, validation and binding
614
+
615
+ Nothing runs until the whole set has loaded. `loadRecipeSet({ output, inputs })` takes the output recipe apart
616
+ from the inputs; `loadRecipes(source)` takes one source holding all of them and finds the output by its `kind`.
617
+ A source can be a file, a directory, JSON or JSON Lines text, bytes, or already-decoded objects. Loading does
618
+ two things, in this order.
619
+
620
+ ### Validation: is each file a recipe?
621
+
622
+ Each document is parsed against its contract. The contracts are strict: an unknown key is an error, so a typo
623
+ never silently does nothing. Every problem is reported at once, each with the JSON path where it is.
624
+
625
+ To show what that looks like, three recipes were broken on purpose and loaded with `loadRecipeSet` from a small
626
+ Node script, passing each recipe as a decoded object. A recipe given as an object is labelled `output` or
627
+ `inputs[N]`; one given as a file is labelled with its path. The first is this page's `book-detail` output with
628
+ `price` typed `money`, the `enum` field's `values` removed, `"required": true` added to the generated
629
+ `scrapedAt`, and a field named `stock.count`:
630
+
631
+ ```text
632
+ RecipeValidationError: output: invalid recipe
633
+ fields.price.type: Invalid option: expected one of "string"|"number"|"integer"|"boolean"|"date"|"datetime"|"currency"|"url"|"enum"|"array"|"object"|"json"
634
+ fields.productType.values: a "enum" field needs "values"
635
+ fields.scrapedAt.required: a generated field is always present; drop "required"
636
+ fields.stock.count: Invalid key in record
637
+ ```
638
+
639
+ The last line is about the field *name*: names can't contain dots, since a dot in a mapping target means "a
640
+ member of an object field".
641
+
642
+ The second is the `quotes-web` input with the loop's `as` deleted, `selector` misspelt `selecter` in the first
643
+ inner `extract`, and `kind` misspelt `cs` in the second:
644
+
645
+ ```text
646
+ RecipeValidationError: inputs[0]: invalid recipe
647
+ steps.2.as: Invalid input: expected string, received undefined
648
+ steps.2.steps.0.selector: Invalid input: expected string, received undefined
649
+ steps.2.steps.0: Unrecognized key: "selecter"
650
+ steps.2.steps.1.kind: Invalid option: expected one of "css"|"xpath"|"jsonpath"|"regex"|"table"
651
+ ```
652
+
653
+ A path is the route to the problem: `steps.2.steps.0` is the first step inside the third top-level step, the same
654
+ numbering the trace uses. When a step type could match several shapes, the errors come from the shape the
655
+ recipe got furthest into, so they describe the step you meant.
656
+
657
+ Point `$schema` at the published JSON Schemas (as every recipe in this guide does), and an editor checks the
658
+ same shapes while you type. Some rules have no JSON Schema form ("an `enum` needs `values`", "give exactly one
659
+ of `selector` or `target`"), so only the loader reports those.
660
+
661
+ ### Binding: do the recipes fit together?
662
+
663
+ Once every file is valid, each input recipe is checked against the output and against itself. The third broken
664
+ recipe is valid on its own: it's the `quotes-api` input with a `goto` added, `author` mapped from an id that
665
+ doesn't exist, a mapping for a field the output doesn't have, and the mapping for the required `text` removed:
666
+
667
+ ```text
668
+ RecipeBindingError: recipes do not bind
669
+ quotes-api steps.1: "goto" needs a browser; this recipe runs in api mode (use session.bootstrap for browser steps)
670
+ quotes-api mapping.author.from: "quote.author.name" does not start with a known id
671
+ quotes-api mapping.language: output "quote" has no field "language"
672
+ quotes-api mapping: required output field "text" is not mapped
673
+ ```
674
+
675
+ Binding checks:
676
+
677
+ - the input's `output` is the output recipe's `id`, and no two inputs share an `id`;
678
+ - every mapping target is a field of the output (a dotted target walks into `object` fields), and none is
679
+ `generated`;
680
+ - every `from` starts with a known id: `page`, `start`, `vars`, a var's name, a step's `id`, a `forEach`'s `as`,
681
+ or a pagination cursor;
682
+ - every `required` field with no `default` and not `generated` is mapped;
683
+ - no id is bound twice on one path, and none is called `page`, `start` or `vars`;
684
+ - the steps fit the mode (the table [above](#web-or-api-the-same-data-read-both-ways)), except in a bootstrap,
685
+ which always runs in a browser;
686
+ - at most one emitting construct on any path: no `emit` inside an emitting `forEach`;
687
+ - `collect.into` is bound before the loop, every `matrix` var is declared in `vars`, a `captcha` step has a
688
+ solver.
689
+
690
+ Some things can only be known once the recipe runs: whether a selector matches, whether a template's path exists
691
+ (a missing one renders as empty: see [part 6](05-templates-and-scope.md#missing-values)), whether a hook name is
692
+ registered, and whether an `emit` naming an `output` names the one being produced. Those surface as step errors,
693
+ under the step's error policy.
694
+
695
+ The capture script of this guide runs the same loading for every scene on each pull request
696
+ (`capture.mjs --check`), so a change to the engine that breaks one of these recipes fails there.
697
+
698
+ Next: [3. Page steps](02-page-steps.md).