@opencraw/mcp 0.1.45 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +28 -1
  2. package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
  3. package/dist/diff-tool/index.d.ts +3 -0
  4. package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
  5. package/dist/docs-tool/docs-root.client.d.ts +8 -0
  6. package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
  7. package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
  8. package/dist/docs-tool/index.d.ts +3 -0
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.esm.js +517 -1
  11. package/dist/list-tool/index.d.ts +3 -0
  12. package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
  13. package/dist/main.d.ts +7 -0
  14. package/dist/probe-tool/index.d.ts +2 -0
  15. package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
  16. package/dist/recipe-source/index.d.ts +3 -0
  17. package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
  18. package/dist/run-tool/index.d.ts +3 -0
  19. package/dist/run-tool/run-tool.handler.d.ts +51 -0
  20. package/dist/server/create-server.use-case.d.ts +11 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
  23. package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
  24. package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
  25. package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
  26. package/dist/src/docs-tool/index.d.ts +3 -0
  27. package/dist/src/server/create-server.use-case.d.ts +1 -1
  28. package/dist/src/studio-tool/index.d.ts +2 -0
  29. package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
  30. package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
  31. package/dist/src/studio-tool/studio-tools.handler.d.ts +68 -0
  32. package/dist/studio-tool/index.d.ts +2 -0
  33. package/dist/studio-tool/studio-command.client.d.ts +11 -0
  34. package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
  35. package/dist/studio-tool/studio-tools.handler.d.ts +68 -0
  36. package/dist/validate-tool/index.d.ts +3 -0
  37. package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
  38. package/docs/how-it-works/01-recipes.md +698 -0
  39. package/docs/how-it-works/02-page-steps.md +672 -0
  40. package/docs/how-it-works/03-data-steps.md +813 -0
  41. package/docs/how-it-works/04-flow-steps.md +738 -0
  42. package/docs/how-it-works/05-templates-and-scope.md +428 -0
  43. package/docs/how-it-works/06-documents.md +1413 -0
  44. package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
  45. package/docs/how-it-works/08-policies.md +405 -0
  46. package/docs/how-it-works/09-running.md +804 -0
  47. package/docs/how-it-works/README.md +225 -0
  48. package/docs/recipes/README.md +22 -0
  49. package/docs/recipes/access.md +440 -0
  50. package/docs/recipes/authoring.md +1503 -0
  51. package/docs/recipes/azure-durable.md +180 -0
  52. package/docs/recipes/captcha.md +204 -0
  53. package/docs/recipes/movies-example.md +12 -0
  54. package/docs/recipes/programmatic-authoring.md +101 -0
  55. package/docs/recipes/quick-guide.md +157 -0
  56. package/docs/recipes/vehicles-example.md +9 -0
  57. package/docs/recipes/worker-mode.md +221 -0
  58. package/docs/studio/manual.md +314 -0
  59. package/docs/studio/pdf-agreements.md +150 -0
  60. package/package.json +29 -4
@@ -0,0 +1,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
+ ![quotes.toscrape.com, with what each extract selects](../assets/how-it-works/data-quotes-web.png)
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
+ ![The /js/ page in a browser: every quote is drawn by a script](../assets/how-it-works/data-regex-js.png)
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
+ ![The hockey table: the header row the selector matches, and the rows](../assets/how-it-works/data-table.png)
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
+ ![The product table: each row is one fragment](../assets/how-it-works/data-fragments.png)
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
+ ![Each grid row is one match: a wrapper of three countries](../assets/how-it-works/data-wrapper-trap.png)
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
+ ![The login form the request reads, and the header link](../assets/how-it-works/data-form.png)
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).