@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,699 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Mapping and transforms
6
+
7
+ [← How OpenCraw works](README.md) · Next: [9. Policies](08-policies.md)
8
+
9
+ When a step emits, the engine takes a snapshot of the scope: every id bound so far, plus `page`, `start` and
10
+ `vars`. The input recipe's `mapping` turns that snapshot into a record. Each output field has a rule, and the
11
+ engine runs these four stages for each one:
12
+
13
+ 1. **Read**: `from` picks a value out of the snapshot (or `each` builds a list of objects).
14
+ 2. **Transform**: the rule's `transform` ops run in order, each on the previous one's result.
15
+ 3. **Coerce**: the value is converted to the field's type (`integer`, `currency`, `url`…) and validated.
16
+ 4. **Policy**: a missing value, or one that can't be converted, is settled by the missing-value policy. That
17
+ is the [next page](08-policies.md).
18
+
19
+ This page covers the first two stages, and every transform, with values from real runs. The reference for
20
+ each key and op is [authoring.md §5](../recipes/authoring.md#5-mapping); this page shows what they do to data.
21
+
22
+ ## The mapping trace
23
+
24
+ The tables on this page are not written by hand. With `CrawlOptions.debug`, every `record:emit` event carries
25
+ two extra things: `scope`, the snapshot the mapping read, and `mapping`, one entry per mapped field:
26
+
27
+ ```js
28
+ const crawler = createCrawler({
29
+ debug: true,
30
+ onEvent: (event) => {
31
+ if (event.type === 'record:emit') console.log(event.mapping.price)
32
+ // { from: '£47.82', steps: [{ op: 'currency', value: { amount: 47.82, currency: 'GBP' } }] }
33
+ },
34
+ })
35
+ ```
36
+
37
+ - An entry is keyed by the mapping target: `price`, `stock.inStock` for a dotted target, `tags[1].url` for a
38
+ field of the second item built by `each`.
39
+ - `from` is the value as read, before any transform. `steps` has one `{ op, value }` per transform, holding the
40
+ value after that op.
41
+ - An op that was skipped because the value was missing is still listed, with no value (*(missing)* in the
42
+ tables below).
43
+ - The trace stops before coercion. What the field finally holds can differ from the last step, which is why
44
+ every table here ends with a **field** row taken from the record.
45
+ - Generated fields (`now`, `uuid`, `sourceUrl`, `recipeId`) are not traced, and a `record:reject` event
46
+ carries the snapshot but no mapping trace.
47
+
48
+ Here it is on a book's detail page from [books.toscrape.com](https://books.toscrape.com)
49
+ ([recipe](recipes/map-books-detail/mystery-detail.input.json)). The product table's cells are read into one
50
+ list, `info`, and several fields pick from it:
51
+
52
+ <!-- capture:map-books-detail mapping fields=price,tax record=0 -->
53
+ | Field | Step | Value |
54
+ |---|---|---|
55
+ | `price` | read | `"£47.82"` |
56
+ | | `currency` | `{"amount":47.82,"currency":"GBP"}` |
57
+ | | **field** | `{"amount":47.82,"currency":"GBP"}` |
58
+ | `tax` | read | `["e00eb4fd7b871a48","Books","£47.82","… 4 more"]` |
59
+ | | `nth` | `"£0.00"` |
60
+ | | `regex` | `"0.00"` |
61
+ | | `currency` | `{"amount":0}` |
62
+ | | **field** | `{"amount":0,"currency":"GBP"}` |
63
+ <!-- /capture -->
64
+
65
+ `tax` shows the gap between the last step and the field. The text `£0.00` goes through `regex`, which keeps
66
+ `0.00` without the pound sign, so `currency` can't find a currency and returns `{ "amount": 0 }`. The field
67
+ declares `"currency": "GBP"`, and coercion adds it. The trace shows the value before that, and the record
68
+ shows it after.
69
+
70
+ ## `from`: where a rule reads
71
+
72
+ `from` is a path into the snapshot. It can start with any id bound by a step (`title`, `info`), a `forEach`
73
+ variable (`quote`), `page` (`{ url, number }`), `start` or `vars`. Dots walk into objects and `[n]` into lists
74
+ (`quote.author.name`, `abilities[0].ability.name`). A path that doesn't exist gives a missing value; it never
75
+ throws.
76
+
77
+ `.` is the whole snapshot at the top level, and the item itself inside `each` (below).
78
+
79
+ **Several sources.** `from` can be a list of paths. The rule then reads a **list** of their values in that
80
+ order, keeping missing ones as missing, and the transforms decide what to do with it: `concat`, `coalesce`,
81
+ `flatten`, `join`. The quotes API of [quotes.toscrape.com](https://quotes.toscrape.com/api/quotes?page=1)
82
+ ([recipe](recipes/map-quotes-api/quotes-api.input.json)) gives the text and the author separately, and
83
+ `citation` puts them together:
84
+
85
+ <!-- capture:map-quotes-api mapping fields=citation record=6 -->
86
+ | Field | Step | Value |
87
+ |---|---|---|
88
+ | `citation` | read | `["“It is better to be hated for what you are than to be loved for what you are not.”","André Gide"]` |
89
+ | | `replace` | `["It is better to be hated for what you are than to be loved for what you are not.","André Gide"]` |
90
+ | | `concat` | `"It is better to be hated for what you are than to be loved for what you are not. — André Gide"` |
91
+ | | **field** | `"It is better to be hated for what you are than to be loved for what you are not. — André Gide"` |
92
+ <!-- /capture -->
93
+
94
+ `replace` ran on both items: it is elementwise (see [How a chain runs](#how-a-chain-runs)). The author's name
95
+ has no curly quotes, so it is left as it is.
96
+
97
+ ## Dotted targets: building objects
98
+
99
+ A mapping key with dots writes into an object. `stock.inStock` and `stock.available` build `stock`, which the
100
+ output recipe declares as an `object` with those two `fields`:
101
+
102
+ ```json
103
+ "stock.inStock": { "from": "availability", "transform": [{ "op": "boolean" }] },
104
+ "stock.available": { "from": "info", "transform": [{ "op": "nth", "index": -2 }, { "op": "regex", "pattern": "(\\d+) available" }, { "op": "integer" }] }
105
+ ```
106
+
107
+ <!-- capture:map-books-detail record record=0 -->
108
+ ```json
109
+ {
110
+ "url": "https://books.toscrape.com/catalogue/sharp-objects_997/index.html",
111
+ "title": "Sharp Objects",
112
+ "slug": "sharp-objects",
113
+ "upc": "E00EB4FD7B871A48",
114
+ "category": "Mystery",
115
+ "path": "Books / Mystery",
116
+ "price": {
117
+ "amount": 47.82,
118
+ "currency": "GBP"
119
+ },
120
+ "tax": {
121
+ "amount": 0,
122
+ "currency": "GBP"
123
+ },
124
+ "stock": {
125
+ "inStock": true,
126
+ "available": 20
127
+ },
128
+ "stars": 4,
129
+ "reviews": 0,
130
+ "hasReviews": false,
131
+ "image": "https://books.toscrape.com/media/cache/c0/59/c05972805aa7201171b8fc71a5b00292.jpg"
132
+ }
133
+ ```
134
+ <!-- /capture -->
135
+
136
+ Each member is coerced, validated and given the missing-value policy on its own, under its dotted path, so an
137
+ error names `stock.available`. A member the output doesn't declare is dropped. The quotes recipe does the same
138
+ for `author.name`, `author.slug` and `author.url`.
139
+
140
+ ## `each`: a list of objects
141
+
142
+ `each` names a list in the snapshot and builds one object per item, with its own `fields` rules. Inside them
143
+ `from` is relative to the item, and `.` is the item itself, which is the only way to read an item that is a
144
+ plain string. A quote's `tags` is such a list:
145
+
146
+ ```json
147
+ "tags": { "each": "quote.tags", "fields": {
148
+ "name": { "from": "." },
149
+ "url": { "from": ".", "transform": [{ "op": "absoluteUrl", "base": "https://quotes.toscrape.com/tag/" }] }
150
+ }}
151
+ ```
152
+
153
+ The trace has one entry per item and field, keyed with the item's index:
154
+
155
+ <!-- capture:map-quotes-api mapping fields=tags[0].name,tags[0].url,tags[1].name,tags[1].url record=6 -->
156
+ | Field | Step | Value |
157
+ |---|---|---|
158
+ | `tags[0].name` | read | `"life"` |
159
+ | | **field** | `"life"` |
160
+ | `tags[0].url` | read | `"life"` |
161
+ | | `absoluteUrl` | `"https://quotes.toscrape.com/tag/life"` |
162
+ | | **field** | `"https://quotes.toscrape.com/tag/life"` |
163
+ | `tags[1].name` | read | `"love"` |
164
+ | | **field** | `"love"` |
165
+ | `tags[1].url` | read | `"love"` |
166
+ | | `absoluteUrl` | `"https://quotes.toscrape.com/tag/love"` |
167
+ | | **field** | `"https://quotes.toscrape.com/tag/love"` |
168
+ <!-- /capture -->
169
+
170
+ - A `lookup` table or a `template` path inside `fields` is looked up in the item first, then in the record's
171
+ snapshot, so a table extracted once per page stays reachable from every item.
172
+ - A `null` or missing list gives a missing field. Something that isn't a list fails the mapping
173
+ (`"quote.tags" is not a list`).
174
+ - The `each` rule itself has no trace entry, only its items' fields.
175
+ - Each member of an item is coerced, validated and given its missing-value policy and `default`, like a field,
176
+ and reported at its index (`tags[1].url`): see [Policies](08-policies.md#the-items-of-each). The loader checks
177
+ that every name in `fields` is a member of the items, and that every required member is mapped.
178
+
179
+ ## Joins
180
+
181
+ `lookup` joins two extractions at mapping time: it finds the value in a table that another step bound and
182
+ returns a column of the matching row. The book's rating is a class name (`star-rating Four`); the recipe binds
183
+ a table of words and numbers once, with `set`, and `stars` looks the word up in it:
184
+
185
+ <!-- capture:map-books-detail mapping fields=stars record=0 -->
186
+ | Field | Step | Value |
187
+ |---|---|---|
188
+ | `stars` | read | `"star-rating Four"` |
189
+ | | `regex` | `"Four"` |
190
+ | | `lookup` | `4` |
191
+ | | **field** | `4` |
192
+ <!-- /capture -->
193
+
194
+ The keys are compared as text, so `"4"` finds `4`. The table can be data, JSON text or a list of JSON texts,
195
+ and it can come from anywhere in the snapshot. The quotes API sends the site's ten most used tags as pairs
196
+ (`["love", 14]`), and `tagPopularity` looks up each of a quote's tags in them, reading the pairs' `0` and `1`
197
+ as paths:
198
+
199
+ ```json
200
+ "tagPopularity": { "from": "quote.tags", "transform": [{ "op": "lookup", "in": "topTags", "key": "0", "pick": "1" }] }
201
+ ```
202
+
203
+ <!-- capture:map-quotes-api mapping fields=tagPopularity record=6 -->
204
+ | Field | Step | Value |
205
+ |---|---|---|
206
+ | `tagPopularity` | read | `["life","love"]` |
207
+ | | `lookup` | `[13,14]` |
208
+ | | **field** | `[13,14]` |
209
+ <!-- /capture -->
210
+
211
+ <!-- capture:map-quotes-api mapping fields=tagPopularity record=0 -->
212
+ | Field | Step | Value |
213
+ |---|---|---|
214
+ | `tagPopularity` | read | `["change","deep-thoughts","thinking","world"]` |
215
+ | | `lookup` | `[null,null,null,null]` |
216
+ | | **field** | `[null,null,null,null]` |
217
+ <!-- /capture -->
218
+
219
+ `lookup` is elementwise, so a list of tags gives a list of counts. A tag that isn't in the top ten gives a
220
+ missing value in its place, which shows as `null` in JSON. More on joins, including a table of JSON texts read
221
+ from `data-*` attributes, in [authoring.md §5.2](../recipes/authoring.md#52-joining-two-extractions).
222
+
223
+ ## How a chain runs
224
+
225
+ The ops run in order, each on the previous result. Two rules decide what an op receives.
226
+
227
+ **Elementwise or whole.** A *scalar* op given a list runs on each item and returns a list; a *list* op runs on
228
+ the list as a whole.
229
+
230
+ | Kind | Ops |
231
+ |---|---|
232
+ | scalar (elementwise over a list) | `trim` `lowercase` `uppercase` `replace` `regex` `split` `number` `integer` `boolean` `currency` `date` `absoluteUrl` `urlEncode` `lookup` |
233
+ | list (the whole value) | `join` `concat` `first` `last` `nth` `slice` `coalesce` `default` `flatten` `unique` `sum` `count` `template` `jsonpath` `group` `hook` |
234
+
235
+ Elementwise goes one level deep: `split` on a list gives a list of lists. The items themselves aren't checked
236
+ for missing values, so `trim` on `["a ", null]` fails with `transform "trim": expects text, got null`.
237
+
238
+ **Missing values.** When the value so far is missing (`undefined` or `null`), every op is skipped except
239
+ `default`, `template` and `hook`. A missing value therefore travels to the end of the chain untouched and
240
+ reaches the missing-value policy. An empty string is not skipped: `trim` on `""` gives `""`.
241
+
242
+ `numberInText` looks for a number in each quote, with `regex` and then `number`. On most quotes there is none:
243
+
244
+ <!-- capture:map-quotes-api mapping fields=numberInText record=0 -->
245
+ | Field | Step | Value |
246
+ |---|---|---|
247
+ | `numberInText` | read | `"“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"` |
248
+ | | `regex` | *(missing)* |
249
+ | | `number` | *(missing)* |
250
+ | | **field** | `null` |
251
+ <!-- /capture -->
252
+
253
+ <!-- capture:map-quotes-api mapping fields=numberInText record=7 -->
254
+ | Field | Step | Value |
255
+ |---|---|---|
256
+ | `numberInText` | read | `"“I have not failed. I've just found 10,000 ways that won't work.”"` |
257
+ | | `regex` | `"10,000"` |
258
+ | | `number` | `10000` |
259
+ | | **field** | `10000` |
260
+ <!-- /capture -->
261
+
262
+ On Einstein's quote, `regex` finds no match and returns a missing value, `number` is skipped, and the field is
263
+ not required, so the built-in policy makes it `null`. On Edison's, `regex` returns group 1, `10,000`, and
264
+ `number` reads it as `10000` (the comma is followed by three digits, so it is a thousands separator).
265
+
266
+ ## Text
267
+
268
+ | Op | Does |
269
+ |---|---|
270
+ | `trim`, `lowercase`, `uppercase` | What the name says. Accept text, numbers and booleans; anything else fails. |
271
+ | `regex` | The first match. Returns group 1 when the pattern has a group, else the whole match; `group` picks another. `flags` apply, except `g`, which is removed. **No match gives a missing value**, not an error; a `group` the pattern doesn't have is an error. |
272
+ | `replace` | Every match replaced (flags `g` by default). `$1` to `$9` and `$&` in `replacement` are expanded. Setting `flags` replaces the default, so `"flags": "i"` replaces only the first match: write `"gi"`. |
273
+ | `split` | Text to a list, on a literal separator. |
274
+ | `join`, `concat` | A list to text. Missing items become empty text, numbers and booleans are written out, objects fail. `concat` is `join` with no separator by default. |
275
+ | `template` | Renders a `{{ }}` template against the snapshot. **It ignores its input**; a template that is exactly one placeholder returns the value with its type. |
276
+ | `urlEncode` | Percent-encodes one URL component (`encodeURIComponent`). |
277
+ | `absoluteUrl` | Resolves a link against `base`, or else the page the record was emitted on. |
278
+
279
+ **`trim`.** Text taken with `take: "text"` has its whitespace collapsed already; `trim` matters for inner HTML,
280
+ JSON and regex results. The hockey table of [scrapethissite.com](https://www.scrapethissite.com/pages/forms/)
281
+ ([recipe](recipes/map-hockey-numbers/boston-seasons.input.json)) is read with `take: "html"`:
282
+
283
+ <!-- capture:map-hockey-numbers mapping fields=team record=0 -->
284
+ | Field | Step | Value |
285
+ |---|---|---|
286
+ | `team` | read | `"\n Boston Bruins\n "` |
287
+ | | `trim` | `"Boston Bruins"` |
288
+ | | **field** | `"Boston Bruins"` |
289
+ <!-- /capture -->
290
+
291
+ **`lowercase`, `replace`, `uppercase`.** A slug from the book's title, and the UPC from the product table's
292
+ first cell:
293
+
294
+ <!-- capture:map-books-detail mapping fields=slug,upc record=1 -->
295
+ | Field | Step | Value |
296
+ |---|---|---|
297
+ | `slug` | read | `"In a Dark, Dark Wood"` |
298
+ | | `lowercase` | `"in a dark, dark wood"` |
299
+ | | `replace` | `"in-a-dark-dark-wood"` |
300
+ | | **field** | `"in-a-dark-dark-wood"` |
301
+ | `upc` | read | `["19ed25f4641d5efd","Books","£19.63","… 4 more"]` |
302
+ | | `first` | `"19ed25f4641d5efd"` |
303
+ | | `uppercase` | `"19ED25F4641D5EFD"` |
304
+ | | **field** | `"19ED25F4641D5EFD"` |
305
+ <!-- /capture -->
306
+
307
+ **`regex`.** The number of copies inside "In stock (20 available)", then `integer`:
308
+
309
+ <!-- capture:map-books-detail mapping fields=stock.available record=0 -->
310
+ | Field | Step | Value |
311
+ |---|---|---|
312
+ | `stock.available` | read | `["e00eb4fd7b871a48","Books","£47.82","… 4 more"]` |
313
+ | | `nth` | `"In stock (20 available)"` |
314
+ | | `regex` | `"20"` |
315
+ | | `integer` | `20` |
316
+ | | **field** | `20` |
317
+ <!-- /capture -->
318
+
319
+ **`replace`, `split`.** An author's page on quotes.toscrape.com gives the birthplace as "in Yate, South
320
+ Gloucestershire, England, The United Kingdom" ([recipe](recipes/map-quotes-authors/quote-authors.input.json)).
321
+ `replace` drops the leading "in "; `split` and `last` keep the country:
322
+
323
+ <!-- capture:map-quotes-authors mapping fields=birthplace,country record=1 -->
324
+ | Field | Step | Value |
325
+ |---|---|---|
326
+ | `birthplace` | read | `"in Yate, South Gloucestershire, England, The United Kingdom"` |
327
+ | | `replace` | `"Yate, South Gloucestershire, England, The United Kingdom"` |
328
+ | | **field** | `"Yate, South Gloucestershire, England, The United Kingdom"` |
329
+ | `country` | read | `"in Yate, South Gloucestershire, England, The United Kingdom"` |
330
+ | | `split` | `["in Yate","South Gloucestershire","England","The United Kingdom"]` |
331
+ | | `last` | `"The United Kingdom"` |
332
+ | | **field** | `"The United Kingdom"` |
333
+ <!-- /capture -->
334
+
335
+ **`join`, `urlEncode`, `template`.** André Gide's quote: its tags joined, his name encoded for a query string,
336
+ and an Open Library search URL built by `template`, which reads the name from the snapshot and ignores the
337
+ value it's given (`urlEncode` inside the braces is the template function, not the op):
338
+
339
+ ```json
340
+ "openLibrary": { "from": "quote.author.name", "transform": [{ "op": "template", "value": "https://openlibrary.org/search.json?author={{ urlEncode(quote.author.name) }}" }] }
341
+ ```
342
+
343
+ <!-- capture:map-quotes-api mapping fields=tagList,authorQuery,openLibrary record=6 -->
344
+ | Field | Step | Value |
345
+ |---|---|---|
346
+ | `tagList` | read | `["life","love"]` |
347
+ | | `join` | `"life, love"` |
348
+ | | **field** | `"life, love"` |
349
+ | `authorQuery` | read | `"André Gide"` |
350
+ | | `urlEncode` | `"Andr%C3%A9%20Gide"` |
351
+ | | **field** | `"Andr%C3%A9%20Gide"` |
352
+ | `openLibrary` | read | `"André Gide"` |
353
+ | | `template` | `"https://openlibrary.org/search.json?author=Andr%C3%A9%20Gide"` |
354
+ | | **field** | `"https://openlibrary.org/search.json?author=Andr%C3%A9%20Gide"` |
355
+ <!-- /capture -->
356
+
357
+ **`absoluteUrl`.** Without `base`, a link resolves against the page the record came from, here the book's
358
+ page. The quotes API gives Goodreads paths, which need `base`:
359
+
360
+ <!-- capture:map-books-detail mapping fields=image record=0 -->
361
+ | Field | Step | Value |
362
+ |---|---|---|
363
+ | `image` | read | `"../../media/cache/c0/59/c05972805aa7201171b8fc71a5b00292.jpg"` |
364
+ | | `absoluteUrl` | `"https://books.toscrape.com/media/cache/c0/59/c05972805aa7201171b8fc71a5b00292.jpg"` |
365
+ | | **field** | `"https://books.toscrape.com/media/cache/c0/59/c05972805aa7201171b8fc71a5b00292.jpg"` |
366
+ <!-- /capture -->
367
+
368
+ <!-- capture:map-quotes-api mapping fields=author.url record=0 -->
369
+ | Field | Step | Value |
370
+ |---|---|---|
371
+ | `author.url` | read | `"/author/show/9810.Albert_Einstein"` |
372
+ | | `absoluteUrl` | `"https://www.goodreads.com/author/show/9810.Albert_Einstein"` |
373
+ | | **field** | `"https://www.goodreads.com/author/show/9810.Albert_Einstein"` |
374
+ <!-- /capture -->
375
+
376
+ ## Numbers and dates
377
+
378
+ | Op | Does |
379
+ |---|---|
380
+ | `number` | Reads the one number in a text; a number passes through. No number, or two or more, is an error. |
381
+ | `integer` | `number`, then drops the fraction (`12.9` → `12`). |
382
+ | `currency` | `{ amount, currency }`: the amount as `number` reads it, and the code from the `currency` arg, else an ISO 4217 code written next to the amount (`USD 12.50`), else a symbol (`€ £ ¥ ₹ ₩ R$ US$ CA$ A$ $`), else an ISO 4217 code anywhere in the text. No code found gives `{ amount }` only. |
383
+ | `date` | A date object. With `format`, numeric text in the tokens `YYYY MM DD HH mm ss`; without, text as JavaScript's `Date` reads it (ISO is the safe choice). Text without a zone is read as UTC, or in `timezone` when given. |
384
+ | `boolean` | `true` or `false`, never an error. |
385
+
386
+ **`number` reads one number.** It finds the number in the text, with its sign, and ignores what's around it:
387
+ that is why `£47.82`, `Price: 1.299,00 €` or `In stock (20 available)` work. A text with no number, or with
388
+ two or more, is an error that names them, rather than a guess. An author's birth date holds two, and the
389
+ scene's `authors-as-number` recipe maps it with `number` alone
390
+ ([recipe](recipes/map-quotes-authors/authors-as-number.input.json)). The rule has no `skip-record`, so the
391
+ transform failure stops the recipe at its first author:
392
+
393
+ <!-- capture:map-quotes-authors summary -->
394
+ ```text
395
+ authors-as-number: 0 emitted, 0 rejected, 0 duplicates, 2 pages, stopped: step steps.2 (forEach) failed: mapping failed: bornAsNumber: transform "number": "March 14, 1879" holds 2 numbers (14, 1879): pick one with a "regex" transform first
396
+ quote-authors: 3 emitted, 0 rejected, 1 duplicates, 5 pages
397
+ ```
398
+ <!-- /capture -->
399
+
400
+ (`quote-authors` is the scene's main recipe; its duplicate is Albert Einstein, who wrote two of the first
401
+ quotes.) The fix is the one the message names: cut out the number you want with `regex`, then read it:
402
+
403
+ <!-- capture:map-quotes-authors mapping fields=bornYear record=0 -->
404
+ | Field | Step | Value |
405
+ |---|---|---|
406
+ | `bornYear` | read | `"March 14, 1879"` |
407
+ | | `regex` | `"1879"` |
408
+ | | `number` | `1879` |
409
+ | | **field** | `1879` |
410
+ <!-- /capture -->
411
+
412
+ **Without a `locale`, `number` guesses the separators.** With both `.` and `,` in the number, the last one is
413
+ the decimal separator (`1.299,50` is `1299.5`). A single one is a thousands separator only when exactly three
414
+ digits follow it and one to three digits other than a lone `0` precede it, so `1.299` is `1299`, but `0.607`
415
+ and `10,00` are decimals. A separator repeated in groups of three (`1,234,567`) groups thousands, and one that
416
+ makes no sense (`1.2.3`) is an error. The hockey table's win percentage has two or three decimals, and reads the
417
+ same with or without `"locale": "en-US"`:
418
+
419
+ <!-- capture:map-hockey-numbers mapping fields=winPct,winPctEnglish record=0 -->
420
+ | Field | Step | Value |
421
+ |---|---|---|
422
+ | `winPct` | read | `"0.55"` |
423
+ | | `number` | `0.55` |
424
+ | | **field** | `0.55` |
425
+ | `winPctEnglish` | read | `"0.55"` |
426
+ | | `number` | `0.55` |
427
+ | | **field** | `0.55` |
428
+ <!-- /capture -->
429
+
430
+ <!-- capture:map-hockey-numbers mapping fields=winPct,winPctEnglish record=2 -->
431
+ | Field | Step | Value |
432
+ |---|---|---|
433
+ | `winPct` | read | `"0.607"` |
434
+ | | `number` | `0.607` |
435
+ | | **field** | `0.607` |
436
+ | `winPctEnglish` | read | `"0.607"` |
437
+ | | `number` | `0.607` |
438
+ | | **field** | `0.607` |
439
+ <!-- /capture -->
440
+
441
+ `0.607` has three digits after the point, which could be a group of thousands, but a lone `0` before it can't
442
+ start one. Give `locale` when a site writes numbers in a known format: it decides what the guess can't know,
443
+ that `1.299` is `1299` on a German page (`"locale": "de-DE"`) and `1.299` on an English one (`"en-US"`).
444
+
445
+ **`integer`.** The same parsing, so the same traps, then truncated. A signed goal difference and a year:
446
+
447
+ <!-- capture:map-hockey-numbers mapping fields=year,goalDiff record=1 -->
448
+ | Field | Step | Value |
449
+ |---|---|---|
450
+ | `year` | read | `"1991"` |
451
+ | | `integer` | `1991` |
452
+ | | **field** | `1991` |
453
+ | `goalDiff` | read | `"-5"` |
454
+ | | `integer` | `-5` |
455
+ | | **field** | `-5` |
456
+ <!-- /capture -->
457
+
458
+ **`currency`.** With a symbol, the code comes from the text. Without one, it comes from the field at
459
+ coercion, as shown for `tax` [above](#the-mapping-trace):
460
+
461
+ <!-- capture:map-books-detail mapping fields=price record=0 -->
462
+ | Field | Step | Value |
463
+ |---|---|---|
464
+ | `price` | read | `"£47.82"` |
465
+ | | `currency` | `{"amount":47.82,"currency":"GBP"}` |
466
+ | | **field** | `{"amount":47.82,"currency":"GBP"}` |
467
+ <!-- /capture -->
468
+
469
+ Two things to know: the field's `currency` **overrides** a code found in the text (there is no conversion),
470
+ and only an ISO 4217 code counts as one, so the capitals in `OTR £34,000` are ignored and the price reads as
471
+ `GBP`.
472
+
473
+ **`date`.** `format` knows only numeric tokens, and the site writes "March 14, 1879". The recipe calls a hook
474
+ first (`hook`, [below](#structure)) that rewrites it as `1879-03-14`, then `date`:
475
+
476
+ <!-- capture:map-quotes-authors mapping fields=born record=0 -->
477
+ | Field | Step | Value |
478
+ |---|---|---|
479
+ | `born` | read | `"March 14, 1879"` |
480
+ | | `hook` | `"1879-03-14"` |
481
+ | | `date` | `"1879-03-14T00:00:00.000Z"` |
482
+ | | **field** | `"1879-03-14"` |
483
+ <!-- /capture -->
484
+
485
+ The `date` op returns a date object, shown in the trace as an instant. The field is of type `date`, so coercion
486
+ writes the day; a `datetime` field would keep the instant, and a `string` field refuses a date object. Text
487
+ that names no zone (no `Z`, offset or `GMT`) is read as UTC, whatever the time zone of the machine running the
488
+ crawl; give `timezone` when the site writes local times.
489
+
490
+ **`boolean`.** Without `truthy`, the text is `true` when it is exactly `true`, `yes`, `y`, `1` or `on`, or
491
+ contains the words `in stock` or `available`; everything else is `false`, including `2`, `none` and
492
+ `unavailable`. With `truthy`, it is `true` when the text contains any of the phrases, as a substring, in any
493
+ case. A missing value skips the op, like any other, and stays missing: it doesn't become `false`.
494
+
495
+ <!-- capture:map-books-detail mapping fields=stock.inStock,hasReviews record=0 -->
496
+ | Field | Step | Value |
497
+ |---|---|---|
498
+ | `stock.inStock` | read | `"In stock (20 available)"` |
499
+ | | `boolean` | `true` |
500
+ | | **field** | `true` |
501
+ | `hasReviews` | read | `["e00eb4fd7b871a48","Books","£47.82","… 4 more"]` |
502
+ | | `last` | `"0"` |
503
+ | | `boolean` | `false` |
504
+ | | **field** | `false` |
505
+ <!-- /capture -->
506
+
507
+ <!-- capture:map-hockey-numbers mapping fields=winning record=0 -->
508
+ | Field | Step | Value |
509
+ |---|---|---|
510
+ | `winning` | read | `"pct text-success"` |
511
+ | | `boolean` | `true` |
512
+ | | **field** | `true` |
513
+ <!-- /capture -->
514
+
515
+ `hasReviews` reads the review count `0` as `false`. That works for zero and one; for two reviews it would be
516
+ `false` too. When the count matters, map it as an `integer`, as `reviews` does. The hockey table marks
517
+ winning seasons with a CSS class, and `truthy` matches it as a substring of `pct text-success`.
518
+
519
+ ## Lists
520
+
521
+ | Op | Does |
522
+ |---|---|
523
+ | `first`, `last` | One item; `last` of `[]` is missing. |
524
+ | `nth` | `index` counts from 0; a negative one counts from the end (`-1` is the last). |
525
+ | `slice` | `start` and an optional `end`, as JavaScript's `slice` (negative counts from the end). |
526
+ | `flatten` | Nested lists into one, at any depth. |
527
+ | `unique` | Keeps the first of each value. Text is compared as text, anything else as its JSON, so `"1"` and `1` are the same. |
528
+ | `count` | The list's length. |
529
+ | `sum` | Adds numbers; any other item is an error, so convert text with `number` first. |
530
+ | `group` | `[{ key, items }]` by a path in each item, in first-seen order; items without the path go under `null`. |
531
+
532
+ `first`, `join`, `count` and the other ops that expect a list fail on a single value that isn't one
533
+ (`transform "first": expects a list, got string`).
534
+
535
+ **`first`, `last`, `nth`, `slice`.** The book's product table and breadcrumb, read as two lists:
536
+
537
+ <!-- capture:map-books-detail mapping fields=upc,reviews,category,path record=0 -->
538
+ | Field | Step | Value |
539
+ |---|---|---|
540
+ | `upc` | read | `["e00eb4fd7b871a48","Books","£47.82","… 4 more"]` |
541
+ | | `first` | `"e00eb4fd7b871a48"` |
542
+ | | `uppercase` | `"E00EB4FD7B871A48"` |
543
+ | | **field** | `"E00EB4FD7B871A48"` |
544
+ | `reviews` | read | `["e00eb4fd7b871a48","Books","£47.82","… 4 more"]` |
545
+ | | `last` | `"0"` |
546
+ | | `integer` | `0` |
547
+ | | **field** | `0` |
548
+ | `category` | read | `["Home","Books","Mystery","Sharp Objects"]` |
549
+ | | `nth` | `"Mystery"` |
550
+ | | **field** | `"Mystery"` |
551
+ | `path` | read | `["Home","Books","Mystery","Sharp Objects"]` |
552
+ | | `slice` | `["Books","Mystery"]` |
553
+ | | `join` | `"Books / Mystery"` |
554
+ | | **field** | `"Books / Mystery"` |
555
+ <!-- /capture -->
556
+
557
+ The cells are `UPC`, product type, two prices, tax, availability and reviews: `nth 4` is the tax, `nth -2` the
558
+ availability, `last` the reviews. `slice 1 -1` drops "Home" and the book's own title from the breadcrumb.
559
+
560
+ [PokeAPI](https://pokeapi.co) gives one JSON document per Pokémon
561
+ ([recipe](recipes/map-pokeapi/pokeapi.input.json)). The recipe extracts lists and objects from it, and the
562
+ mapping works on them.
563
+
564
+ **`flatten`.** Two sources give a list of two lists; `flatten` makes one:
565
+
566
+ <!-- capture:map-pokeapi mapping fields=keywords record=0 -->
567
+ | Field | Step | Value |
568
+ |---|---|---|
569
+ | `keywords` | read | `[["grass","poison"],["overgrow","chlorophyll"]]` |
570
+ | | `flatten` | `["grass","poison","overgrow","chlorophyll"]` |
571
+ | | **field** | `["grass","poison","overgrow","chlorophyll"]` |
572
+ <!-- /capture -->
573
+
574
+ **`unique`, `count`.** `moveMethods` holds how each of the Pokémon's moves is learnt, one entry per move:
575
+
576
+ <!-- capture:map-pokeapi mapping fields=learnMethods,moveCount record=0 -->
577
+ | Field | Step | Value |
578
+ |---|---|---|
579
+ | `learnMethods` | read | `["egg","machine","machine","… 83 more"]` |
580
+ | | `unique` | `["egg","machine","tutor","level-up"]` |
581
+ | | **field** | `["egg","machine","tutor","level-up"]` |
582
+ | `moveCount` | read | `["egg","machine","machine","… 83 more"]` |
583
+ | | `count` | `86` |
584
+ | | **field** | `86` |
585
+ <!-- /capture -->
586
+
587
+ **`jsonpath`, `sum`.** `stats` is a list of objects; `jsonpath` takes the numbers and `sum` adds them:
588
+
589
+ <!-- capture:map-pokeapi mapping fields=statTotal record=0 -->
590
+ | Field | Step | Value |
591
+ |---|---|---|
592
+ | `statTotal` | read | `[{"base_stat":45,"effort":0,"stat":{"name":"hp","url":"https://pokeapi.co/api/v2/stat/1/"}},{"base_stat":49,"effort":0,"stat":{"name":"attack","url":"https://pokeapi.co/api/v2/stat/2/"}},{"base_stat":49,"effort":0,"s…` |
593
+ | | `jsonpath` | `[45,49,49,"… 3 more"]` |
594
+ | | `sum` | `318` |
595
+ | | **field** | `318` |
596
+ <!-- /capture -->
597
+
598
+ **`group`.** The abilities grouped by whether they are hidden:
599
+
600
+ <!-- capture:map-pokeapi mapping fields=abilitiesByHidden record=0 -->
601
+ | Field | Step | Value |
602
+ |---|---|---|
603
+ | `abilitiesByHidden` | read | `[{"is_hidden":false,"slot":1,"ability":{"name":"overgrow","url":"https://pokeapi.co/api/v2/ability/65/"}},{"is_hidden":true,"slot":3,"ability":{"name":"chlorophyll","url":"https://pokeapi.co/api/v2/ability/34/"}}]` |
604
+ | | `group` | `[{"key":false,"items":[{"is_hidden":false,"slot":1,"ability":"{…}"}]},{"key":true,"items":[{"is_hidden":true,"slot":3,"ability":"{…}"}]}]` |
605
+ | | **field** | `[{"key":false,"items":[{"is_hidden":false,"slot":1,"ability":"{…}"}]},{"key":true,"items":[{"is_hidden":true,"slot":3,"ability":"{…}"}]}]` |
606
+ <!-- /capture -->
607
+
608
+ The result goes into a `json` field here. It can also go into an `array` of `object` with `key` and `items`
609
+ members, which coercion fills item by item. `each` can't walk it: `each` reads a list from the snapshot, never
610
+ the output of a transform.
611
+
612
+ ## Structure
613
+
614
+ | Op | Does |
615
+ |---|---|
616
+ | `jsonpath` | Runs a JSONPath on the value and always returns a list. It doesn't parse JSON text: extract with `kind: "jsonpath"` for that. |
617
+ | `lookup` | See [Joins](#joins). No match, or a table that isn't there, gives a missing value. |
618
+ | `coalesce` | The first item of a list that isn't missing or blank text. |
619
+ | `default` | Replaces `undefined`, `null` or `""` with `value`. An empty list is a value, and is kept. Runs on missing values. |
620
+ | `hook` | Calls a function registered with `createCrawler({ hooks })` with the value so far. Runs on missing values; whatever it throws is a transform failure. |
621
+
622
+ **`jsonpath`, `first`.** The hidden ability, found with a filter:
623
+
624
+ <!-- capture:map-pokeapi mapping fields=types,hiddenAbility record=1 -->
625
+ | Field | Step | Value |
626
+ |---|---|---|
627
+ | `types` | read | `[{"slot":1,"type":{"name":"electric","url":"https://pokeapi.co/api/v2/type/13/"}}]` |
628
+ | | `jsonpath` | `["electric"]` |
629
+ | | `join` | `"electric"` |
630
+ | | **field** | `"electric"` |
631
+ | `hiddenAbility` | read | `[{"is_hidden":false,"slot":1,"ability":{"name":"static","url":"https://pokeapi.co/api/v2/ability/9/"}},{"is_hidden":true,"slot":3,"ability":{"name":"lightning-rod","url":"https://pokeapi.co/api/v2/ability/31/"}}]` |
632
+ | | `jsonpath` | `["lightning-rod"]` |
633
+ | | `first` | `"lightning-rod"` |
634
+ | | **field** | `"lightning-rod"` |
635
+ <!-- /capture -->
636
+
637
+ **`coalesce`.** The female sprite when there is one, else the default one. Bulbasaur has no female sprite
638
+ (`null`), Pikachu has:
639
+
640
+ <!-- capture:map-pokeapi mapping fields=sprite record=0 -->
641
+ | Field | Step | Value |
642
+ |---|---|---|
643
+ | `sprite` | read | `[null,"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"]` |
644
+ | | `coalesce` | `"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"` |
645
+ | | **field** | `"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"` |
646
+ <!-- /capture -->
647
+
648
+ <!-- capture:map-pokeapi mapping fields=sprite record=1 -->
649
+ | Field | Step | Value |
650
+ |---|---|---|
651
+ | `sprite` | read | `["https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/female/25.png","https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png"]` |
652
+ | | `coalesce` | `"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/female/25.png"` |
653
+ | | **field** | `"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/female/25.png"` |
654
+ <!-- /capture -->
655
+
656
+ **`default`.** Bulbasaur holds no item. `first` of an empty list is missing, and `default` fills it; applied to
657
+ the empty list itself, `default` keeps it, because an empty list is not missing:
658
+
659
+ <!-- capture:map-pokeapi mapping fields=heldItem,heldItems record=0 -->
660
+ | Field | Step | Value |
661
+ |---|---|---|
662
+ | `heldItem` | read | `[]` |
663
+ | | `first` | *(missing)* |
664
+ | | `default` | `"none"` |
665
+ | | **field** | `"none"` |
666
+ | `heldItems` | read | `[]` |
667
+ | | `default` | `[]` |
668
+ | | **field** | `[]` |
669
+ <!-- /capture -->
670
+
671
+ **`hook`.** Any function, sync or async, registered when the crawler is created. The authors scene registers
672
+ `monthDayYear`, which turns "March 14, 1879" into `1879-03-14` (the scene's version, in
673
+ [`capture/scenes/07-mapping.mjs`](capture/scenes/07-mapping.mjs), also throws on text it can't read):
674
+
675
+ ```js
676
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
677
+
678
+ createCrawler({ hooks: {
679
+ monthDayYear: (input) => {
680
+ const [, month, day, year] = /^(\w+) (\d{1,2}), (\d{4})$/.exec(input)
681
+
682
+ return `${year}-${String(MONTHS.indexOf(month) + 1).padStart(2, '0')}-${day.padStart(2, '0')}`
683
+ },
684
+ } })
685
+ ```
686
+
687
+ ```json
688
+ "born": { "from": "bornDate", "transform": [{ "op": "hook", "name": "monthDayYear" }, { "op": "date" }] }
689
+ ```
690
+
691
+ A hook receives `(value, args, { recipeId, scope, log })`. Hooks belong to the crawler, not the recipe, so the
692
+ loader can't check a hook's name; `run` and `work` do, before the first request. A recipe that names a hook
693
+ nobody registered rejects with `UnknownHookError`, which says where the recipe uses it and which hooks are
694
+ registered (`quote-authors mapping.born.transform.0: unknown hook "monthDayYear" (no hooks registered)`).
695
+
696
+ ## What's next
697
+
698
+ The values on this page all arrived. [Policies](08-policies.md) is about the ones that don't: a missing
699
+ value, a value that can't be converted, a transform that throws, and what each does to the record and the run.