@opencraw/mcp 0.1.44 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +27 -1
  2. package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
  3. package/dist/diff-tool/index.d.ts +3 -0
  4. package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
  5. package/dist/docs-tool/docs-root.client.d.ts +8 -0
  6. package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
  7. package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
  8. package/dist/docs-tool/index.d.ts +3 -0
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.esm.js +449 -6
  11. package/dist/list-tool/index.d.ts +3 -0
  12. package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
  13. package/dist/main.d.ts +7 -0
  14. package/dist/probe-tool/index.d.ts +2 -0
  15. package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
  16. package/dist/recipe-source/index.d.ts +3 -0
  17. package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
  18. package/dist/run-tool/index.d.ts +3 -0
  19. package/dist/run-tool/run-tool.handler.d.ts +51 -0
  20. package/dist/server/create-server.use-case.d.ts +11 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
  23. package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
  24. package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
  25. package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
  26. package/dist/src/docs-tool/index.d.ts +3 -0
  27. package/dist/src/server/create-server.use-case.d.ts +1 -1
  28. package/dist/src/studio-tool/index.d.ts +2 -0
  29. package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
  30. package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
  31. package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
  32. package/dist/studio-tool/index.d.ts +2 -0
  33. package/dist/studio-tool/studio-command.client.d.ts +11 -0
  34. package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
  35. package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
  36. package/dist/validate-tool/index.d.ts +3 -0
  37. package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
  38. package/docs/how-it-works/01-recipes.md +698 -0
  39. package/docs/how-it-works/02-page-steps.md +672 -0
  40. package/docs/how-it-works/03-data-steps.md +813 -0
  41. package/docs/how-it-works/04-flow-steps.md +738 -0
  42. package/docs/how-it-works/05-templates-and-scope.md +428 -0
  43. package/docs/how-it-works/06-documents.md +1413 -0
  44. package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
  45. package/docs/how-it-works/08-policies.md +405 -0
  46. package/docs/how-it-works/09-running.md +804 -0
  47. package/docs/how-it-works/README.md +225 -0
  48. package/docs/recipes/README.md +22 -0
  49. package/docs/recipes/access.md +440 -0
  50. package/docs/recipes/authoring.md +1503 -0
  51. package/docs/recipes/azure-durable.md +180 -0
  52. package/docs/recipes/captcha.md +204 -0
  53. package/docs/recipes/movies-example.md +12 -0
  54. package/docs/recipes/programmatic-authoring.md +101 -0
  55. package/docs/recipes/quick-guide.md +157 -0
  56. package/docs/recipes/vehicles-example.md +9 -0
  57. package/docs/recipes/worker-mode.md +221 -0
  58. package/docs/studio/manual.md +312 -0
  59. package/docs/studio/pdf-agreements.md +150 -0
  60. package/package.json +29 -4
