@opencraw/mcp 0.1.45 → 0.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -1
- package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
- package/dist/diff-tool/index.d.ts +3 -0
- package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/docs-tool/index.d.ts +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.esm.js +430 -1
- package/dist/list-tool/index.d.ts +3 -0
- package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
- package/dist/main.d.ts +7 -0
- package/dist/probe-tool/index.d.ts +2 -0
- package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
- package/dist/recipe-source/index.d.ts +3 -0
- package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
- package/dist/run-tool/index.d.ts +3 -0
- package/dist/run-tool/run-tool.handler.d.ts +51 -0
- package/dist/server/create-server.use-case.d.ts +11 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/src/docs-tool/index.d.ts +3 -0
- package/dist/src/server/create-server.use-case.d.ts +1 -1
- package/dist/src/studio-tool/index.d.ts +2 -0
- package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/studio-tool/index.d.ts +2 -0
- package/dist/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/validate-tool/index.d.ts +3 -0
- package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
- package/docs/how-it-works/01-recipes.md +698 -0
- package/docs/how-it-works/02-page-steps.md +672 -0
- package/docs/how-it-works/03-data-steps.md +813 -0
- package/docs/how-it-works/04-flow-steps.md +738 -0
- package/docs/how-it-works/05-templates-and-scope.md +428 -0
- package/docs/how-it-works/06-documents.md +1413 -0
- package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
- package/docs/how-it-works/08-policies.md +405 -0
- package/docs/how-it-works/09-running.md +804 -0
- package/docs/how-it-works/README.md +225 -0
- package/docs/recipes/README.md +22 -0
- package/docs/recipes/access.md +440 -0
- package/docs/recipes/authoring.md +1503 -0
- package/docs/recipes/azure-durable.md +180 -0
- package/docs/recipes/captcha.md +204 -0
- package/docs/recipes/movies-example.md +12 -0
- package/docs/recipes/programmatic-authoring.md +101 -0
- package/docs/recipes/quick-guide.md +157 -0
- package/docs/recipes/vehicles-example.md +9 -0
- package/docs/recipes/worker-mode.md +221 -0
- package/docs/studio/manual.md +312 -0
- package/docs/studio/pdf-agreements.md +150 -0
- package/package.json +29 -4
|
@@ -0,0 +1,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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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).
|