@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,813 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Data steps
|
|
6
|
+
|
|
7
|
+
[← How OpenCraw works](README.md) · Next: [5. Flow steps](04-flow-steps.md)
|
|
8
|
+
|
|
9
|
+
Four steps produce values: `extract` reads one out of a page or a document, `request` fetches a document,
|
|
10
|
+
`set` computes one from what is already in scope, and `hook` asks your own code for one. Each binds its result
|
|
11
|
+
under its `id` in the scope it runs in (inside a `forEach`, that is the iteration's scope). This page follows
|
|
12
|
+
each of them on a real page or a real API: what the selector matches, what the step binds, and the edge cases
|
|
13
|
+
that make a value come out different from what you expected.
|
|
14
|
+
|
|
15
|
+
[authoring.md §3.2–3.3](../recipes/authoring.md#32-requests-http) and
|
|
16
|
+
[§4](../recipes/authoring.md#4-extract-in-depth) are the reference for every key named here.
|
|
17
|
+
|
|
18
|
+
## Which engine reads: the live page or a document
|
|
19
|
+
|
|
20
|
+
`extract` has two engines behind it, and which one runs depends on the mode, on `from`, and on the `kind`:
|
|
21
|
+
|
|
22
|
+
| Mode | `from` | `kind` | What is read, and by what |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| web | none | `css`, `xpath` | The live page, through Playwright locators: one `evaluateAll` round trip for every match. |
|
|
25
|
+
| web | none | `regex` | The live page's serialised DOM (`page.content()`): the HTML after scripts ran, not what the server sent. |
|
|
26
|
+
| web | none | `table` | The same serialised DOM, through the HTML table reader. |
|
|
27
|
+
| web | none | `jsonpath` | Never the page: the JSON the last `request` fetched (see [request](#request-fetching-a-document)). |
|
|
28
|
+
| web | an id | any | The value bound to that id, read statically, as in api mode. |
|
|
29
|
+
| api | none | any | The current document: the body of the nearest `request`. |
|
|
30
|
+
| api | an id | any | The value bound to that id. |
|
|
31
|
+
|
|
32
|
+
"Statically" means [cheerio](https://cheerio.js.org) for `css`, `xpath` over an XML DOM for `xpath`, and
|
|
33
|
+
jsonpath-plus for `jsonpath`. The two engines agree on plain CSS, but not on everything:
|
|
34
|
+
|
|
35
|
+
| | Playwright (live page) | cheerio (fetched HTML, fragments) |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| What it sees | The DOM after scripts ran | The HTML as the server sent it |
|
|
38
|
+
| `:has-text("…")`, `:visible`, `>>` chaining | Work | Fail: `Unknown pseudo-class :has-text` |
|
|
39
|
+
| `:contains("…")` | Fails: `is not a valid selector` | Works |
|
|
40
|
+
| `:has()`, attribute selectors, combinators | Work | Work |
|
|
41
|
+
| `take: "html"` | `element.getHTML()` | cheerio's `.html()` |
|
|
42
|
+
| `take: "value"` | The live `.value` property (what was typed) | `val()`, else the `value` attribute |
|
|
43
|
+
| Waiting for elements | None: `evaluateAll` reads what is there now | Not applicable |
|
|
44
|
+
|
|
45
|
+
Neither engine waits. A web `extract` that runs before a script has drawn its elements gets nothing: a single
|
|
46
|
+
extract fails, a `many` extract binds `[]`. Put a `wait` (or `goto` with `ready`) before it
|
|
47
|
+
([part 3](02-page-steps.md)).
|
|
48
|
+
|
|
49
|
+
## `extract` on a web page: CSS and XPath
|
|
50
|
+
|
|
51
|
+
The recipe [`quotes-web`](recipes/data-quotes-web/quotes-web.input.json) opens
|
|
52
|
+
[quotes.toscrape.com](https://quotes.toscrape.com), then reads the page in three ways:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{ "type": "extract", "id": "topTags", "selector": "//div[contains(@class, 'tags-box')]//a[@class='tag']", "kind": "xpath", "many": true },
|
|
56
|
+
{ "type": "extract", "id": "einstein", "selector": "div.quote:has-text(\"Albert Einstein\") span.text", "kind": "css", "many": true },
|
|
57
|
+
{ "type": "extract", "id": "quotes", "selector": "div.quote", "kind": "css", "take": "html", "many": true },
|
|
58
|
+
{ "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "steps": [
|
|
59
|
+
{ "type": "extract", "id": "text", "from": "quote", "selector": "span.text", "kind": "css" },
|
|
60
|
+
{ "type": "extract", "id": "author", "from": "quote", "selector": "//small[@class='author']", "kind": "xpath" },
|
|
61
|
+
{ "type": "extract", "id": "tags", "from": "quote", "selector": "a.tag", "kind": "css", "many": true },
|
|
62
|
+
…
|
|
63
|
+
]}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
<!-- capture:data-quotes-web screenshot alt=quotes.toscrape.com,_with_what_each_extract_selects -->
|
|
67
|
+

|
|
68
|
+
<!-- /capture -->
|
|
69
|
+
|
|
70
|
+
The first three extracts have no `from`, so they run on the live page. `topTags` is XPath through Playwright's
|
|
71
|
+
`xpath=` engine. `einstein` uses `:has-text()`, a Playwright extension to CSS: it keeps the quotes whose box
|
|
72
|
+
contains the text, and it would fail in cheerio. `quotes` takes each `div.quote` as its inner HTML, one string
|
|
73
|
+
per quote. Inside the loop every extract has `from: "quote"`, so it reads that string with cheerio, and the XPath
|
|
74
|
+
`//small[@class='author']` runs over the fragment alone: `//` means "anywhere in this quote", not "anywhere in
|
|
75
|
+
the page". For the first quote:
|
|
76
|
+
|
|
77
|
+
<!-- capture:data-quotes-web scope ids=topTags,einstein,text,author,about,tags -->
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"topTags": [
|
|
81
|
+
"love",
|
|
82
|
+
"inspirational",
|
|
83
|
+
"life",
|
|
84
|
+
"… 7 more"
|
|
85
|
+
],
|
|
86
|
+
"einstein": [
|
|
87
|
+
"“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
|
|
88
|
+
"“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.”",
|
|
89
|
+
"“Try not to become a man of success. Rather become a man of value.”"
|
|
90
|
+
],
|
|
91
|
+
"text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
|
|
92
|
+
"author": "Albert Einstein",
|
|
93
|
+
"about": "/author/Albert-Einstein",
|
|
94
|
+
"tags": [
|
|
95
|
+
"change",
|
|
96
|
+
"deep-thoughts",
|
|
97
|
+
"thinking",
|
|
98
|
+
"world"
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
<!-- /capture -->
|
|
103
|
+
|
|
104
|
+
### What `take` returns
|
|
105
|
+
|
|
106
|
+
The same element, `a.tag` in the first quote (`<a class="tag" href="/tag/change/page/1/">change</a>`), taken
|
|
107
|
+
five ways:
|
|
108
|
+
|
|
109
|
+
<!-- capture:data-quotes-web scope ids=tagText,tagHtml,tagOuter,tagHref,tagTitle -->
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"tagText": "change",
|
|
113
|
+
"tagHtml": "change",
|
|
114
|
+
"tagOuter": "<a class=\"tag\" href=\"/tag/change/page/1/\">change</a>",
|
|
115
|
+
"tagHref": "/tag/change/page/1/"
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
<!-- /capture -->
|
|
119
|
+
|
|
120
|
+
| `take` | Gives | Use it for |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `text` (default) | The text content, whitespace collapsed: `change` | Almost everything. |
|
|
123
|
+
| `html` | The inner HTML: here also `change`, since the link has no markup inside | A block you will `extract` from again with `from`. |
|
|
124
|
+
| `json` | The outer HTML, the element itself | An element whose information sits on its own attributes (`<li title="…">`), read again later. |
|
|
125
|
+
| `attr:href` | The attribute as written: a relative `/tag/change/page/1/` | Links (make them absolute with the `absoluteUrl` transform, [part 8](07-mapping-and-transforms.md)). |
|
|
126
|
+
| `attr:title` | Nothing: the link has no `title` | |
|
|
127
|
+
|
|
128
|
+
`tagTitle` isn't in the scope at all. A missing attribute is not a failed match: the element matched, its value
|
|
129
|
+
is `undefined`, and the mapping treats it as missing, under its missing-value policy ([part 9](08-policies.md)).
|
|
130
|
+
Only an element that isn't there fails the step (see [no match](#one-or-many-and-no-match)).
|
|
131
|
+
|
|
132
|
+
## JSONPath: reading JSON
|
|
133
|
+
|
|
134
|
+
The recipe [`quotes-api`](recipes/data-api-quotes/quotes-api.input.json) runs in api mode against
|
|
135
|
+
`https://quotes.toscrape.com/api/quotes?page=1`. The `request` binds the parsed response under `list`, and it
|
|
136
|
+
becomes the current document. The response, as the scope holds it (lists cut to three items):
|
|
137
|
+
|
|
138
|
+
<!-- capture:data-api-quotes scope ids=list -->
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"list": {
|
|
142
|
+
"has_next": true,
|
|
143
|
+
"page": 1,
|
|
144
|
+
"quotes": [
|
|
145
|
+
{
|
|
146
|
+
"author": {
|
|
147
|
+
"goodreads_link": "/author/show/9810.Albert_Einstein",
|
|
148
|
+
"name": "Albert Einstein",
|
|
149
|
+
"slug": "Albert-Einstein"
|
|
150
|
+
},
|
|
151
|
+
"tags": [
|
|
152
|
+
"change",
|
|
153
|
+
"deep-thoughts",
|
|
154
|
+
"thinking",
|
|
155
|
+
"world"
|
|
156
|
+
],
|
|
157
|
+
"text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"author": {
|
|
161
|
+
"goodreads_link": "/author/show/1077326.J_K_Rowling",
|
|
162
|
+
"name": "J.K. Rowling",
|
|
163
|
+
"slug": "J-K-Rowling"
|
|
164
|
+
},
|
|
165
|
+
"tags": [
|
|
166
|
+
"abilities",
|
|
167
|
+
"choices"
|
|
168
|
+
],
|
|
169
|
+
"text": "“It is our choices, Harry, that show what we truly are, far more than our abilities.”"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"author": {
|
|
173
|
+
"goodreads_link": "/author/show/9810.Albert_Einstein",
|
|
174
|
+
"name": "Albert Einstein",
|
|
175
|
+
"slug": "Albert-Einstein"
|
|
176
|
+
},
|
|
177
|
+
"tags": [
|
|
178
|
+
"inspirational",
|
|
179
|
+
"life",
|
|
180
|
+
"live",
|
|
181
|
+
"… 2 more"
|
|
182
|
+
],
|
|
183
|
+
"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.”"
|
|
184
|
+
},
|
|
185
|
+
"… 7 more"
|
|
186
|
+
],
|
|
187
|
+
"tag": null,
|
|
188
|
+
"top_ten_tags": [
|
|
189
|
+
[
|
|
190
|
+
"love",
|
|
191
|
+
14
|
|
192
|
+
],
|
|
193
|
+
[
|
|
194
|
+
"inspirational",
|
|
195
|
+
13
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
"life",
|
|
199
|
+
13
|
|
200
|
+
],
|
|
201
|
+
"… 7 more"
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
<!-- /capture -->
|
|
207
|
+
|
|
208
|
+
JSONPath (the [jsonpath-plus](https://github.com/JSONPath-Plus/JSONPath) dialect) always yields a list of nodes.
|
|
209
|
+
What `take` does with each node is the part that surprises people:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{ "type": "extract", "id": "hasNext", "selector": "$.has_next", "kind": "jsonpath" },
|
|
213
|
+
{ "type": "extract", "id": "hasNextData", "selector": "$.has_next", "kind": "jsonpath", "take": "json" },
|
|
214
|
+
{ "type": "extract", "id": "authorTexts", "selector": "$.quotes[*].author", "kind": "jsonpath", "many": true },
|
|
215
|
+
{ "type": "extract", "id": "authorNames", "from": "authorTexts", "selector": "$[*].name", "kind": "jsonpath", "take": "json", "many": true }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
<!-- capture:data-api-quotes scope ids=hasNext,hasNextData,authorTexts,authorNames -->
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"hasNext": "true",
|
|
222
|
+
"hasNextData": true,
|
|
223
|
+
"authorTexts": [
|
|
224
|
+
"{\"goodreads_link\":\"/author/show/9810.Albert_Einstein\",\"name\":\"Albert Einstein\",\"slug\":\"Albert-Einstein\"}",
|
|
225
|
+
"{\"goodreads_link\":\"/author/show/1077326.J_K_Rowling\",\"name\":\"J.K. Rowling\",\"slug\":\"J-K-Rowling\"}",
|
|
226
|
+
"{\"goodreads_link\":\"/author/show/9810.Albert_Einstein\",\"name\":\"Albert Einstein\",\"slug\":\"Albert-Einstein\"}",
|
|
227
|
+
"… 7 more"
|
|
228
|
+
],
|
|
229
|
+
"authorNames": [
|
|
230
|
+
"Albert Einstein",
|
|
231
|
+
"J.K. Rowling",
|
|
232
|
+
"Albert Einstein",
|
|
233
|
+
"… 7 more"
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
<!-- /capture -->
|
|
238
|
+
|
|
239
|
+
- **The default `take: "text"` turns every node into text.** `$.has_next` is the boolean `true` in the response,
|
|
240
|
+
but `hasNext` holds the string `"true"`. An object becomes its JSON text: each item of `authorTexts` is a
|
|
241
|
+
string, not an object. A `null` node becomes `undefined`.
|
|
242
|
+
- **`take: "json"` keeps the node as it is**: `hasNextData` is a real boolean, and a `$.quotes[*]` extract with
|
|
243
|
+
`take: "json"` gives objects you can loop over and read paths from (`{{quote.author.name}}`).
|
|
244
|
+
- **A list of texts is read as JSON.** `authorNames` reads `from: "authorTexts"`, a list of strings. For
|
|
245
|
+
`jsonpath`, the engine parses every string that is JSON, drops the ones that aren't, and runs the path over
|
|
246
|
+
the array of results, so `$[*].name` finds every name. A single string is parsed the same way.
|
|
247
|
+
|
|
248
|
+
That last rule exists for JSON-LD. A page usually has several `<script type="application/ld+json">` blocks,
|
|
249
|
+
and the one you want is not always the first. Take them all as text, then query across them:
|
|
250
|
+
|
|
251
|
+
```json
|
|
252
|
+
{ "type": "extract", "id": "ld", "selector": "script[type=\"application/ld+json\"]", "kind": "css", "many": true },
|
|
253
|
+
{ "type": "extract", "id": "name", "from": "ld", "selector": "$[?(@['@type']=='Product')].name", "kind": "jsonpath" }
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The parser also unwraps what sites wrap their JSON in: CDATA and comment guards, `)]}'` prefixes, JSONP
|
|
257
|
+
callbacks, `window.__STATE__ = {…};` assignments. Nothing is evaluated: what is left must be strict JSON
|
|
258
|
+
([authoring §4.1](../recipes/authoring.md#41-which-document)).
|
|
259
|
+
|
|
260
|
+
Inside the loop, `from: "quote"` reads a bound object directly, with no parsing:
|
|
261
|
+
|
|
262
|
+
<!-- capture:data-api-quotes scope ids=text,author,authorData,tags -->
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
|
|
266
|
+
"author": "{\"goodreads_link\":\"/author/show/9810.Albert_Einstein\",\"name\":\"Albert Einstein\",\"slug\":\"Albert-Einstein\"}",
|
|
267
|
+
"authorData": {
|
|
268
|
+
"goodreads_link": "/author/show/9810.Albert_Einstein",
|
|
269
|
+
"name": "Albert Einstein",
|
|
270
|
+
"slug": "Albert-Einstein"
|
|
271
|
+
},
|
|
272
|
+
"tags": [
|
|
273
|
+
"change",
|
|
274
|
+
"deep-thoughts",
|
|
275
|
+
"thinking",
|
|
276
|
+
"world"
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
<!-- /capture -->
|
|
281
|
+
|
|
282
|
+
`author` and `authorData` use the same path, `$.author`. Only the `take` differs. The mapping reads
|
|
283
|
+
`authorData.name`; `author.name` would find nothing, because `author` is a string.
|
|
284
|
+
|
|
285
|
+
JSONPath reads JSON, and a PDF, a workbook or a deck as data ([part 7](06-documents.md)). On an HTML, text or
|
|
286
|
+
XML document it fails at once with `jsonpath needs a JSON document; the current document is html`.
|
|
287
|
+
|
|
288
|
+
## Regex: values that live in scripts
|
|
289
|
+
|
|
290
|
+
[quotes.toscrape.com/js](https://quotes.toscrape.com/js/) looks like the home page in a browser, but the quotes
|
|
291
|
+
are drawn by a script from a `var data = [...]` literal in the page.
|
|
292
|
+
|
|
293
|
+
<!-- capture:data-regex-js screenshot alt=The_/js/_page_in_a_browser:_every_quote_is_drawn_by_a_script -->
|
|
294
|
+

|
|
295
|
+
<!-- /capture -->
|
|
296
|
+
|
|
297
|
+
The recipe [`quotes-js`](recipes/data-regex-js/quotes-js.input.json) fetches it in api mode, with no browser:
|
|
298
|
+
|
|
299
|
+
```json
|
|
300
|
+
{ "type": "request", "id": "html", "url": "{{start.url}}" },
|
|
301
|
+
{ "type": "extract", "id": "rendered", "selector": "div.quote", "kind": "css", "take": "html", "many": true },
|
|
302
|
+
{ "type": "extract", "id": "names", "selector": "\"name\": \"([^\"]+)\"", "kind": "regex", "many": true },
|
|
303
|
+
{ "type": "extract", "id": "firstName", "selector": "\"name\": \"[^\"]+\"", "kind": "regex" },
|
|
304
|
+
{ "type": "extract", "id": "data", "selector": "var data = (\\[.*?\\]);\\s*for", "kind": "regex" },
|
|
305
|
+
{ "type": "extract", "id": "quotes", "from": "data", "selector": "$[*]", "kind": "jsonpath", "take": "json", "many": true }
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
<!-- capture:data-regex-js scope ids=rendered,names,firstName -->
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"rendered": [],
|
|
312
|
+
"names": [
|
|
313
|
+
"Albert Einstein",
|
|
314
|
+
"J.K. Rowling",
|
|
315
|
+
"Albert Einstein",
|
|
316
|
+
"… 7 more"
|
|
317
|
+
],
|
|
318
|
+
"firstName": "\"name\": \"Albert Einstein\""
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
<!-- /capture -->
|
|
322
|
+
|
|
323
|
+
- `rendered` is empty. The server sends no `div.quote`: only a browser running the script makes them. The same
|
|
324
|
+
CSS extract in web mode would find ten.
|
|
325
|
+
- A regex extract returns **capture group 1** when the pattern has one, and the whole match otherwise.
|
|
326
|
+
`names` has a group, so it holds the names. `firstName` has none, so it holds the whole `"name": "…"` text.
|
|
327
|
+
Only group 1 is ever returned: to pick another part, make it the first group.
|
|
328
|
+
- `names` is raw text from the script. Its seventh name (cut from the scope above) is `Andr\u00e9 Gide`,
|
|
329
|
+
the JSON escape as written in the page: a regex never decodes anything.
|
|
330
|
+
- The flags are fixed at `g` and `s`: every match is found, and `.` matches line breaks. There is no option for
|
|
331
|
+
other flags, so to ignore case write both cases, as in `[Pp]rice`. `take` is ignored.
|
|
332
|
+
|
|
333
|
+
The `data` extract uses the `s` flag: `\[.*?\]` spans the sixty lines of the literal. What it binds is text, and
|
|
334
|
+
a `jsonpath` extract with `from: "data"` parses it. The two records, with `André` decoded this time:
|
|
335
|
+
|
|
336
|
+
<!-- capture:data-regex-js records n=2 -->
|
|
337
|
+
```json
|
|
338
|
+
{"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","tags":["change","deep-thoughts","thinking","world"]}
|
|
339
|
+
{"text":"“It is our choices, Harry, that show what we truly are, far more than our abilities.”","author":"J.K. Rowling","tags":["abilities","choices"]}
|
|
340
|
+
```
|
|
341
|
+
<!-- /capture -->
|
|
342
|
+
|
|
343
|
+
A regex reads any document as text: HTML and XML as markup, JSON re-serialised, a PDF's or a workbook's text, a
|
|
344
|
+
list of strings joined by line breaks. In web mode without `from`, it reads `page.content()`, which includes the
|
|
345
|
+
elements the script drew.
|
|
346
|
+
|
|
347
|
+
## Tables, in outline
|
|
348
|
+
|
|
349
|
+
`kind: "table"` reads tables rather than elements: the `selector` is a case-insensitive regular expression for
|
|
350
|
+
the header row, and the result is one object per table found, with its rows keyed by column. On an HTML page it
|
|
351
|
+
reads every `<table>`; [part 7](06-documents.md) covers how, and how PDFs, spreadsheets and decks are read into
|
|
352
|
+
the same shape. The recipe [`hockey-table`](recipes/data-table/hockey-table.input.json) reads
|
|
353
|
+
[scrapethissite.com's hockey table](https://www.scrapethissite.com/pages/forms/?per_page=5):
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
{ "type": "extract", "id": "table", "selector": "^Team Name", "kind": "table", "columns": { "team": "^Team Name$", "year": "^Year$", "wins": "^Wins$", "losses": "^Losses$", "otLosses": "^OT Losses$" } },
|
|
357
|
+
{ "type": "set", "id": "rows", "value": "{{table.rows}}" },
|
|
358
|
+
{ "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [] }
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
<!-- capture:data-table screenshot alt=The_hockey_table:_the_header_row_the_selector_matches,_and_the_rows -->
|
|
362
|
+

|
|
363
|
+
<!-- /capture -->
|
|
364
|
+
|
|
365
|
+
<!-- capture:data-table scope ids=table,row -->
|
|
366
|
+
```json
|
|
367
|
+
{
|
|
368
|
+
"table": {
|
|
369
|
+
"sheet": "table 1",
|
|
370
|
+
"title": "Team Name",
|
|
371
|
+
"header": [
|
|
372
|
+
"Team Name",
|
|
373
|
+
"Year",
|
|
374
|
+
"Wins",
|
|
375
|
+
"… 6 more"
|
|
376
|
+
],
|
|
377
|
+
"rows": [
|
|
378
|
+
{
|
|
379
|
+
"team": "Boston Bruins",
|
|
380
|
+
"year": "1990",
|
|
381
|
+
"wins": "44",
|
|
382
|
+
"losses": "24",
|
|
383
|
+
"otLosses": ""
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
"team": "Buffalo Sabres",
|
|
387
|
+
"year": "1990",
|
|
388
|
+
"wins": "31",
|
|
389
|
+
"losses": "30",
|
|
390
|
+
"otLosses": ""
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"team": "Calgary Flames",
|
|
394
|
+
"year": "1990",
|
|
395
|
+
"wins": "46",
|
|
396
|
+
"losses": "26",
|
|
397
|
+
"otLosses": ""
|
|
398
|
+
},
|
|
399
|
+
"… 2 more"
|
|
400
|
+
]
|
|
401
|
+
},
|
|
402
|
+
"row": {
|
|
403
|
+
"team": "Boston Bruins",
|
|
404
|
+
"year": "1990",
|
|
405
|
+
"wins": "44",
|
|
406
|
+
"losses": "24",
|
|
407
|
+
"otLosses": ""
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
<!-- /capture -->
|
|
412
|
+
|
|
413
|
+
`columns` renames the columns it names and drops the rest (`Win %`, `Goals For (GF)`…). Cells are text:
|
|
414
|
+
`"1990"` becomes the integer `1990` only when the mapping coerces it into an `integer` field. The `set` is
|
|
415
|
+
there because `forEach` loops over one id, not a path ([part 5](04-flow-steps.md#foreach)).
|
|
416
|
+
|
|
417
|
+
## One or many, and no match
|
|
418
|
+
|
|
419
|
+
| | `many: true` | Without `many` |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| Matches | The list of every value, in document order | The first value |
|
|
422
|
+
| No match | `[]`, and the step succeeds | The step fails: `no match for <selector>` |
|
|
423
|
+
| A match whose value is missing (an absent attribute, a JSON `null` with `take: "text"`) | `undefined` in the list | The id holds `undefined`, which the mapping treats as missing; the step succeeds |
|
|
424
|
+
|
|
425
|
+
A failing extract goes through its error policy: the step's `onError`, else the recipe's, else `fail`. Page 3
|
|
426
|
+
of [quotes.toscrape.com's API](https://quotes.toscrape.com/api/quotes?page=3) has a quote with no tags, and a
|
|
427
|
+
recipe reading `$.tags[0]` with `onError: { "policy": "skip" }` goes past it:
|
|
428
|
+
|
|
429
|
+
<!-- capture:flow-onerror-skip trace grep=↷|✚_record_\["“It_is_impossible lines=2 -->
|
|
430
|
+
```text
|
|
431
|
+
↷ steps.2.steps.1 extract firstTag skipped: no match for $.tags[0]
|
|
432
|
+
✚ record ["“It is impossible to live without failing at something, unless you live so cautiously that you might as well not have lived at all - in which case, you fail by default.”"]
|
|
433
|
+
```
|
|
434
|
+
<!-- /capture -->
|
|
435
|
+
|
|
436
|
+
The skipped extract leaves `firstTag` unbound for that quote alone, and the mapping's missing-value policy
|
|
437
|
+
decides what the record gets (`null` here). Without the `skip`, the same miss stops the whole recipe, and a
|
|
438
|
+
`skip` on the enclosing `forEach` does not help: [part 5](04-flow-steps.md#error-policies) shows both.
|
|
439
|
+
|
|
440
|
+
Two idioms follow from the table. To test whether something is there, use `many: true` and test the list
|
|
441
|
+
(`{{ len(wall) > 0 }}`): it never fails. To say a value is optional, use `onError: skip` on a single extract:
|
|
442
|
+
the step's miss is then visible in the trace as `↷`, which a `many` extract never shows.
|
|
443
|
+
|
|
444
|
+
## `from` and fragments
|
|
445
|
+
|
|
446
|
+
`from` names an id to read instead of the page or the current document. The usual pattern: take each repeating
|
|
447
|
+
block as HTML with `many: true`, loop over the blocks, and read each one with `from`. What the id holds decides
|
|
448
|
+
how it's read:
|
|
449
|
+
|
|
450
|
+
| The id holds | `css` | `xpath` | `jsonpath` | `regex` |
|
|
451
|
+
|---|---|---|---|---|
|
|
452
|
+
| Text starting with `<!doctype` or `<html` | A full document | XML if well-formed, else HTML parsed as a browser would | The text parsed as JSON | The text |
|
|
453
|
+
| Other text (a fragment) | A fragment | XML if well-formed, else HTML wrapped in one root | The text parsed as JSON | The text |
|
|
454
|
+
| A list of texts | Fails | Fails | Every text that parses as JSON, as one array | The texts joined by line breaks |
|
|
455
|
+
| Data (an object, a list of objects) | Fails: `is not HTML text; use kind "jsonpath" for data` | Fails: `is not markup` | The data | The data as JSON text |
|
|
456
|
+
|
|
457
|
+
A read PDF, workbook, deck or XML document bound to an id is read as [part 7](06-documents.md) describes, and
|
|
458
|
+
`table` reads those, and HTML text too: with `from` bound to a page a `request` fetched, a Word or Markdown
|
|
459
|
+
document, or a list of fragments taken with `take: "html"`, it reads their `<table>`s.
|
|
460
|
+
|
|
461
|
+
The fragment row matters for table rows and list items. The recipe
|
|
462
|
+
[`book-specs`](recipes/data-fragments/book-specs.input.json) reads the product table of a
|
|
463
|
+
[books.toscrape.com book](https://books.toscrape.com/catalogue/sharp-objects_997/index.html), one `tr` at a time:
|
|
464
|
+
|
|
465
|
+
```json
|
|
466
|
+
{ "type": "extract", "id": "rows", "selector": "table.table-striped tr", "kind": "css", "take": "html", "many": true },
|
|
467
|
+
{ "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [
|
|
468
|
+
{ "type": "extract", "id": "name", "from": "row", "selector": "th", "kind": "css" },
|
|
469
|
+
{ "type": "extract", "id": "value", "from": "row", "selector": "td", "kind": "css" },
|
|
470
|
+
{ "type": "extract", "id": "valueByXpath", "from": "row", "selector": "//td", "kind": "xpath" }
|
|
471
|
+
]}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
<!-- capture:data-fragments screenshot alt=The_product_table:_each_row_is_one_fragment -->
|
|
475
|
+

|
|
476
|
+
<!-- /capture -->
|
|
477
|
+
|
|
478
|
+
<!-- capture:data-fragments scope ids=row,name,value,valueByXpath -->
|
|
479
|
+
```json
|
|
480
|
+
{
|
|
481
|
+
"row": "\n <th>UPC</th><td>e00eb4fd7b871a48</td>\n ",
|
|
482
|
+
"name": "UPC",
|
|
483
|
+
"value": "e00eb4fd7b871a48",
|
|
484
|
+
"valueByXpath": "e00eb4fd7b871a48"
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
<!-- /capture -->
|
|
488
|
+
|
|
489
|
+
`row` is `<th>UPC</th><td>…</td>` with no `<table>` or `<tr>` around it. An HTML5 parser given that as a
|
|
490
|
+
document drops the cells, because a `<td>` outside a table is invalid: `td` would match nothing. The engine
|
|
491
|
+
parses any text that doesn't start with `<!doctype` or `<html` as a fragment, which keeps the cells as they are.
|
|
492
|
+
XPath does the equivalent: markup that isn't well-formed XML is parsed as HTML and wrapped in a single
|
|
493
|
+
`<fragment>` root, so `//td` finds the cell. The same holds for `<li>`, `<option>` and `<dt>`/`<dd>` blocks.
|
|
494
|
+
|
|
495
|
+
<!-- capture:data-fragments records n=4 -->
|
|
496
|
+
```json
|
|
497
|
+
{"book":"Sharp Objects","name":"UPC","value":"e00eb4fd7b871a48"}
|
|
498
|
+
{"book":"Sharp Objects","name":"Product Type","value":"Books"}
|
|
499
|
+
{"book":"Sharp Objects","name":"Price (excl. tax)","value":"£47.82"}
|
|
500
|
+
{"book":"Sharp Objects","name":"Price (incl. tax)","value":"£47.82"}
|
|
501
|
+
```
|
|
502
|
+
<!-- /capture -->
|
|
503
|
+
|
|
504
|
+
### The wrapper trap, and shifted fields
|
|
505
|
+
|
|
506
|
+
A selector that matches a wrapper instead of the item gives one block per wrapper, and "the first X" in that
|
|
507
|
+
block is the first item's X. [scrapethissite.com's country list](https://www.scrapethissite.com/pages/simple/)
|
|
508
|
+
lays out its 250 countries in rows of three. The recipe
|
|
509
|
+
[`countries-by-row`](recipes/data-wrapper-trap/countries-by-row.input.json) takes the grid rows as its items:
|
|
510
|
+
|
|
511
|
+
```json
|
|
512
|
+
{ "type": "extract", "id": "countries", "selector": "div.country", "kind": "css", "take": "html", "many": true },
|
|
513
|
+
{ "type": "extract", "id": "rows", "selector": "div.row:has(div.country)", "kind": "css", "take": "html", "many": true },
|
|
514
|
+
{ "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [
|
|
515
|
+
{ "type": "extract", "id": "name", "from": "row", "selector": "h3.country-name", "kind": "css" },
|
|
516
|
+
{ "type": "extract", "id": "capital", "from": "row", "selector": ".country-capital", "kind": "css" },
|
|
517
|
+
{ "type": "extract", "id": "population", "from": "row", "selector": ".country-population", "kind": "css", "many": true }
|
|
518
|
+
]}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
<!-- capture:data-wrapper-trap screenshot alt=Each_grid_row_is_one_match:_a_wrapper_of_three_countries -->
|
|
522
|
+

|
|
523
|
+
<!-- /capture -->
|
|
524
|
+
|
|
525
|
+
<!-- capture:data-wrapper-trap scope ids=counts,name,capital,population -->
|
|
526
|
+
```json
|
|
527
|
+
{
|
|
528
|
+
"counts": {
|
|
529
|
+
"rows": 84,
|
|
530
|
+
"countries": 250
|
|
531
|
+
},
|
|
532
|
+
"name": "Andorra",
|
|
533
|
+
"capital": "Andorra la Vella",
|
|
534
|
+
"population": [
|
|
535
|
+
"84000",
|
|
536
|
+
"4975593",
|
|
537
|
+
"29121286"
|
|
538
|
+
]
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
<!-- /capture -->
|
|
542
|
+
|
|
543
|
+
84 records instead of 250, and nothing failed. Each record is right about its first country (Andorra, Andorra
|
|
544
|
+
la Vella) and silently drops the other two, and the `many` extract collects all three populations. Now suppose
|
|
545
|
+
the first country of a row had no capital. A single `.country-capital` extract would still match, the second
|
|
546
|
+
country's capital, and the record would pair Andorra with the wrong city: a **shifted field**, with no error
|
|
547
|
+
anywhere. That is what nested markup (a table inside a table, a list inside a list) does when the outer element
|
|
548
|
+
is selected: its inner HTML holds every inner item.
|
|
549
|
+
|
|
550
|
+
The fix is to select the innermost repeating element (`div.country` here, `table.inner tr` rather than `tr`) and
|
|
551
|
+
to compare the number of items with what the page shows. `counts` in the scope above is that check.
|
|
552
|
+
|
|
553
|
+
## `request`: fetching a document
|
|
554
|
+
|
|
555
|
+
`request` sends an HTTP request and makes the response **the current document** of the scope it runs in. In api
|
|
556
|
+
mode it's how every document arrives. In web mode it goes through the page's own session (see
|
|
557
|
+
[the form section](#a-form-post-from-a-web-page)).
|
|
558
|
+
|
|
559
|
+
The recipe [`pokeapi-species`](recipes/data-request/pokeapi-species.input.json) lists three Pokémon from
|
|
560
|
+
[PokeAPI](https://pokeapi.co), then fetches each one's species:
|
|
561
|
+
|
|
562
|
+
```json
|
|
563
|
+
{ "type": "request", "id": "list", "url": "{{start.url}}", "query": { "limit": "{{vars.limit}}", "offset": "0" } },
|
|
564
|
+
{ "type": "extract", "id": "results", "selector": "$.results[*]", "kind": "jsonpath", "take": "json", "many": true },
|
|
565
|
+
{ "type": "forEach", "over": "results", "as": "mon", "emit": true, "steps": [
|
|
566
|
+
{ "type": "request", "id": "species", "url": "/api/v2/pokemon-species/{{mon.name}}/", "as": "json" },
|
|
567
|
+
{ "type": "extract", "id": "genus", "selector": "$.genera[?(@.language.name=='en')].genus", "kind": "jsonpath" },
|
|
568
|
+
{ "type": "extract", "id": "color", "selector": "$.color.name", "kind": "jsonpath" },
|
|
569
|
+
{ "type": "extract", "id": "captureRate", "selector": "$.capture_rate", "kind": "jsonpath", "take": "json" }
|
|
570
|
+
]}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
<!-- capture:data-request trace lines=11 -->
|
|
574
|
+
```text
|
|
575
|
+
▶ pokeapi-species (api)
|
|
576
|
+
⇄ access direct (direct)
|
|
577
|
+
⇢ page 1 https://pokeapi.co/api/v2/pokemon?limit=3&offset=0
|
|
578
|
+
· steps.0 request list … ms
|
|
579
|
+
· steps.1 extract results … ms
|
|
580
|
+
⇢ page 1 https://pokeapi.co/api/v2/pokemon-species/bulbasaur/
|
|
581
|
+
· steps.2.steps.0 request species … ms
|
|
582
|
+
· steps.2.steps.1 extract genus … ms
|
|
583
|
+
· steps.2.steps.2 extract color … ms
|
|
584
|
+
· steps.2.steps.3 extract captureRate … ms
|
|
585
|
+
✚ record ["bulbasaur"]
|
|
586
|
+
…
|
|
587
|
+
```
|
|
588
|
+
<!-- /capture -->
|
|
589
|
+
|
|
590
|
+
Step by step, for the first `request` and then inside the loop:
|
|
591
|
+
|
|
592
|
+
1. **Templates.** `url`, every `query` value and every header are rendered as text: `{{vars.limit}}` (the number
|
|
593
|
+
`3`) becomes `limit=3`, added to the URL. A `body` is rendered all the way down instead: every string in an
|
|
594
|
+
object or a list is a template, and a string that is exactly one placeholder keeps its type, so
|
|
595
|
+
`"limit": "{{vars.limit}}"` sends the number `3`. An object or list body is sent as JSON, and a request with
|
|
596
|
+
a body defaults to `POST` (without one, `GET`).
|
|
597
|
+
2. **The URL is resolved against the current page.** `/api/v2/pokemon-species/bulbasaur/` has no host: it is
|
|
598
|
+
resolved against `page.url`, which the first request set to
|
|
599
|
+
`https://pokeapi.co/api/v2/pokemon?limit=3&offset=0`. A relative URL with no page to resolve against fails
|
|
600
|
+
with `is not a URL and no page is known to resolve it against`.
|
|
601
|
+
3. **The response is read** by `as` when given (`json`, `html`, `text`, `xml`, `csv`, `pdf`…), else by its
|
|
602
|
+
content type. [Part 7](06-documents.md) covers the document formats.
|
|
603
|
+
4. **The status is checked.** A 4xx or 5xx fails the step with `HTTP 404 for <url>`. Before that, statuses
|
|
604
|
+
408, 425, 429, 500, 502, 503 and 504, and dropped connections, are retried with backoff (`limits.retry`,
|
|
605
|
+
[part 10](09-running.md)). A `goto` is different: a 404 page loads like any other.
|
|
606
|
+
5. **The scope's page changes**: `page.url` becomes the final URL (after redirects) and the parsed body becomes
|
|
607
|
+
the current document, in the scope the step ran in. Here that's each iteration's scope, so `genus`, `color`
|
|
608
|
+
and `captureRate` read the species with no `from`, and the list is untouched for the next iteration.
|
|
609
|
+
6. **The id holds the body**: parsed JSON as data, HTML and text as strings, a PDF, workbook, deck or XML
|
|
610
|
+
document as the read object.
|
|
611
|
+
|
|
612
|
+
<!-- capture:data-request scope ids=list,page,mon,genus,color,captureRate -->
|
|
613
|
+
```json
|
|
614
|
+
{
|
|
615
|
+
"list": {
|
|
616
|
+
"count": 1351,
|
|
617
|
+
"next": "https://pokeapi.co/api/v2/pokemon?offset=3&limit=3",
|
|
618
|
+
"previous": null,
|
|
619
|
+
"results": [
|
|
620
|
+
{
|
|
621
|
+
"name": "bulbasaur",
|
|
622
|
+
"url": "https://pokeapi.co/api/v2/pokemon/1/"
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
"name": "ivysaur",
|
|
626
|
+
"url": "https://pokeapi.co/api/v2/pokemon/2/"
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
"name": "venusaur",
|
|
630
|
+
"url": "https://pokeapi.co/api/v2/pokemon/3/"
|
|
631
|
+
}
|
|
632
|
+
]
|
|
633
|
+
},
|
|
634
|
+
"page": {
|
|
635
|
+
"url": "https://pokeapi.co/api/v2/pokemon-species/bulbasaur/",
|
|
636
|
+
"number": 1
|
|
637
|
+
},
|
|
638
|
+
"mon": {
|
|
639
|
+
"name": "bulbasaur",
|
|
640
|
+
"url": "https://pokeapi.co/api/v2/pokemon/1/"
|
|
641
|
+
},
|
|
642
|
+
"genus": "Seed Pokémon",
|
|
643
|
+
"color": "green",
|
|
644
|
+
"captureRate": 45
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
<!-- /capture -->
|
|
648
|
+
|
|
649
|
+
`list` is the whole first response; `page.url` is the species URL of this iteration. The trace shows every
|
|
650
|
+
`request` as a `⇢ page 1` line: `request` reports a visit, but only `paginate` advances `page.number`.
|
|
651
|
+
|
|
652
|
+
A failing status, from a recipe that asks PokeAPI for a Pokémon that doesn't exist (the retries are its
|
|
653
|
+
`onError`, [part 5](04-flow-steps.md#error-policies)):
|
|
654
|
+
|
|
655
|
+
<!-- capture:flow-onerror-retry trace grep=⇢|✖_step lines=4 -->
|
|
656
|
+
```text
|
|
657
|
+
⇢ page 1 https://pokeapi.co/api/v2/pokemon/missingno [404]
|
|
658
|
+
⇢ page 1 https://pokeapi.co/api/v2/pokemon/missingno [404]
|
|
659
|
+
⇢ page 1 https://pokeapi.co/api/v2/pokemon/missingno [404]
|
|
660
|
+
✖ step steps.0 (request) failed: HTTP 404 for https://pokeapi.co/api/v2/pokemon/missingno
|
|
661
|
+
```
|
|
662
|
+
<!-- /capture -->
|
|
663
|
+
|
|
664
|
+
### A form post from a web page
|
|
665
|
+
|
|
666
|
+
In web mode, `request` is sent through the page's browser context: it carries the page's cookies (a login, a
|
|
667
|
+
consent, a solved captcha), the way the page's own scripts call its endpoints. `form` reads a form on the page
|
|
668
|
+
and posts it as the browser would. The recipe [`quotes-login`](recipes/data-form/quotes-login.input.json) logs
|
|
669
|
+
in to [quotes.toscrape.com/login](https://quotes.toscrape.com/login), which accepts any name and password:
|
|
670
|
+
|
|
671
|
+
```json
|
|
672
|
+
{ "type": "goto", "url": "{{start.url}}" },
|
|
673
|
+
{ "type": "request", "id": "home", "url": "/login", "method": "POST", "as": "html",
|
|
674
|
+
"form": { "selector": "form", "set": { "username": "{{vars.user}}", "password": "{{vars.password}}" } } },
|
|
675
|
+
{ "type": "extract", "id": "liveLink", "selector": "div.header-box p a", "kind": "css" },
|
|
676
|
+
{ "type": "extract", "id": "fetchedLink", "from": "home", "selector": "div.header-box p a", "kind": "css" }
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
<!-- capture:data-form screenshot alt=The_login_form_the_request_reads,_and_the_header_link -->
|
|
680
|
+

|
|
681
|
+
<!-- /capture -->
|
|
682
|
+
|
|
683
|
+
<!-- capture:data-form scope ids=liveLink,fetchedLink,page -->
|
|
684
|
+
```json
|
|
685
|
+
{
|
|
686
|
+
"liveLink": "Login",
|
|
687
|
+
"fetchedLink": "Logout",
|
|
688
|
+
"page": {
|
|
689
|
+
"url": "https://quotes.toscrape.com/login",
|
|
690
|
+
"number": 1
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
```
|
|
694
|
+
<!-- /capture -->
|
|
695
|
+
|
|
696
|
+
- The form is read with the browser's `FormData`, so the hidden `csrf_token` goes along with it, and the session
|
|
697
|
+
cookie the `goto` got makes the token valid. `set` adds or replaces fields (as templates); the form's own
|
|
698
|
+
values are sent as read, never rendered.
|
|
699
|
+
- The server answers with a redirect to `/`, followed, and the logged-in home page is the response: `home`
|
|
700
|
+
holds it, and its header says **Logout**.
|
|
701
|
+
- The browser tab never moved. `page.url` is still `/login`, and an extract without `from` reads the live page,
|
|
702
|
+
whose header still says **Login**. In web mode, `css`, `xpath`, `regex` and `table` without `from` always read
|
|
703
|
+
the live page: read a fetched document with `from: <request id>`, `table` included. Only `jsonpath` reads the
|
|
704
|
+
fetched JSON directly.
|
|
705
|
+
|
|
706
|
+
`form` is refused in api mode, since there is no page to read it from: send the fields as a `body` there.
|
|
707
|
+
|
|
708
|
+
## `set`: computing a value
|
|
709
|
+
|
|
710
|
+
`set` binds `value` under its `id`, rendered all the way down like a request body. From the
|
|
711
|
+
[`quotes-api`](recipes/data-api-quotes/quotes-api.input.json) recipe:
|
|
712
|
+
|
|
713
|
+
```json
|
|
714
|
+
{ "type": "set", "id": "summary", "value": {
|
|
715
|
+
"page": "{{list.page}}", "quotes": "{{ len(list.quotes) }}", "label": "page {{list.page}}, {{ len(list.quotes) }} quotes",
|
|
716
|
+
"more": "{{list.has_next}}", "firstTwo": ["{{authorNames[0]}}", "{{authorNames[1]}}"] } }
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
<!-- capture:data-api-quotes scope ids=summary -->
|
|
720
|
+
```json
|
|
721
|
+
{
|
|
722
|
+
"summary": {
|
|
723
|
+
"page": 1,
|
|
724
|
+
"quotes": 10,
|
|
725
|
+
"label": "page 1, 10 quotes",
|
|
726
|
+
"more": true,
|
|
727
|
+
"firstTwo": [
|
|
728
|
+
"Albert Einstein",
|
|
729
|
+
"J.K. Rowling"
|
|
730
|
+
]
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
<!-- /capture -->
|
|
735
|
+
|
|
736
|
+
A string that is exactly one placeholder keeps the value's type: `page` and `quotes` are numbers, `more` is the
|
|
737
|
+
boolean. A placeholder inside longer text is stringified: `label` is text. Numbers, booleans and `null` in the
|
|
738
|
+
`value` stay as they are. A `set` without an `id` does nothing.
|
|
739
|
+
|
|
740
|
+
`set` is also how a path becomes an id (`forEach` loops over one id), how an empty list is prepared for
|
|
741
|
+
`collect` ([part 5](04-flow-steps.md#cross-page-accumulation)), and how a lookup table is written into the
|
|
742
|
+
recipe (`starWords` in [the overview](README.md#a-run-end-to-end)). [Part 6](05-templates-and-scope.md) covers
|
|
743
|
+
the expression language.
|
|
744
|
+
|
|
745
|
+
## `hook`: asking your code
|
|
746
|
+
|
|
747
|
+
A `hook` step calls a function registered with the crawler and binds what it returns. This page's scenes
|
|
748
|
+
([`03-data-and-flow.mjs`](capture/scenes/03-data-and-flow.mjs)) register one for
|
|
749
|
+
[`quotes-api`](recipes/data-api-quotes/quotes-api.input.json):
|
|
750
|
+
|
|
751
|
+
```js
|
|
752
|
+
createCrawler({ hooks: { wordCount: (_input, args) => String(args.text ?? '').split(/\s+/).filter(Boolean).length } })
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
```json
|
|
756
|
+
{ "type": "hook", "id": "words", "name": "wordCount", "args": { "text": "{{text}}" } }
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
<!-- capture:data-api-quotes scope ids=text,words -->
|
|
760
|
+
```json
|
|
761
|
+
{
|
|
762
|
+
"text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
|
|
763
|
+
"words": 21
|
|
764
|
+
}
|
|
765
|
+
```
|
|
766
|
+
<!-- /capture -->
|
|
767
|
+
|
|
768
|
+
- From a step, the hook's first argument (`input`) is `undefined`; from a `hook` transform in the mapping, it is
|
|
769
|
+
the value being transformed.
|
|
770
|
+
- `args` is rendered like `set`'s value before the call.
|
|
771
|
+
- The third argument carries `recipeId`, a snapshot of the scope, and `log(level, message)`: an `error` log
|
|
772
|
+
becomes an `error` event, anything else a `warning`, both in the trace.
|
|
773
|
+
- The result, awaited if it's a promise, is bound as it is: a number stays a number.
|
|
774
|
+
- A name nobody registered fails at the call, not at load time, with the list of registered hooks.
|
|
775
|
+
|
|
776
|
+
[authoring §6](../recipes/authoring.md#6-hooks) shows how the CLI and the MCP server load hooks from a module.
|
|
777
|
+
|
|
778
|
+
## The route through the steps
|
|
779
|
+
|
|
780
|
+
The trace of `quotes-api`, the recipe of the `set`, `hook` and JSONPath examples above: the `request`, the
|
|
781
|
+
five top-level extracts and the `set` run once, then the loop body runs once per quote until
|
|
782
|
+
`limits.maxRecords` (2) stops it.
|
|
783
|
+
|
|
784
|
+
<!-- capture:data-api-quotes trace lines=24 -->
|
|
785
|
+
```text
|
|
786
|
+
▶ quotes-api (api)
|
|
787
|
+
⇄ access direct (direct)
|
|
788
|
+
⇢ page 1 https://quotes.toscrape.com/api/quotes?page=1
|
|
789
|
+
· steps.0 request list … ms
|
|
790
|
+
· steps.1 extract hasNext … ms
|
|
791
|
+
· steps.2 extract hasNextData … ms
|
|
792
|
+
· steps.3 extract authorTexts … ms
|
|
793
|
+
· steps.4 extract authorNames … ms
|
|
794
|
+
· steps.5 set summary … ms
|
|
795
|
+
· steps.6 extract quotes … ms
|
|
796
|
+
· steps.7.steps.0 extract text … ms
|
|
797
|
+
· steps.7.steps.1 extract author … ms
|
|
798
|
+
· steps.7.steps.2 extract authorData … ms
|
|
799
|
+
· steps.7.steps.3 extract tags … ms
|
|
800
|
+
· steps.7.steps.4 hook words … ms
|
|
801
|
+
✚ record ["“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"]
|
|
802
|
+
· steps.7.steps.0 extract text … ms
|
|
803
|
+
· steps.7.steps.1 extract author … ms
|
|
804
|
+
· steps.7.steps.2 extract authorData … ms
|
|
805
|
+
· steps.7.steps.3 extract tags … ms
|
|
806
|
+
· steps.7.steps.4 hook words … ms
|
|
807
|
+
✚ record ["“It is our choices, Harry, that show what we truly are, far more than our abilities.”"]
|
|
808
|
+
· steps.7 forEach … ms
|
|
809
|
+
■ quotes-api: 2 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
|
|
810
|
+
```
|
|
811
|
+
<!-- /capture -->
|
|
812
|
+
|
|
813
|
+
Next: [5. Flow steps](04-flow-steps.md).
|