@opencraw/mcp 0.1.44 → 0.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -1
- package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
- package/dist/diff-tool/index.d.ts +3 -0
- package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/docs-tool/index.d.ts +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.esm.js +449 -6
- package/dist/list-tool/index.d.ts +3 -0
- package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
- package/dist/main.d.ts +7 -0
- package/dist/probe-tool/index.d.ts +2 -0
- package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
- package/dist/recipe-source/index.d.ts +3 -0
- package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
- package/dist/run-tool/index.d.ts +3 -0
- package/dist/run-tool/run-tool.handler.d.ts +51 -0
- package/dist/server/create-server.use-case.d.ts +11 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/src/docs-tool/index.d.ts +3 -0
- package/dist/src/server/create-server.use-case.d.ts +1 -1
- package/dist/src/studio-tool/index.d.ts +2 -0
- package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/studio-tool/index.d.ts +2 -0
- package/dist/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/validate-tool/index.d.ts +3 -0
- package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
- package/docs/how-it-works/01-recipes.md +698 -0
- package/docs/how-it-works/02-page-steps.md +672 -0
- package/docs/how-it-works/03-data-steps.md +813 -0
- package/docs/how-it-works/04-flow-steps.md +738 -0
- package/docs/how-it-works/05-templates-and-scope.md +428 -0
- package/docs/how-it-works/06-documents.md +1413 -0
- package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
- package/docs/how-it-works/08-policies.md +405 -0
- package/docs/how-it-works/09-running.md +804 -0
- package/docs/how-it-works/README.md +225 -0
- package/docs/recipes/README.md +22 -0
- package/docs/recipes/access.md +440 -0
- package/docs/recipes/authoring.md +1503 -0
- package/docs/recipes/azure-durable.md +180 -0
- package/docs/recipes/captcha.md +204 -0
- package/docs/recipes/movies-example.md +12 -0
- package/docs/recipes/programmatic-authoring.md +101 -0
- package/docs/recipes/quick-guide.md +157 -0
- package/docs/recipes/vehicles-example.md +9 -0
- package/docs/recipes/worker-mode.md +221 -0
- package/docs/studio/manual.md +312 -0
- package/docs/studio/pdf-agreements.md +150 -0
- package/package.json +29 -4
|
@@ -0,0 +1,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
|
+

|
|
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).
|