@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.
- 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 +449 -6
- 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,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.
|