@@ -0,0 +1,738 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Flow steps
6
+
7
+ [← How OpenCraw works](README.md) · Next: [6. Templates and scope](05-templates-and-scope.md)
8
+
9
+ Flow steps decide which steps run, how often, and when a record comes out: `forEach` runs a body once per item,
10
+ `paginate` once per page, `if` picks a branch, `collect` carries values out of a loop, and `emit` produces a
11
+ record. They run the same way in web and api mode, apart from how `paginate` finds its next page: the leaf
12
+ steps inside them (`goto`, `request`, `extract`…) are what differ. This page runs each of them on a practice
13
+ site or a public API and shows the trace, because a trace is where loops and pages become visible. It ends with
14
+ what happens when a step fails, and with `limits.maxRecords`.
15
+
16
+ [authoring.md §3.3](../recipes/authoring.md#33-steps-of-both-modes), [§3.6](../recipes/authoring.md#36-pagination),
17
+ [§3.8](../recipes/authoring.md#38-decisions) and
18
+ [§7](../recipes/authoring.md#7-policies-what-happens-when-something-is-missing-or-fails) are the reference.
19
+
20
+ ## Reading a trace
21
+
22
+ `traceLine` prints one line per event, indented by how deep the step is: one level per `steps` or `else` in its
23
+ path. `steps.3.steps.2.steps.0` is the first step of the body of step 2 of the body of top-level step 3. Every
24
+ iteration and every page prints the same path: the trace has no iteration index, so count the repeats.
25
+
26
+ | Glyph | Event |
27
+ |---|---|
28
+ | `▶` `■` | The recipe starts; the recipe ends, with its counts and, if it stopped, `✖ stopped: <error>` |
29
+ | `⇢ page N <url>` | A page or document was visited (a `goto`, a `request`, a next-page click), with its status when it isn't 2xx |
30
+ | `· <path> <type> <id>` | A step finished. A control step (`forEach`, `paginate`, `if`) prints its line after its body |
31
+ | `⑂ <path> then` / `else` | An `if` chose a branch |
32
+ | `✚ record <key>` | A record was emitted, with its key |
33
+ | `↷` | A step failed and its `skip` policy let the walk go on |
34
+ | `↻` | A step failed and its `retry` policy runs it again |
35
+ | `↺` | A request failed in passing (a dropped connection, a 503) and is sent again |
36
+ | `✖` | A step failed for good, or a record was rejected |
37
+
38
+ A step skipped by its `when` prints nothing at all.
39
+
40
+ ## `forEach`
41
+
42
+ `forEach` runs its `steps` once per item of a list, each time in a **fresh child scope** where the item is bound
43
+ under `as`. With `emit: true`, the iteration's scope becomes a record once its body has run. The recipe
44
+ [`quotes-foreach`](recipes/flow-foreach/quotes-foreach.input.json) loops over the quotes of
45
+ [quotes.toscrape.com's API](https://quotes.toscrape.com/api/quotes?page=1), and over each quote's tags inside:
46
+
47
+ ```json
48
+ { "type": "set", "id": "seen", "value": [] },
49
+ { "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "steps": [
50
+ { "type": "extract", "id": "author", "from": "quote", "selector": "$.author.name", "kind": "jsonpath" },
51
+ { "type": "set", "id": "tags", "value": "{{quote.tags}}" },
52
+ { "type": "forEach", "over": "tags", "as": "tag", "steps": [
53
+ { "type": "collect", "into": "seen", "value": "{{tag}}" }
54
+ ]},
55
+ { "type": "set", "id": "tagCount", "value": "{{ len(tags) }}" },
56
+ { "type": "set", "id": "seenCount", "value": "{{ len(seen) }}" }
57
+ ]}
58
+ ```
59
+
60
+ <!-- capture:flow-foreach trace lines=16 -->
61
+ ```text
62
+ ▶ quotes-foreach (api)
63
+ ⇄ access direct (direct)
64
+ ⇢ page 1 https://quotes.toscrape.com/api/quotes?page=1
65
+ · steps.0 request list … ms
66
+ · steps.1 extract quotes … ms
67
+ · steps.2 set seen … ms
68
+ · steps.3.steps.0 extract author … ms
69
+ · steps.3.steps.1 set tags … ms
70
+ · steps.3.steps.2.steps.0 collect … ms
71
+ · steps.3.steps.2.steps.0 collect … ms
72
+ · steps.3.steps.2.steps.0 collect … ms
73
+ · steps.3.steps.2.steps.0 collect … ms
74
+ · steps.3.steps.2 forEach … ms
75
+ · steps.3.steps.3 set tagCount … ms
76
+ · steps.3.steps.4 set seenCount … ms
77
+ ✚ record (no key)
78
+ …
79
+ ```
80
+ <!-- /capture -->
81
+
82
+ The inner loop runs four times for Einstein's first quote (four `collect` lines at depth 3), and its own line,
83
+ `steps.3.steps.2 forEach`, comes after them. The scope of the first two records:
84
+
85
+ <!-- capture:flow-foreach scope ids=author,tags,tagCount,seen,seenCount record=0 -->
86
+ ```json
87
+ {
88
+ "author": "Albert Einstein",
89
+ "tags": [
90
+ "change",
91
+ "deep-thoughts",
92
+ "thinking",
93
+ "world"
94
+ ],
95
+ "tagCount": 4,
96
+ "seen": [
97
+ "change",
98
+ "deep-thoughts",
99
+ "thinking",
100
+ "world"
101
+ ],
102
+ "seenCount": 4
103
+ }
104
+ ```
105
+ <!-- /capture -->
106
+
107
+ <!-- capture:flow-foreach scope ids=author,tags,tagCount,seen,seenCount record=1 -->
108
+ ```json
109
+ {
110
+ "author": "J.K. Rowling",
111
+ "tags": [
112
+ "abilities",
113
+ "choices"
114
+ ],
115
+ "tagCount": 2,
116
+ "seen": [
117
+ "change",
118
+ "deep-thoughts",
119
+ "thinking",
120
+ "… 3 more"
121
+ ],
122
+ "seenCount": 6
123
+ }
124
+ ```
125
+ <!-- /capture -->
126
+
127
+ - **`over` takes one id, not a path.** `"over": "quote.tags"` is refused at load (`an id is a word`), which is
128
+ why the recipe `set`s `tags` first. A lone placeholder keeps its type, so `tags` is the list itself.
129
+ - **What `over` holds decides the iterations.** A list gives one per item, `undefined` or `null` gives none (and
130
+ no error), and any other value gives exactly one, with that value as the item.
131
+ - **Each iteration's scope is dropped when it ends.** `author`, `tags` and `tagCount` of the first quote are
132
+ gone when the second starts: a quote without an author could not inherit the previous one's. The inner loop's
133
+ `tag` is not in either record: its iterations ended before the emit.
134
+ - **`collect` writes into the scope that binds the list**, here the recipe's top scope, so `seen` grows across
135
+ iterations: 4 tags after the first quote, 6 after the second. Each `collect` replaces the list with a longer
136
+ copy instead of changing it, so a record keeps the list as it was at its emit.
137
+ - **`emit: true` emits the iteration's scope after its body**, with everything visible from there: the loop
138
+ variable, the ids bound in the body, and every enclosing scope's ids, `page`, `vars` and `start`.
139
+ `emit: { "output": "…" }` names the output, which must be this recipe's.
140
+ - **One emitting construct per path.** An emitting `forEach` may contain no `emit` step and no other emitting
141
+ `forEach`: the loader refuses it. The inner loop here doesn't emit.
142
+
143
+ `forEach` can also loop over the elements a CSS selector matches on the live page (`selector` instead of
144
+ `over`, web mode only), re-finding each element by position on every use; [part 3](02-page-steps.md) shows it.
145
+
146
+ ### Running iterations at once
147
+
148
+ `limits.concurrency` above 1 lets a `forEach` over a list run that many iterations at the same time. The recipe
149
+ [`mystery-parallel`](recipes/flow-concurrency/mystery-parallel.input.json) fetches the Mystery listing of
150
+ [books.toscrape.com](https://books.toscrape.com), then every book page, three at a time:
151
+
152
+ ```json
153
+ "limits": { "delayMs": 300, "concurrency": 3, "maxRecords": 6 },
154
+ "steps": [
155
+ { "type": "request", "url": "{{start.url}}" },
156
+ { "type": "extract", "id": "links", "selector": "article.product_pod h3 a", "kind": "css", "take": "attr:href", "many": true },
157
+ { "type": "forEach", "over": "links", "as": "link", "emit": true, "steps": [
158
+ { "type": "request", "url": "{{link}}" },
159
+ { "type": "extract", "id": "title", "selector": "h1", "kind": "css" },
160
+ { "type": "extract", "id": "upc", "selector": "table.table-striped td", "kind": "css" }
161
+ ]}
162
+ ]
163
+ ```
164
+
165
+ <!-- capture:flow-concurrency trace lines=50 -->
166
+ ```text
167
+ ▶ mystery-parallel (api)
168
+ ⇄ access direct (direct)
169
+ ⇢ page 1 https://books.toscrape.com/catalogue/category/books/mystery_3/index.html
170
+ · steps.0 request … ms
171
+ · steps.1 extract links … ms
172
+ ⇢ page 1 https://books.toscrape.com/catalogue/sharp-objects_997/index.html
173
+ · steps.2.steps.0 request … ms
174
+ · steps.2.steps.1 extract title … ms
175
+ · steps.2.steps.2 extract upc … ms
176
+ ✚ record ["https://books.toscrape.com/catalogue/sharp-objects_997/index.html"]
177
+ ⇢ page 1 https://books.toscrape.com/catalogue/in-a-dark-dark-wood_963/index.html
178
+ · steps.2.steps.0 request … ms
179
+ · steps.2.steps.1 extract title … ms
180
+ · steps.2.steps.2 extract upc … ms
181
+ ✚ record ["https://books.toscrape.com/catalogue/in-a-dark-dark-wood_963/index.html"]
182
+ ⇢ page 1 https://books.toscrape.com/catalogue/the-past-never-ends_942/index.html
183
+ · steps.2.steps.0 request … ms
184
+ · steps.2.steps.1 extract title … ms
185
+ · steps.2.steps.2 extract upc … ms
186
+ ✚ record ["https://books.toscrape.com/catalogue/the-past-never-ends_942/index.html"]
187
+ ⇢ page 1 https://books.toscrape.com/catalogue/a-murder-in-time_877/index.html
188
+ · steps.2.steps.0 request … ms
189
+ · steps.2.steps.1 extract title … ms
190
+ · steps.2.steps.2 extract upc … ms
191
+ ✚ record ["https://books.toscrape.com/catalogue/a-murder-in-time_877/index.html"]
192
+ ⇢ page 1 https://books.toscrape.com/catalogue/the-murder-of-roger-ackroyd-hercule-poirot-4_852/index.html
193
+ · steps.2.steps.0 request … ms
194
+ · steps.2.steps.1 extract title … ms
195
+ · steps.2.steps.2 extract upc … ms
196
+ ✚ record ["https://books.toscrape.com/catalogue/the-murder-of-roger-ackroyd-hercule-poirot-4_852/index.html"]
197
+ ⇢ page 1 https://books.toscrape.com/catalogue/the-last-mile-amos-decker-2_754/index.html
198
+ · steps.2.steps.0 request … ms
199
+ · steps.2.steps.1 extract title … ms
200
+ · steps.2.steps.2 extract upc … ms
201
+ ✚ record ["https://books.toscrape.com/catalogue/the-last-mile-amos-decker-2_754/index.html"]
202
+ ⇢ page 1 https://books.toscrape.com/catalogue/that-darkness-gardiner-and-renner-1_743/index.html
203
+ · steps.2.steps.0 request … ms
204
+ · steps.2.steps.1 extract title … ms
205
+ · steps.2.steps.2 extract upc … ms
206
+ ⇢ page 1 https://books.toscrape.com/catalogue/tastes-like-fear-di-marnie-rome-3_742/index.html
207
+ · steps.2.steps.0 request … ms
208
+ · steps.2.steps.1 extract title … ms
209
+ · steps.2.steps.2 extract upc … ms
210
+ · steps.2 forEach … ms
211
+ ■ mystery-parallel: 6 emitted, 0 rejected, 0 duplicates, 9 pages, … ms
212
+ ```
213
+ <!-- /capture -->
214
+
215
+ - **Iterations start in list order, as permits free up, and records come out in the order they finish.** Here
216
+ that is list order again: every book page answered within the 300 ms `delayMs`, so no iteration overtook
217
+ another. On a slower site a book whose page answers sooner is emitted before one listed above it.
218
+ - `delayMs` spaces **the start of every request** across the whole recipe run, whichever iteration sends it:
219
+ three in flight at most, one new one every 300 ms at most.
220
+ - `maxRecords` stays exact. The 7th and 8th books were already being fetched when the 6th record came out: the
221
+ trace shows their bodies run to the end with no `✚`, since their emits returned without producing a record,
222
+ and no 9th iteration started. That is why the summary counts 9 pages for 6 records.
223
+ - The limit is per recipe run, not per loop. A loop nested inside a concurrent iteration runs its body one item
224
+ at a time, so nesting never multiplies the number of requests in flight.
225
+ - A failure under the `fail` policy stops new iterations. The ones in flight finish, then the first failure
226
+ stops the recipe.
227
+
228
+ In web mode each concurrent iteration gets its own **tab** in the recipe's browser context: the tabs share
229
+ cookies (the login), each has its own page, and each closes when its iteration ends. A tab opens blank, so the
230
+ body must start with a `goto`. A `forEach` over `selector` always runs one element at a time, since its elements
231
+ belong to one page. [authoring §3.9](../recipes/authoring.md#39-concurrency) and [part 10](09-running.md) cover
232
+ concurrency across recipes and per site.
233
+
234
+ ## `paginate`
235
+
236
+ `paginate` runs its `steps` once per page, each page in a **fresh child scope**, and finds the next page after
237
+ each body. In the engine's own terms (`step-flow/paginate.use-case.ts`):
238
+
239
+ ```text
240
+ number = the page number of the enclosing scope (1 at the start)
241
+ for count = 1, 2, …
242
+ run the body in a new child scope whose page.number is `number` (with the cursor bound, if there is one)
243
+ stop if the body reached limits.maxRecords
244
+ stop if `until` renders truthy, rendered in the page's scope
245
+ stop if count = maxPages
246
+ ask for the next page; stop if there is none
247
+ number = number + 1; remember the next page's URL (or the cursor) for the next round
248
+ ```
249
+
250
+ Three consequences:
251
+
252
+ - **`until` is checked after the body.** The page it stops on is fully processed, and `until: "{{page.number}}"`
253
+ stops after page 1, not before it.
254
+ - **`maxPages` has no default.** A `paginate` without `until` or `maxPages` runs until the site has no next page,
255
+ which on an inert "next" link (visible, but going nowhere) is never.
256
+ - **Nothing on page N is visible on page N+1**, since each page's scope is dropped. To carry values across
257
+ pages, `collect` them ([below](#cross-page-accumulation)).
258
+
259
+ How the next page is found depends on `next` and on the mode:
260
+
261
+ | `next` | Web mode | Api mode |
262
+ |---|---|---|
263
+ | `{ "selector": … }` | If the body navigated away, go back to the page the body started on. Take the first match; if it isn't visible within 2 s, there is no next page. Click it and wait for the page to load. | Refused at load: `next.selector needs a browser` |
264
+ | `{ "url": … }` | Render the template; an empty result means no next page. Otherwise `goto` it (relative URLs allowed). | Render; empty means no next page. Otherwise resolve it against `page.url` and **fetch nothing**: the next body must `request` `{{page.url}}` itself. |
265
+ | `{ "jsonpath": … }` | Read the first match in the JSON the page body's last `request` fetched. `undefined`, `null`, `""` and `false` mean no next page. Without `as` the value must be a URL (relative allowed); with `as`, it is bound under that name in the next page's scope and `page.url` doesn't change. | Same |
266
+
267
+ ### Next by selector: clicking "next"
268
+
269
+ The recipe [`catalogue-click`](recipes/flow-paginate-click/catalogue-click.input.json) reads three pages of
270
+ [books.toscrape.com's catalogue](https://books.toscrape.com/catalogue/page-1.html) in a browser, and emits one
271
+ record per page:
272
+
273
+ ```json
274
+ { "type": "goto", "url": "{{start.url}}" },
275
+ { "type": "paginate", "next": { "selector": "li.next a" }, "maxPages": 3, "steps": [
276
+ { "type": "extract", "id": "titles", "selector": "article.product_pod h3 a", "kind": "css", "take": "attr:title", "many": true },
277
+ { "type": "extract", "id": "pager", "selector": "li.current", "kind": "css" },
278
+ { "type": "emit" }
279
+ ]}
280
+ ```
281
+
282
+ <!-- capture:flow-paginate-click screenshot alt=The_pager:_li.next_a_is_clicked_after_each_page -->
283
+ ![The pager: li.next a is clicked after each page](../assets/how-it-works/flow-paginate-click.png)
284
+ <!-- /capture -->
285
+
286
+ <!-- capture:flow-paginate-click trace -->
287
+ ```text
288
+ ▶ catalogue-click (web)
289
+ ⇄ access direct (direct)
290
+ ⇢ page 1 https://books.toscrape.com/catalogue/page-1.html
291
+ · steps.0 goto … ms
292
+ · steps.1.steps.0 extract titles … ms
293
+ · steps.1.steps.1 extract pager … ms
294
+ ✚ record [1]
295
+ · steps.1.steps.2 emit … ms
296
+ ↺ https://books.toscrape.com/catalogue/page-2.html: net::ERR_TOO_MANY_RETRIES, try 2 in … ms
297
+ ⇢ page 2 https://books.toscrape.com/catalogue/page-2.html
298
+ · steps.1.steps.0 extract titles … ms
299
+ · steps.1.steps.1 extract pager … ms
300
+ ✚ record [2]
301
+ · steps.1.steps.2 emit … ms
302
+ ↺ https://books.toscrape.com/catalogue/page-3.html: net::ERR_TOO_MANY_RETRIES, try 2 in … ms
303
+ ⇢ page 3 https://books.toscrape.com/catalogue/page-3.html
304
+ · steps.1.steps.0 extract titles … ms
305
+ · steps.1.steps.1 extract pager … ms
306
+ ✚ record [3]
307
+ · steps.1.steps.2 emit … ms
308
+ · steps.1 paginate … ms
309
+ ■ catalogue-click: 3 emitted, 0 rejected, 0 duplicates, 3 pages, … ms
310
+ ```
311
+ <!-- /capture -->
312
+
313
+ <!-- capture:flow-paginate-click records n=3 -->
314
+ ```json
315
+ {"page":1,"url":"https://books.toscrape.com/catalogue/page-1.html","pager":"Page 1 of 50","books":20,"firstTitle":"A Light in the Attic"}
316
+ {"page":2,"url":"https://books.toscrape.com/catalogue/page-2.html","pager":"Page 2 of 50","books":20,"firstTitle":"In Her Wake"}
317
+ {"page":3,"url":"https://books.toscrape.com/catalogue/page-3.html","pager":"Page 3 of 50","books":20,"firstTitle":"Slow States of Collapse: Poems"}
318
+ ```
319
+ <!-- /capture -->
320
+
321
+ Each click shows as a `⇢ page N` line between two runs of the body, and `page.number` counts the pages
322
+ (`Page 2 of 50` agrees). The navigation a "next" click causes is checked like a `goto`'s: its status, the block
323
+ rule, and the transport retry. On the network this was captured on, the proxy dropped the loads of pages 2 and 3
324
+ once each (the `↺` lines); the engine loaded the same URL again, which is safe for a link, rather than clicking a
325
+ second time. A load that still fails fails the `paginate` step with its cause (`net::…` or `HTTP 503 at …`)
326
+ instead of running the body on the browser's error page. A "next" that swaps the content in place, with no
327
+ navigation, still pages: it counts a page with no status. The run stops at `maxPages`, with 47 pages left. The URL each record carries is the
328
+ browser's real URL after the click: the web runner re-reads it after every step.
329
+
330
+ When the body leaves the listing (a `forEach` that does `goto {{link}}` on each product), the engine goes back
331
+ to the listing's URL before looking for "next". It can't restore anything else the body changed on that page (a
332
+ filter, a scroll position), so a listing that depends on them is better paged by URL.
333
+
334
+ ### Next by URL template
335
+
336
+ [quotes.toscrape.com's API](https://quotes.toscrape.com/api/quotes?page=1) takes the page as a query parameter
337
+ and says whether there is another one. The recipe
338
+ [`quotes-api-pages`](recipes/flow-paginate-url/quotes-api-pages.input.json) starts at page 9 so the end comes
339
+ quickly:
340
+
341
+ ```json
342
+ { "type": "paginate", "next": { "url": "?page={{ list.page + 1 }}" }, "until": "{{ !list.has_next }}", "maxPages": 5, "steps": [
343
+ { "type": "request", "id": "list", "url": "{{page.url}}" },
344
+ { "type": "emit" }
345
+ ]}
346
+ ```
347
+
348
+ <!-- capture:flow-paginate-url trace -->
349
+ ```text
350
+ ▶ quotes-api-pages (api)
351
+ ⇄ access direct (direct)
352
+ ⇢ page 1 https://quotes.toscrape.com/api/quotes?page=9
353
+ · steps.0.steps.0 request list … ms
354
+ ✚ record [9]
355
+ · steps.0.steps.1 emit … ms
356
+ ⇢ page 2 https://quotes.toscrape.com/api/quotes?page=10
357
+ · steps.0.steps.0 request list … ms
358
+ ✚ record [10]
359
+ · steps.0.steps.1 emit … ms
360
+ · steps.0 paginate … ms
361
+ ■ quotes-api-pages: 2 emitted, 0 rejected, 0 duplicates, 2 pages, … ms
362
+ ```
363
+ <!-- /capture -->
364
+
365
+ <!-- capture:flow-paginate-url records n=2 -->
366
+ ```json
367
+ {"apiPage":9,"pageNumber":1,"url":"https://quotes.toscrape.com/api/quotes?page=9","quotes":10,"hasNext":true}
368
+ {"apiPage":10,"pageNumber":2,"url":"https://quotes.toscrape.com/api/quotes?page=10","quotes":10,"hasNext":false}
369
+ ```
370
+ <!-- /capture -->
371
+
372
+ - In api mode `next.url` only computes the next URL. The `request` of `{{page.url}}` in the body is what fetches
373
+ it: without it, every page's body would read the same first response (or nothing).
374
+ - The template is rendered in the finished page's scope, so it can read that page's response: `list.page + 1`,
375
+ here `10`. `?page=10` is resolved against the current `page.url`, which keeps the path.
376
+ - `until` read `has_next: false` from page 10's response after its body ran, so page 10 was emitted and there
377
+ was no page 11. `maxPages: 5` is the safety net that never fired. `apiPage` (the API's own count) and
378
+ `pageNumber` (`page.number`, which starts at 1 whatever the start URL) differ.
379
+
380
+ ### Next by JSONPath
381
+
382
+ [PokeAPI](https://pokeapi.co/api/v2/pokemon?limit=4) returns the URL of the next page in `next`. The recipe
383
+ [`pokeapi-next`](recipes/flow-paginate-cursor/pokeapi-next.input.json) follows it:
384
+
385
+ ```json
386
+ { "type": "paginate", "next": { "jsonpath": "$.next" }, "maxPages": 2, "steps": [
387
+ { "type": "request", "id": "list", "url": "{{page.url}}" },
388
+ { "type": "extract", "id": "results", "selector": "$.results[*]", "kind": "jsonpath", "take": "json", "many": true },
389
+ { "type": "forEach", "over": "results", "as": "mon", "emit": true, "steps": [] }
390
+ ]}
391
+ ```
392
+
393
+ <!-- capture:flow-paginate-cursor trace -->
394
+ ```text
395
+ ▶ pokeapi-next (api)
396
+ ⇄ access direct (direct)
397
+ ⇢ page 1 https://pokeapi.co/api/v2/pokemon?limit=4
398
+ · steps.0.steps.0 request list … ms
399
+ · steps.0.steps.1 extract results … ms
400
+ ✚ record ["bulbasaur"]
401
+ ✚ record ["ivysaur"]
402
+ ✚ record ["venusaur"]
403
+ ✚ record ["charmander"]
404
+ · steps.0.steps.2 forEach … ms
405
+ ⇢ page 2 https://pokeapi.co/api/v2/pokemon?offset=4&limit=4
406
+ · steps.0.steps.0 request list … ms
407
+ · steps.0.steps.1 extract results … ms
408
+ ✚ record ["charmeleon"]
409
+ ✚ record ["charizard"]
410
+ ✚ record ["squirtle"]
411
+ ✚ record ["wartortle"]
412
+ · steps.0.steps.2 forEach … ms
413
+ · steps.0 paginate … ms
414
+ ■ pokeapi-next: 8 emitted, 0 rejected, 0 duplicates, 2 pages, … ms
415
+ ```
416
+ <!-- /capture -->
417
+
418
+ `$.next` is read from the nearest JSON document, which should be the one the page's own `request` just fetched.
419
+ A page scope inherits its parent's document, so a body without a `request` would read the same enclosing
420
+ document, and the same `next`, on every page. With no JSON document at all, the step fails with
421
+ `next.jsonpath needs a JSON document from a request in the page body`. On its last page
422
+ PokeAPI's `next` is `null`, which would end pagination by itself; `maxPages: 2` stops this run first. Each
423
+ record's `page` is the page it came from:
424
+
425
+ <!-- capture:flow-paginate-cursor records n=5 -->
426
+ ```json
427
+ {"name":"bulbasaur","url":"https://pokeapi.co/api/v2/pokemon/1/","page":1}
428
+ {"name":"ivysaur","url":"https://pokeapi.co/api/v2/pokemon/2/","page":1}
429
+ {"name":"venusaur","url":"https://pokeapi.co/api/v2/pokemon/3/","page":1}
430
+ {"name":"charmander","url":"https://pokeapi.co/api/v2/pokemon/4/","page":1}
431
+ {"name":"charmeleon","url":"https://pokeapi.co/api/v2/pokemon/5/","page":2}
432
+ ```
433
+ <!-- /capture -->
434
+
435
+ An API that returns an opaque cursor instead of a URL (`"after": "c2Vjb25k"`) needs `as`: the value is bound in
436
+ the next page's scope, and the body builds the URL itself. The next example does that.
437
+
438
+ ### Cross-page accumulation
439
+
440
+ Page scopes are dropped, so a value found on page 1 is gone on page 2. To keep values across pages, bind a list
441
+ **before** the `paginate` and `collect` into it inside. The recipe
442
+ [`pokeapi-cursor`](recipes/flow-accumulate/pokeapi-cursor.input.json) reads three pages of five names, with the
443
+ next URL bound as a cursor, and emits a single record once pagination ends:
444
+
445
+ ```json
446
+ { "type": "set", "id": "names", "value": [] },
447
+ { "type": "paginate", "next": { "jsonpath": "$.next", "as": "cursor" }, "maxPages": 3, "steps": [
448
+ { "type": "request", "id": "list", "url": "{{ cursor ?? start.url }}" },
449
+ { "type": "extract", "id": "batch", "selector": "$.results[*].name", "kind": "jsonpath", "many": true },
450
+ { "type": "collect", "into": "names", "value": "{{batch}}" }
451
+ ]},
452
+ { "type": "set", "id": "count", "value": "{{ len(names) }}" },
453
+ { "type": "emit" }
454
+ ```
455
+
456
+ <!-- capture:flow-accumulate trace -->
457
+ ```text
458
+ ▶ pokeapi-cursor (api)
459
+ ⇄ access direct (direct)
460
+ · steps.0 set names … ms
461
+ ⇢ page 1 https://pokeapi.co/api/v2/pokemon?offset=20&limit=5
462
+ · steps.1.steps.0 request list … ms
463
+ · steps.1.steps.1 extract batch … ms
464
+ · steps.1.steps.2 collect … ms
465
+ ⇢ page 2 https://pokeapi.co/api/v2/pokemon?offset=25&limit=5
466
+ · steps.1.steps.0 request list … ms
467
+ · steps.1.steps.1 extract batch … ms
468
+ · steps.1.steps.2 collect … ms
469
+ ⇢ page 3 https://pokeapi.co/api/v2/pokemon?offset=30&limit=5
470
+ · steps.1.steps.0 request list … ms
471
+ · steps.1.steps.1 extract batch … ms
472
+ · steps.1.steps.2 collect … ms
473
+ · steps.1 paginate … ms
474
+ · steps.2 set count … ms
475
+ ✚ record (no key)
476
+ · steps.3 emit … ms
477
+ ■ pokeapi-cursor: 1 emitted, 0 rejected, 0 duplicates, 3 pages, … ms
478
+ ```
479
+ <!-- /capture -->
480
+
481
+ <!-- capture:flow-accumulate record -->
482
+ ```json
483
+ {
484
+ "count": 15,
485
+ "names": [
486
+ "spearow",
487
+ "fearow",
488
+ "ekans",
489
+ "… 12 more"
490
+ ],
491
+ "pagesRead": 3,
492
+ "pageUrl": "https://pokeapi.co/api/v2/pokemon?offset=20&limit=5"
493
+ }
494
+ ```
495
+ <!-- /capture -->
496
+
497
+ - `cursor` is unset on page 1, so `{{ cursor ?? start.url }}` requests the start URL; on pages 2 and 3 it holds
498
+ PokeAPI's `next`.
499
+ - `collect` appends a list item by item: three `batch`es of five make fifteen names, not three lists.
500
+ - `names` was bound in the recipe's top scope, so it outlives every page. Without the `set`, the loader refuses
501
+ the recipe (`"names" is not a known id: set it to [] before the loop that collects into it`).
502
+ - After `paginate`, the enclosing scope's `page.number` is the last page's (`pagesRead: 3`). `page.url` is still
503
+ the start URL: with `as` the next page is a value, not a URL, and each page's `request` set `page.url` in the
504
+ page's own scope, which is gone.
505
+
506
+ The list lives in memory until the recipe ends: fine for thousands of values. To emit as you go instead, put
507
+ the `forEach` inside the `paginate` body, as the previous example does.
508
+
509
+ ## `if`, `collect` and `emit`
510
+
511
+ `if` renders its `test` and runs `steps` when it is truthy, `else` otherwise. It opens **no scope**: whatever a
512
+ branch binds is visible after the `if`. The recipe [`quotes-if`](recipes/flow-if/quotes-if.input.json) labels
513
+ each quote on [page 3 of the API](https://quotes.toscrape.com/api/quotes?page=3), where one quote has no tags,
514
+ and emits every quote except Einstein's with an explicit `emit`:
515
+
516
+ ```json
517
+ { "type": "forEach", "over": "quotes", "as": "quote", "steps": [
518
+ { "type": "if", "test": "{{ len(quote.tags) == 0 }}", "steps": [
519
+ { "type": "set", "id": "label", "value": "untagged" }
520
+ ], "else": [
521
+ { "type": "set", "id": "label", "value": "{{ join(quote.tags, ', ') }}" }
522
+ ]},
523
+ { "type": "emit", "when": "{{ quote.author.name != 'Albert Einstein' }}" }
524
+ ]}
525
+ ```
526
+
527
+ <!-- capture:flow-if trace lines=10 -->
528
+ ```text
529
+ ▶ quotes-if (api)
530
+ ⇄ access direct (direct)
531
+ ⇢ page 1 https://quotes.toscrape.com/api/quotes?page=3
532
+ · steps.0 request list … ms
533
+ · steps.1 extract quotes … ms
534
+ ⑂ steps.2.steps.0 else
535
+ · steps.2.steps.0.else.0 set label … ms
536
+ · steps.2.steps.0 if … ms
537
+ ✚ record ["“I love you without knowing how, or when, or from where. I love you simply, without problems or pride: I love you in this way because I do not know any other way of loving but this, in which there is no I or you, so intimate that your hand upon my chest is my hand, so intimate that when I fall asleep your eyes close.”"]
538
+ · steps.2.steps.1 emit … ms
539
+ …
540
+ ```
541
+ <!-- /capture -->
542
+
543
+ Every quote with tags goes through `else`, like the first one. The only `then` in the run is J.K. Rowling's
544
+ untagged quote:
545
+
546
+ <!-- capture:flow-if trace grep=⑂.*then|steps.2.steps.0.steps.0|✚_record_\["“It_is lines=3 -->
547
+ ```text
548
+ ⑂ steps.2.steps.0 then
549
+ · steps.2.steps.0.steps.0 set label … ms
550
+ ✚ 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.”"]
551
+ ```
552
+ <!-- /capture -->
553
+
554
+ <!-- capture:flow-if record record=6 -->
555
+ ```json
556
+ {
557
+ "text": "“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 …",
558
+ "author": "J.K. Rowling",
559
+ "label": "untagged",
560
+ "firstTag": null
561
+ }
562
+ ```
563
+ <!-- /capture -->
564
+
565
+ <!-- capture:flow-if summary -->
566
+ ```text
567
+ quotes-if: 8 emitted, 0 rejected, 0 duplicates, 1 pages
568
+ ```
569
+ <!-- /capture -->
570
+
571
+ - `⑂ steps.2.steps.0 then` and `else` name the branch. Branch paths end in `.steps.N` or `.else.N`.
572
+ - Both branches bind `label`, and the loader accepts that: they never both run, so the id is bound once on each
573
+ path. The `emit` after the `if` sees whichever ran.
574
+ - `test` uses recipe truthiness: `false`, `0`, `null`, missing, an empty list, and the texts `""`, `"false"`,
575
+ `"0"`, `"null"` and `"undefined"` are false ([part 6](05-templates-and-scope.md)).
576
+ - 8 records from 10 quotes: `when` skipped the `emit` for the two Einstein quotes, silently (a step skipped by
577
+ `when` prints no trace line). `when` works on any step and skips that step alone; `if` chooses between two
578
+ lists.
579
+
580
+ `emit` produces a record from **everything visible in the scope where it runs**: the iteration's values, then
581
+ each enclosing scope's, then `page`, `vars` and `start`. The snapshot is taken at the moment of the `emit`, so
582
+ later steps cannot change it. An `emit` step inside a non-emitting `forEach` is the same as `emit: true` on the
583
+ loop, except that it can be conditional, as here, or placed in one branch of an `if` (each branch counts as its
584
+ own path for the one-emit rule).
585
+
586
+ `collect` appends `value` (rendered like `set`) to the list `into` names, in whichever enclosing scope binds it.
587
+ A list is appended item by item, and a missing value appends nothing. It is the only way a value leaves a
588
+ `forEach` iteration or a `paginate` page: see [`seen`](#foreach) and [`names`](#cross-page-accumulation) above.
589
+
590
+ ## Error policies
591
+
592
+ A step that throws (an extract with no match, a request that gets a 404, a `goto` that times out) goes through
593
+ its error policy: the step's `onError`, else the recipe's `onError`, else `fail`.
594
+
595
+ | Policy | What happens | Trace |
596
+ |---|---|---|
597
+ | `{ "policy": "fail" }` (default) | The step fails as `step <path> (<type>) failed: <cause>`. The recipe stops, and its report carries the error: a failure is reported, never thrown by `run`. | `✖` |
598
+ | `{ "policy": "skip" }` | The walk goes on with the next step. A skipped extract or request leaves its id unbound. | `↷` |
599
+ | `{ "policy": "retry", "attempts": 3, "backoffMs": 500 }` | The step runs again, up to `attempts` times in all (1 to 20), waiting `backoffMs × (n − 1)` before attempt `n`: 500 ms, then 1000 ms. When the last attempt fails, the step fails as under `fail`, not `skip`. | `↻` |
600
+
601
+ ### `skip`
602
+
603
+ The recipe [`quotes-skip`](recipes/flow-onerror-skip/quotes-skip.input.json) reads the first tag of each quote
604
+ on page 3 with `onError: { "policy": "skip" }` on that extract alone:
605
+
606
+ <!-- capture:flow-onerror-skip trace grep=↷|It_is_impossible|✚_record_\["“Logic|■ lines=4 -->
607
+ ```text
608
+ ↷ steps.2.steps.1 extract firstTag skipped: no match for $.tags[0]
609
+ ✚ 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.”"]
610
+ ✚ record ["“Logic will get you from A to Z; imagination will get you everywhere.”"]
611
+ ■ quotes-skip: 10 emitted, 0 rejected, 0 duplicates, 1 steps skipped, 1 pages, … ms
612
+ ```
613
+ <!-- /capture -->
614
+
615
+ <!-- capture:flow-onerror-skip record record=7 -->
616
+ ```json
617
+ {
618
+ "text": "“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 …",
619
+ "author": "J.K. Rowling",
620
+ "label": null,
621
+ "firstTag": null
622
+ }
623
+ ```
624
+ <!-- /capture -->
625
+
626
+ The quote without tags gets `firstTag: null` (the field is `nullable`, and a missing optional field becomes
627
+ `null`: [part 9](08-policies.md)), and the next quote is read as usual. The recipe's summary counts the skipped
628
+ step.
629
+
630
+ ### `retry`
631
+
632
+ The recipe [`pokeapi-missing`](recipes/flow-onerror-retry/pokeapi-missing.input.json) asks PokeAPI for a Pokémon
633
+ that doesn't exist, with `onError: { "policy": "retry", "attempts": 3, "backoffMs": 500 }` on the request:
634
+
635
+ <!-- capture:flow-onerror-retry trace -->
636
+ ```text
637
+ ▶ pokeapi-missing (api)
638
+ ⇄ access direct (direct)
639
+ ⇢ page 1 https://pokeapi.co/api/v2/pokemon/missingno [404]
640
+ ↻ steps.0 request mon retry 2: HTTP 404 for https://pokeapi.co/api/v2/pokemon/missingno
641
+ ⇢ page 1 https://pokeapi.co/api/v2/pokemon/missingno [404]
642
+ ↻ steps.0 request mon retry 3: HTTP 404 for https://pokeapi.co/api/v2/pokemon/missingno
643
+ ⇢ page 1 https://pokeapi.co/api/v2/pokemon/missingno [404]
644
+ ✖ step steps.0 (request) failed: HTTP 404 for https://pokeapi.co/api/v2/pokemon/missingno
645
+ ■ pokeapi-missing: 0 emitted, 0 rejected, 0 duplicates, 3 pages, … ms
646
+ ✖ stopped: step steps.0 (request) failed: HTTP 404 for https://pokeapi.co/api/v2/pokemon/missingno
647
+ ```
648
+ <!-- /capture -->
649
+
650
+ A step retry runs the whole step again: three requests, three `⇢ … [404]` lines, and the recipe stops after the
651
+ third. A 404 is a client error, so retrying doesn't help here: `retry` is for failures that come and go (an
652
+ element that shows late, a flaky endpoint). It sits above the transport retry, which already re-sends a request
653
+ on a dropped connection or a 408, 425, 429, 500, 502, 503 or 504 with exponential backoff
654
+ ([part 10](09-running.md)). Each step attempt gets its own transport retries.
655
+
656
+ ### The trap: an outer `onError` doesn't catch inner steps
657
+
658
+ `onError` belongs to the step it's written on. On a `forEach`, `paginate` or `if` it covers that step's own
659
+ failures, not the failures of the steps in its body: those have already gone through their own policy (or the
660
+ recipe's) by the time they reach the loop, and they pass through it untouched. The recipe
661
+ [`quotes-outer-skip`](recipes/flow-onerror-trap/quotes-outer-skip.input.json) is `quotes-skip` with the `skip`
662
+ moved to the loop:
663
+
664
+ ```json
665
+ { "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "onError": { "policy": "skip" }, "steps": [
666
+ { "type": "extract", "id": "author", "from": "quote", "selector": "$.author.name", "kind": "jsonpath" },
667
+ { "type": "extract", "id": "firstTag", "from": "quote", "selector": "$.tags[0]", "kind": "jsonpath" }
668
+ ]}
669
+ ```
670
+
671
+ <!-- capture:flow-onerror-trap trace grep=fairy|✖ -->
672
+ ```text
673
+ ✚ record ["“If you want your children to be intelligent, read them fairy tales. If you want them to be more intelligent, read them more fairy tales.”"]
674
+ ✖ step steps.2.steps.1 (extract) failed: no match for $.tags[0]
675
+ ■ quotes-outer-skip: 7 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
676
+ ✖ stopped: step steps.2.steps.1 (extract) failed: no match for $.tags[0]
677
+ ```
678
+ <!-- /capture -->
679
+
680
+ Seven records, then the extract's `fail` (the default) stops the recipe at the eighth quote, `skip` on the loop
681
+ notwithstanding. To tolerate a failure inside a loop, put `onError` on the inner step, or set `onError` on the
682
+ recipe, which every step without its own policy uses.
683
+
684
+ Two more consequences of policies on control steps:
685
+
686
+ - **Retrying a control step re-runs its whole body**, emits included: a `forEach` retried after a failure emits
687
+ its earlier records again. De-duplication drops them only when the output has key fields.
688
+ - **A mapping failure fails the emitting step.** An emitting `forEach` whose record can't be mapped fails under
689
+ the `forEach`'s policy, and `skip` there skips the rest of the loop, not just that record. Use the mapping's
690
+ own policies for that ([part 9](08-policies.md)).
691
+
692
+ ## `limits.maxRecords`
693
+
694
+ `limits.maxRecords` stops the walk cleanly once that many records have been emitted. The recipe
695
+ [`catalogue-first-five`](recipes/flow-maxrecords/catalogue-first-five.input.json) would page through ten pages
696
+ of the catalogue, but stops at five books:
697
+
698
+ ```json
699
+ "limits": { "delayMs": 300, "maxRecords": 5 },
700
+ "steps": [
701
+ { "type": "paginate", "next": { "url": "page-{{ page.number + 1 }}.html" }, "maxPages": 10, "steps": [
702
+ { "type": "request", "url": "{{page.url}}" },
703
+ { "type": "extract", "id": "titles", "selector": "article.product_pod h3 a", "kind": "css", "take": "attr:title", "many": true },
704
+ { "type": "forEach", "over": "titles", "as": "title", "emit": true, "steps": [] }
705
+ ]}
706
+ ]
707
+ ```
708
+
709
+ <!-- capture:flow-maxrecords trace -->
710
+ ```text
711
+ ▶ catalogue-first-five (api)
712
+ ⇄ access direct (direct)
713
+ ⇢ page 1 https://books.toscrape.com/catalogue/page-1.html
714
+ · steps.0.steps.0 request … ms
715
+ · steps.0.steps.1 extract titles … ms
716
+ ✚ record ["A Light in the Attic"]
717
+ ✚ record ["Tipping the Velvet"]
718
+ ✚ record ["Soumission"]
719
+ ✚ record ["Sharp Objects"]
720
+ ✚ record ["Sapiens: A Brief History of Humankind"]
721
+ · steps.0.steps.2 forEach … ms
722
+ · steps.0 paginate … ms
723
+ ■ catalogue-first-five: 5 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
724
+ ```
725
+ <!-- /capture -->
726
+
727
+ The trace ends on page 1. After the fifth `✚`, the loop and `paginate` finish at once (their `·` lines), and no
728
+ `⇢ page 2` follows, though fifteen titles of page 1 and nine more pages were left.
729
+
730
+ - After each record, the engine compares the count with the limit. At the limit, the emit tells the step that
731
+ called it to stop, and the stop travels up through `forEach`, `paginate` and `if` to the recipe, which ends as
732
+ if it had run out of steps: no error, no next page, no next start URL.
733
+ - Only emitted records count. Rejected records and duplicates don't, so a run with many duplicates reads more
734
+ pages to reach its limit.
735
+ - With concurrent iterations the count is still exact: iterations in flight finish, but their emits produce
736
+ nothing.
737
+
738
+ Next: [6. Templates and scope](05-templates-and-scope.md).