@opencraw/mcp 0.1.45 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +430 -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 +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,804 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Running
6
+
7
+ [How OpenCraw works](README.md), part 10 of 10.
8
+
9
+ The other parts follow the steps: what a step sees, what it keeps, how a value becomes a field. This part is about
10
+ what surrounds them while a crawl runs: the run itself, the browser or HTTP session each recipe gets, the network it
11
+ goes through, what counts as a block, how fast it may go, what it retries, where it may go, how much runs at once,
12
+ and how you watch all of it.
13
+
14
+ Every trace below comes from a run against [books.toscrape.com](https://books.toscrape.com) or
15
+ [quotes.toscrape.com](https://quotes.toscrape.com), two sites built for scraping practice. Where something can't be
16
+ shown safely on a live site (captchas, proxies, a worker pool under real load), the page says so and quotes the
17
+ engine's own end-to-end tests instead, labelled as such.
18
+
19
+ **Contents**
20
+
21
+ 1. [The run](#1-the-run)
22
+ 2. [Sessions](#2-sessions)
23
+ 3. [Access, proxies and blocks](#3-access-proxies-and-blocks)
24
+ 4. [Pace and retries](#4-pace-and-retries)
25
+ 5. [Captchas](#5-captchas)
26
+ 6. [Allowed hosts](#6-allowed-hosts)
27
+ 7. [Concurrency and worker mode](#7-concurrency-and-worker-mode)
28
+ 8. [Events and the trace](#8-events-and-the-trace)
29
+
30
+ ## 1. The run
31
+
32
+ ### The crawler and `run`
33
+
34
+ ```ts
35
+ import { createCrawler, jsonLinesSink, loadRecipeSet, traceLine } from '@opencraw/core'
36
+
37
+ const recipes = await loadRecipeSet({ output: 'recipes/book.output.json', inputs: ['recipes/'] })
38
+ const crawler = createCrawler({
39
+ sink: jsonLinesSink('out/books.jsonl'),
40
+ onEvent: event => { const line = traceLine(event); if (line) console.log(line) },
41
+ })
42
+ try {
43
+ const report = await crawler.run(recipes)
44
+ } finally {
45
+ await crawler.close()
46
+ }
47
+ ```
48
+
49
+ `createCrawler` builds what every run of this crawler shares: the sink, the hooks, the captcha solvers, the access
50
+ broker, the per-site throttle, the host allowlist and the event bus. It checks the configuration then (an access
51
+ profile that can't work, two solvers with one name, a bad host pattern, `resume` with a sink that can't answer), so a
52
+ mistake fails before any page loads. It does **not** start a browser: the first recipe or bootstrap that needs one
53
+ launches it, and every later one shares it. A browser that crashed is launched again on the next need. `close()`
54
+ closes it.
55
+
56
+ `run(set)` opens the sink once, runs the set's input recipes, closes the sink, and returns the report. A recipe that
57
+ fails doesn't throw: its failure is written into its entry of the report, and the others go on (unless
58
+ `onRecipeError: 'stop'`).
59
+
60
+ ### Recipe runs: start points and variants
61
+
62
+ A **recipe run** is one input recipe with one set of vars. A recipe without a `matrix` gives one run. With a
63
+ `matrix`, it gives one run per combination, one after the other:
64
+
65
+ - an object of lists (`{ "state": ["DL", "GA"], "group": ["Bus", "Car"] }`) runs every combination, four here;
66
+ - a list of var sets runs exactly those.
67
+
68
+ Inside a run, the **start points** run in order, each from a fresh scope with `start.url`, `vars` and page 1. A run
69
+ that reaches `maxRecords` stops there, remaining start points included.
70
+
71
+ Where a var's value comes from, weakest first:
72
+
73
+ | Source | Example |
74
+ |---|---|
75
+ | the recipe's `vars` | `"vars": { "category": "Travel" }` |
76
+ | the matrix combination, or in worker mode the work item's vars | `"matrix": [{ "category": "Poetry" }]` |
77
+ | the start point's own `vars` | `"start": [{ "url": "…", "vars": { "category": "Art" } }]` |
78
+
79
+ The last row is the surprising one: a start point's vars beat the matrix and the work item.
80
+
81
+ [`category-firsts.input.json`](recipes/run-matrix/category-firsts.input.json) is an api recipe with a two-entry
82
+ matrix. Each variant requests its category's listing (`{{vars.slug}}/index.html`, relative to the start URL) and keeps
83
+ two books:
84
+
85
+ <!-- capture:run-matrix trace grep=▶|⇢|✚|■ -->
86
+ ```text
87
+ ▶ category-firsts [category=Travel, slug=travel_2] (api)
88
+ ⇢ page 1 https://books.toscrape.com/catalogue/category/books/travel_2/index.html
89
+ ✚ record ["https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html"]
90
+ ✚ record ["https://books.toscrape.com/catalogue/full-moon-over-noahs-ark-an-odyssey-to-mount-ararat-and-beyond_811/index.html"]
91
+ ■ category-firsts [category=Travel, slug=travel_2]: 2 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
92
+ ▶ category-firsts [category=Poetry, slug=poetry_23] (api)
93
+ ⇢ page 1 https://books.toscrape.com/catalogue/category/books/poetry_23/index.html
94
+ ✚ record ["https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"]
95
+ ✚ record ["https://books.toscrape.com/catalogue/the-black-maria_991/index.html"]
96
+ ■ category-firsts [category=Poetry, slug=poetry_23]: 2 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
97
+ ```
98
+ <!-- /capture -->
99
+
100
+ The variant is part of the run's identity: `▶` and `■` show it in brackets, and each variant has its own entry in the
101
+ report. The summary here prints one line per entry, without the variant:
102
+
103
+ <!-- capture:run-matrix summary -->
104
+ ```text
105
+ category-firsts: 2 emitted, 0 rejected, 0 duplicates, 1 pages
106
+ category-firsts: 2 emitted, 0 rejected, 0 duplicates, 1 pages
107
+ ```
108
+ <!-- /capture -->
109
+
110
+ ### The report
111
+
112
+ `run` returns a `CrawlReport`:
113
+
114
+ | Field | What it holds |
115
+ |---|---|
116
+ | `outputId` | the output recipe the set feeds |
117
+ | `recipes` | one `RecipeReport` per recipe run (per variant), in the set's order whatever order they finished in |
118
+ | `records` | the sum of `emitted` |
119
+ | `sink` | what the sink's `close()` returned: `{ written, location? }` |
120
+ | `durationMs` | the whole run |
121
+
122
+ and each `RecipeReport`:
123
+
124
+ | Field | What it counts |
125
+ |---|---|
126
+ | `recipeId`, `variant?`, `mode`, `item?` | which run this was (`item` in worker mode) |
127
+ | `emitted` | records written to the sink |
128
+ | `rejected` | records a `skip-record` policy dropped ([part 9](08-policies.md)) |
129
+ | `duplicates` | records whose key was already seen ([de-duplication](#the-sink-and-de-duplication)) |
130
+ | `skipped` | records a resumed run already had ([resume](#resume)) |
131
+ | `stepsSkipped` | steps whose `onError: skip` swallowed a failure |
132
+ | `pages` | `page:visit` events, bootstrap pages included |
133
+ | `captchas?` | `{ detected, solved, failed }`, only when a challenge was seen |
134
+ | `error?`, `errorKind?` | why the run stopped, and the kind: `captcha`, `blocked`, `host`, `browser`, `http`, `timeout`, `network`, `mapping`, `step` or `error` |
135
+ | `durationMs` | this run |
136
+
137
+ ### The sink and de-duplication
138
+
139
+ The sink receives the records: `memorySink()` (the default; `records` holds them) or `jsonLinesSink(path)` (one JSON
140
+ object per line, with a `_source` member), or anything with `open`, `write`, `close` and optionally `has(key)`. Every
141
+ emit of a run goes through one queue, so the sink sees one record at a time however many iterations run at once.
142
+
143
+ Between the mapping and the sink, a record passes two checks, in this order:
144
+
145
+ 1. **resume**: with `resume: true`, a key the sink already `has` is `skipped`;
146
+ 2. **de-duplication**: a key already seen is a `duplicate`. `dedupe` sets where "already seen" looks: `run` (the
147
+ default for `run`: one set shared by every recipe of the call), `recipe` (one set per recipe run, the default in
148
+ worker mode, where a run is an item) or `off`. Records without key fields always pass.
149
+
150
+ ### Resume
151
+
152
+ A crawl that dies halfway doesn't have to start over. Open the sink in append mode and pass `resume: true`:
153
+
154
+ ```ts
155
+ createCrawler({ sink: jsonLinesSink('out/books.jsonl', { append: true }), resume: true })
156
+ ```
157
+
158
+ In append mode every line carries the record's key as `_key`, and `open` reads the keys already in the file.
159
+
160
+ [`travel-list.input.json`](recipes/run-resume/travel-list.input.json) reads the 11 books of the Travel category. An
161
+ earlier run of it stopped after five records, and left
162
+ [`earlier-run.jsonl`](recipes/run-resume/earlier-run.jsonl). The scene runs the recipe again with `resume: true` and a
163
+ `jsonLinesSink` in append mode over a copy of that file:
164
+
165
+ <!-- capture:run-resume trace grep=⇢|⤼|✚|■ -->
166
+ ```text
167
+ ⇢ page 1 https://books.toscrape.com/catalogue/category/books/travel_2/index.html
168
+ ⤼ skipped ["https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html"]
169
+ ⤼ skipped ["https://books.toscrape.com/catalogue/full-moon-over-noahs-ark-an-odyssey-to-mount-ararat-and-beyond_811/index.html"]
170
+ ⤼ skipped ["https://books.toscrape.com/catalogue/see-america-a-celebration-of-our-national-parks-treasured-sites_732/index.html"]
171
+ ⤼ skipped ["https://books.toscrape.com/catalogue/vagabonding-an-uncommon-guide-to-the-art-of-long-term-world-travel_552/index.html"]
172
+ ⤼ skipped ["https://books.toscrape.com/catalogue/under-the-tuscan-sun_504/index.html"]
173
+ ✚ record ["https://books.toscrape.com/catalogue/a-summer-in-europe_458/index.html"]
174
+ ✚ record ["https://books.toscrape.com/catalogue/the-great-railway-bazaar_446/index.html"]
175
+ ✚ record ["https://books.toscrape.com/catalogue/a-year-in-provence-provence-1_421/index.html"]
176
+ ✚ record ["https://books.toscrape.com/catalogue/the-road-to-little-dribbling-adventures-of-an-american-in-britain-notes-from-a-small-island-2_277/index.html"]
177
+ ✚ record ["https://books.toscrape.com/catalogue/neither-here-nor-there-travels-in-europe_198/index.html"]
178
+ ✚ record ["https://books.toscrape.com/catalogue/1000-places-to-see-before-you-die_1/index.html"]
179
+ ■ travel-list: 6 emitted, 0 rejected, 0 duplicates, 5 skipped, 1 pages, … ms
180
+ ```
181
+ <!-- /capture -->
182
+
183
+ The five books the file had are `⤼ skipped`, the six others are written, and `■` counts both. The steps still ran for
184
+ the five: the engine has to map a record to know its key. A resumed run saves the duplicates, not the requests.
185
+
186
+ Two things to know:
187
+
188
+ - **A key repeated within the same run is also `skipped`**, not `duplicate`. Both built-in sinks' `has` sees what
189
+ was written earlier in this run too, and resume is checked before de-duplication.
190
+ - **A `jsonLinesSink` without `append` has nothing to resume from.** It passes the check at `createCrawler` (it has a
191
+ `has`), but it empties the file on open, so only keys written during this run are found.
192
+
193
+ ### Worker mode holds records
194
+
195
+ In worker mode ([section 7](#worker-mode)) a run's records are mapped as it goes, so `record:reject` fires live, but
196
+ they are **held**. Only when the whole item succeeds do they go through resume, de-duplication and the sink, and
197
+ `record:emit` fires. A failed item writes nothing and leaves no key behind, so it can be run again cleanly.
198
+
199
+ ### Change detection
200
+
201
+ Change detection isn't part of `run`: it compares two runs' records afterwards. Records have keys, so the comparison
202
+ is record by record: `added`, `removed`, and `changed` with each field before and after (nested fields dotted:
203
+ `price.amount`). Order doesn't matter, the fields the engine stamps (`generated: now`, `uuid`) are ignored, and a run
204
+ with under half the previous run's records is flagged as `shrunk`. A site that changes its markup rarely makes a
205
+ recipe fail; it makes it find less.
206
+
207
+ ```sh
208
+ opencraw run recipes/ --out books.jsonl --diff books.jsonl --changes changes.jsonl
209
+ ```
210
+
211
+ From code, `diffRecords(previous, current, diffOptionsFor(output))`. [authoring.md §8.1](../recipes/authoring.md#81-change-detection)
212
+ has the details.
213
+
214
+ ## 2. Sessions
215
+
216
+ Each recipe run gets its own session, opened when the run starts (after its access lease, [section 3](#3-access-proxies-and-blocks))
217
+ and closed when it ends:
218
+
219
+ | Mode | Session | Opened how |
220
+ |---|---|---|
221
+ | `web` | a new **browser context** with one page, on the crawler's shared browser | with the lease's proxy and headers, the recipe's `userAgent`, `viewport`, `cookies`, and the saved state (below) |
222
+ | `web` with `session.browserProfile` | a persistent profile of the runner (cookies, storage, cache kept between runs) | [access.md](../recipes/access.md#persistent-browser-profiles) |
223
+ | `api` | an **HTTP session** (Playwright's request context): no browser, cookies kept between requests | with the same proxy, headers and user agent, and the saved state's cookies |
224
+
225
+ A context is a browser's private window: the recipes of one crawler share the browser process but not their cookies.
226
+ A `forEach` with `concurrency` adds tabs to the run's context ([section 7](#7-concurrency-and-worker-mode)). A
227
+ rotation ([section 3](#blocks-and-rotation)) closes nothing in use: it opens a new session beside the old one.
228
+
229
+ ### `session.bootstrap`
230
+
231
+ Some sites must be entered before they can be crawled: a login, a cookie wall, a country picker. `session.bootstrap`
232
+ runs steps **before** the crawl, always in a browser (an api recipe gets a throwaway browser context for it, on the
233
+ same lease, so the login and the crawl come from one IP), then keeps what `keep` lists:
234
+
235
+ - `cookies`: the context's cookies;
236
+ - `localStorage`: each origin's local storage.
237
+
238
+ A `web` recipe starts its context from that state; an `api` recipe sends those cookies with every request. The
239
+ bootstrap never emits, runs its steps one at a time under the recipe's `delayMs`, and sees the recipe's `vars` (not a
240
+ start point's).
241
+
242
+ [`quotes-signed-in.input.json`](recipes/run-session/quotes-signed-in.input.json) is an api recipe that logs in to
243
+ quotes.toscrape.com in its bootstrap (the site takes any name and password), then reads the home page over plain HTTP.
244
+ [`quotes-anonymous.input.json`](recipes/run-session/quotes-anonymous.input.json) is the same recipe without the
245
+ bootstrap. Both check for the Logout link first:
246
+
247
+ <!-- capture:run-session trace lines=30 -->
248
+ ```text
249
+ ▶ quotes-anonymous (api)
250
+ ⇄ access direct (direct)
251
+ ⇢ page 1 https://quotes.toscrape.com/
252
+ · steps.0 request home … ms
253
+ ✖ step steps.1 (extract) failed: no match for a[href='/logout']
254
+ ■ quotes-anonymous: 0 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
255
+ ✖ stopped: step steps.1 (extract) failed: no match for a[href='/logout']
256
+ ▶ quotes-signed-in (api)
257
+ ⇄ access direct (direct)
258
+ ⇢ page 1 https://quotes.toscrape.com/login
259
+ · session.bootstrap.steps.0 goto … ms
260
+ · session.bootstrap.steps.1 fill … ms
261
+ · session.bootstrap.steps.2 fill … ms
262
+ ⇢ page 1 https://quotes.toscrape.com/
263
+ · session.bootstrap.steps.3 click … ms
264
+ · session.bootstrap.steps.4 wait … ms
265
+ ⇢ page 1 https://quotes.toscrape.com/
266
+ · steps.0 request home … ms
267
+ · steps.1 extract logout … ms
268
+ · steps.2 extract quotes … ms
269
+ · steps.3.steps.0 extract text … ms
270
+ · steps.3.steps.1 extract author … ms
271
+ · steps.3.steps.2 extract goodreads … ms
272
+ ✚ record ["“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"]
273
+ · steps.3.steps.0 extract text … ms
274
+ · steps.3.steps.1 extract author … ms
275
+ · steps.3.steps.2 extract goodreads … ms
276
+ ✚ record ["“It is our choices, Harry, that show what we truly are, far more than our abilities.”"]
277
+ · steps.3 forEach … ms
278
+ ■ quotes-signed-in: 2 emitted, 0 rejected, 0 duplicates, 3 pages, … ms
279
+ ```
280
+ <!-- /capture -->
281
+
282
+ The bootstrap's steps have paths under `session.bootstrap.steps`, and its pages count: `3 pages` is the login page, the page
283
+ the login click lands on, and the home page the api recipe reads. The HTTP session carried the login cookie, so the page it got is the logged-in one, with a Goodreads
284
+ link that anonymous visitors don't see:
285
+
286
+ <!-- capture:run-session records n=2 -->
287
+ ```json
288
+ {"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","goodreads":"http://goodreads.com/author/show/9810.Albert_Einstein"}
289
+ {"text":"“It is our choices, Harry, that show what we truly are, far more than our abilities.”","author":"J.K. Rowling","goodreads":"http://goodreads.com/author/show/1077326.J_K_Rowling"}
290
+ ```
291
+ <!-- /capture -->
292
+
293
+ The anonymous recipe got the same page without the cookie, and its check failed. [Part 3](02-page-steps.md) logs in
294
+ the other way, with the login steps in the recipe's own steps, in web mode.
295
+
296
+ **Saving and reusing a session.** `bootstrap.saveTo` writes what was kept to a JSON file (relative to the crawler's
297
+ `storageStateDir`). Another recipe, or a later run, starts from it with `session.storageStatePath`:
298
+
299
+ - when `storageStatePath` is set, the bootstrap is **skipped**, even if the recipe has one;
300
+ - the file must **exist**. A missing file fails the run, so a recipe can't point `storageStatePath` at its own
301
+ `saveTo` and expect the first run to create it. Run the bootstrap once (without `storageStatePath`) to create the
302
+ file.
303
+
304
+ With a `browserProfile` and no `storageStatePath`, the bootstrap runs in the profile on every run. An api recipe with a
305
+ profile and no bootstrap starts from the cookies the profile holds, so it can pick up a login a browser left there.
306
+
307
+ ## 3. Access, proxies and blocks
308
+
309
+ ### Profiles and leases
310
+
311
+ Where traffic goes is the runner's business, not the recipe's. The crawler's `access` config names **profiles**:
312
+ `direct`, a `proxy` (a server, or a **preset** for a known provider such as Bright Data, Oxylabs or Zyte, with sticky or
313
+ per-request sessions), a `pool` of proxies taken in turn, a remote browser over `cdp`, or a `plugin`. A recipe asks for
314
+ what it needs (`session.access: { profile?, country?, sticky? }`) and never holds credentials.
315
+
316
+ Each recipe run takes one **lease** from its profile: a proxy server with a fresh session id (so a new IP on rotating
317
+ providers), or the next proxy of a pool, or a remote browser. The bootstrap and the crawl share it. The trace's `⇄`
318
+ line names the lease, never the credentials; every trace on this page runs `direct`:
319
+
320
+ ```text
321
+ ⇄ access direct (direct)
322
+ ⇄ access residential (proxy https://brd.superproxy.io:44445, session k3v9x0q2ma)
323
+ ```
324
+
325
+ The second line is from [access.md](../recipes/access.md), which covers profiles, presets, pools, remote browsers and
326
+ plugins in full. No proxy was used for this page.
327
+
328
+ ### Block detection
329
+
330
+ After each `goto` and each `request`, and after every navigation a `click`, `press`, `select` or pagination
331
+ click causes, the response is checked against the recipe's **block rule**. When nothing is set, the default rule
332
+ is:
333
+
334
+ ```json
335
+ { "status": [403, 429], "header": { "x-amzn-waf-action": "challenge" } }
336
+ ```
337
+
338
+ `session.blockedWhen` has three optional conditions, any one of which is a block: `status` (a list), `header` (a name
339
+ mapped to a case-insensitive pattern for its value) and `text` (a case-insensitive pattern for the body). **A rule you
340
+ set replaces the default; it isn't merged with it.** `"blockedWhen": { "text": "verify you are human" }` no longer
341
+ treats a 403 as a block. List `403` and `429` again if you still want them.
342
+
343
+ A block fails the step with a `BlockedError` naming the URL and the reason (`errorKind: 'blocked'`), and emits
344
+ `access:blocked`. The point is the error message: a challenge page otherwise shows up later as a selector that matched
345
+ nothing.
346
+
347
+ Anything not in the rule isn't a block. A 404 in a browser is a page like any other: the `goto` succeeds and the
348
+ next step finds nothing. [`missing-plain.input.json`](recipes/run-not-found/missing-plain.input.json) opens a book page
349
+ that doesn't exist, with the default rule. `[404]` on the `⇢` line is the status, shown whenever it isn't 2xx:
350
+
351
+ <!-- capture:run-not-found trace -->
352
+ ```text
353
+ ▶ missing-plain (web)
354
+ ⇄ access direct (direct)
355
+ ⇢ page 1 https://books.toscrape.com/catalogue/no-such-book_0/index.html [404]
356
+ · steps.0 goto … ms
357
+ ✖ step steps.1 (extract) failed: no match for div.product_main h1
358
+ ■ missing-plain: 0 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
359
+ ✖ stopped: step steps.1 (extract) failed: no match for div.product_main h1
360
+ ```
361
+ <!-- /capture -->
362
+
363
+ (In api mode a 404 fails the `request` anyway, as an `HttpError`. A block rule turns that into a block, which is what
364
+ triggers rotation.)
365
+
366
+ ### Blocks and rotation
367
+
368
+ `session.onBlock: { rotate: true, attempts }` makes a block take a **new lease** (a new session id, so a new IP on a
369
+ rotating proxy), open a new session, run the bootstrap again (a login on the new IP), and retry the blocked step.
370
+ `attempts` caps the rotations per recipe run (default 2). A rotation doesn't spend the step's own retry attempts. When
371
+ the rotations are used up, the block goes to the step's `onError` like any other error.
372
+
373
+ [`missing-blocked.input.json`](recipes/run-blocked/missing-blocked.input.json) requests the same missing page over
374
+ HTTP with every layer switched on, so the order they fire in shows:
375
+
376
+ ```json
377
+ "session": { "blockedWhen": { "status": [404] }, "onBlock": { "rotate": true, "attempts": 1 } },
378
+ "limits": { "delayMs": 300, "retry": { "attempts": 2, "backoffMs": 500, "statuses": [404] } },
379
+ "steps": [
380
+ { "type": "request", "id": "book", "url": "{{start.url}}", "as": "html", "onError": { "policy": "retry", "attempts": 2, "backoffMs": 1000 } },
381
+ ```
382
+
383
+ <!-- capture:run-blocked trace -->
384
+ ```text
385
+ ▶ missing-blocked (api)
386
+ ⇄ access direct (direct)
387
+ ↺ https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404, try 2 in … ms
388
+ ⇢ page 1 https://books.toscrape.com/catalogue/no-such-book_0/index.html [404]
389
+ ⛔ blocked https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404
390
+ ↻ new access lease (attempt 2)
391
+ ⇄ access direct (direct)
392
+ ↺ https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404, try 2 in … ms
393
+ ⇢ page 1 https://books.toscrape.com/catalogue/no-such-book_0/index.html [404]
394
+ ⛔ blocked https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404
395
+ ↻ steps.0 request book retry 2: blocked at https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404
396
+ ↺ https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404, try 2 in … ms
397
+ ⇢ page 1 https://books.toscrape.com/catalogue/no-such-book_0/index.html [404]
398
+ ⛔ blocked https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404
399
+ ✖ step steps.0 (request) failed: blocked at https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404
400
+ ■ missing-blocked: 0 emitted, 0 rejected, 0 duplicates, 3 pages, … ms
401
+ ✖ stopped: step steps.0 (request) failed: blocked at https://books.toscrape.com/catalogue/no-such-book_0/index.html: HTTP 404
402
+ ```
403
+ <!-- /capture -->
404
+
405
+ Read it from the top:
406
+
407
+ 1. `↺`: the 404 is in `limits.retry.statuses`, so the request is first retried as a failure in passing, once
408
+ (`attempts: 2` is two tries in all).
409
+ 2. `⇢ … [404]`, then `⛔`: the last response still says 404, and the block rule matches.
410
+ 3. `↻ new access lease`, `⇄`: the one rotation allowed. On a `direct` profile it reopens the session from the same
411
+ IP, which is why it can't help here.
412
+ 4. The same request on the new session: `↺`, `⛔` again. No rotation left, so the step's own policy decides.
413
+ 5. `↻ steps.0 request book retry 2`: the step retry, a second attempt of the whole step, with its own transport
414
+ retries.
415
+ 6. `✖`: nothing left. The run stops with the block, as `errorKind: 'blocked'`.
416
+
417
+ Six requests for one URL: two tries × (first session + rotation + step retry). That is the cost of stacking every
418
+ layer. Step 1 is also why a **429 is retried before it counts as a block**: 429 is in both the default retry
419
+ statuses and the default block rule, and the block rule only sees the response the retries end with. A site that
420
+ answers 429 once and then 200 never shows as blocked; one that keeps answering 429 does. [Section 4](#two-kinds-of-retry)
421
+ has the retry rules, and [access.md](../recipes/access.md#blocks-and-rotation) covers blocks and rotation in full.
422
+
423
+ ## 4. Pace and retries
424
+
425
+ ### Two limits on pace
426
+
427
+ | Setting | Scope | What it bounds |
428
+ |---|---|---|
429
+ | `limits.delayMs` (input recipe) | one recipe run | the minimum time between two **request starts** in that run, whatever loop or tab starts them |
430
+ | `limits.concurrency` (input recipe) | one recipe run | how many `forEach` iterations run at once ([section 7](#7-concurrency-and-worker-mode)) |
431
+ | `throttle` (crawler, or `access.throttle`) | a **site**, across every recipe, run and worker window of the crawler | `delayMs` between request starts to that site, and `concurrency` (requests in flight) |
432
+
433
+ Both count navigations, `request` steps, bootstrap pages and pagination clicks. A page's own sub-requests (images,
434
+ scripts, XHR) aren't counted. Where both apply, a request waits for both: the stricter one wins. Under `throttle`,
435
+ `domains` entries cover a domain and its subdomains, the longest match winning; a host no rule covers is not held
436
+ back.
437
+
438
+ [`fantasy-pages`](recipes/run-throttle/fantasy-pages.input.json) and
439
+ [`sequential-art-pages`](recipes/run-throttle/sequential-art-pages.input.json) each read three listing pages of a
440
+ category, with `delayMs: 300` of their own. The scene runs them side by side (`parallel: 2`) under a crawler throttle
441
+ of one request a second for the site:
442
+
443
+ ```js
444
+ createCrawler({ parallel: 2, throttle: { domains: { 'books.toscrape.com': { delayMs: 1000, concurrency: 1 } } } })
445
+ ```
446
+
447
+ <!-- capture:run-throttle trace grep=▶|⇄|⇢|■ -->
448
+ ```text
449
+ ▶ fantasy-pages (api)
450
+ ▶ sequential-art-pages (api)
451
+ ⇄ access direct (direct)
452
+ ⇄ access direct (direct)
453
+ ⇢ page 1 https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html
454
+ ⇢ page 1 https://books.toscrape.com/catalogue/category/books/sequential-art_5/index.html
455
+ ⇢ page 2 https://books.toscrape.com/catalogue/category/books/fantasy_19/page-2.html
456
+ ⇢ page 2 https://books.toscrape.com/catalogue/category/books/sequential-art_5/page-2.html
457
+ ⇢ page 3 https://books.toscrape.com/catalogue/category/books/fantasy_19/page-3.html
458
+ ■ fantasy-pages: 3 emitted, 0 rejected, 0 duplicates, 3 pages, … ms
459
+ ⇢ page 3 https://books.toscrape.com/catalogue/category/books/sequential-art_5/page-3.html
460
+ ■ sequential-art-pages: 3 emitted, 0 rejected, 0 duplicates, 3 pages, … ms
461
+ ```
462
+ <!-- /capture -->
463
+
464
+ Both recipes start together, but their pages alternate: the site has one lane, and every request waits for the one
465
+ before it, whichever recipe sent it. The capture strips timings, so here they are from a timed run of the same two
466
+ recipes, the same day. The responses arrived at 0.37 s, 1.31 s, 2.13 s, 3.10 s, 4.08 s and 5.11 s, about one a
467
+ second, and the recipes took 4.1 s and 5.1 s. Without the throttle, the same run took 0.78 s and 0.81 s: each recipe
468
+ kept its own 300 ms spacing, and nothing held one back for the other.
469
+
470
+ <!-- capture:run-throttle summary -->
471
+ ```text
472
+ fantasy-pages: 3 emitted, 0 rejected, 0 duplicates, 3 pages
473
+ sequential-art-pages: 3 emitted, 0 rejected, 0 duplicates, 3 pages
474
+ ```
475
+ <!-- /capture -->
476
+
477
+ ### Two kinds of retry
478
+
479
+ There are two retry mechanisms, one inside the other, and they behave differently:
480
+
481
+ | | Transport retry: `limits.retry` | Step retry: `onError: { "policy": "retry" }` |
482
+ |---|---|---|
483
+ | Repeats | one request: a `goto`'s load, a `request`, a web `paginate`'s next page by URL | the whole step; for `forEach`, `paginate` or `if`, its whole body |
484
+ | On | a failure in passing: a dropped or refused connection, a timeout, a DNS lookup that couldn't run, a proxy tunnel that failed, or a status in `statuses` (default 408, 425, 429, 500, 502, 503, 504) | any error of the step: no match, a block once rotation is used up, an HTTP error, a failed emit |
485
+ | Not on | `ENOTFOUND` / `ERR_NAME_NOT_RESOLVED` (a typo in a host name), any other status | a failure an inner step already reported: it passes through the outer step's policy |
486
+ | Default | **on**: 3 tries in all | **off**: the policy defaults to `fail` |
487
+ | Pause | **exponential with jitter**: `backoffMs × 2^(n−1)`, ×0.75 to ×1.25, so 1 s then 2 s by default, capped at `maxDelayMs` (30 s). A `Retry-After` is obeyed as given, and holds back the whole site; one longer than `maxDelayMs` is not retried at all | **linear**: `backoffMs × (attempt − 1)`, and `backoffMs` defaults to **0 ms**: an immediate retry |
488
+ | Budget | `attempts` (1 to 10) and/or `forMs`, a time budget (with `forMs` alone, tries aren't counted) | `attempts` (1 to 20) |
489
+ | Event | `request:retry`: `↺ <url>: <reason>, try N in D ms` | `step:retry`: `↻ <path> <type> retry N: <error>` |
490
+
491
+ A step retry runs its requests again, each with its own transport retries: in the trace above, one step retry cost
492
+ two more requests. Retrying a `forEach` that emits re-runs the whole loop and emits its records again; de-duplication
493
+ drops the repeats only when the output has key fields.
494
+
495
+ `limits.retry` in a recipe is laid over the crawler's `retry`, which is laid over the defaults, field by field.
496
+
497
+ **Not shown live:** a site that answers 429 on purpose. The engine's end-to-end test does it against a local fixture
498
+ site that answers 503 once to a browser and 429 with `Retry-After: 1` once to an api recipe
499
+ ([`e2e/retry.e2e.test.ts`](../../packages/core/e2e/retry.e2e.test.ts), an excerpt):
500
+
501
+ ```ts
502
+ it('retries a 503, and waits as long as a 429 asks', async () => {
503
+ const started = Date.now()
504
+ const { report, retries } = await crawl([recipe('web-503', 'web', 'key=web-503&fail=1&mode=status'), recipe('api-429', 'api', 'key=api-429&fail=1&mode=retry-after')])
505
+ expect(report.recipes.map(entry => [entry.emitted, entry.error])).toEqual([[1, undefined], [1, undefined]])
506
+ expect(retries.map(event => [event.recipeId, event.reason, event.delayMs])).toEqual([['web-503', 'HTTP 503', expect.any(Number)], ['api-429', 'HTTP 429', 1000]])
507
+ expect(Date.now() - started).toBeGreaterThanOrEqual(1000)
508
+ }, 60000)
509
+ ```
510
+
511
+ The 503 waits a jittered backoff (`expect.any(Number)`); the 429 waits exactly the 1000 ms the server asked for. Both
512
+ recipes then succeed, and neither counts as blocked.
513
+
514
+ ## 5. Captchas
515
+
516
+ A captcha is solved by a **solver**, a function you register (`createCrawler({ captchaSolvers: [...] })`) that calls
517
+ a solving service or asks a person. Solving costs money and may break a site's terms, so this page runs none live. In
518
+ outline, when `session.captcha` names a solver:
519
+
520
+ 1. **Detect.** After each `goto`, `click`, `press` and pagination click, the engine looks for the first *visible*
521
+ reCAPTCHA, hCaptcha or Turnstile widget (or `detect.selector`). A `captcha` step looks at one known point, and
522
+ also finds invisible reCAPTCHA v3. Api recipes don't solve: with `session.captcha` set, a challenge in an HTTP
523
+ response is reported as a block.
524
+ 2. **Solve.** The solver gets the kind, the URL and the site key. One attempt spends one of the run's `maxSolves`
525
+ (default 10), and has `timeoutMs` (default 2 minutes).
526
+ 3. **Verify.** The solver's answer is only a claim. The engine waits up to 10 s for the challenge to be gone and,
527
+ if given, `verify.selector` to appear. A token the site rejects is a failed attempt.
528
+ 4. **Attempts.** Up to `attempts` (default 3) per challenge. After the last, a `CaptchaError`, which is a block: with
529
+ `onBlock.rotate` the run takes a new IP and tries again.
530
+ 5. **Budget.** Once `maxSolves` is spent, the next challenge is left unsolved (`captcha:budget`) and the step fails.
531
+
532
+ `onBlock.solve: true` handles a block page that shows a challenge: it is solved in place instead of failing, with
533
+ rotation as the fallback. What the trace looks like, from the engine's end-to-end test against a local fake reCAPTCHA
534
+ ([`e2e/captcha.e2e.test.ts`](../../packages/core/e2e/captcha.e2e.test.ts), an excerpt): the solver's first answer is
535
+ wrong, the page keeps showing the challenge, and the second answer passes.
536
+
537
+ ```ts
538
+ expect(ofType(events, 'captcha:failed')).toEqual([expect.objectContaining({ attempt: 1, reason: 'the page still shows the challenge' })])
539
+ expect(ofType(events, 'captcha:solved')).toEqual([expect.objectContaining({ attempt: 2, solver: 'fake' })])
540
+ expect(report.recipes[0].captchas).toEqual({ detected: 1, solved: 1, failed: 1 })
541
+ ```
542
+
543
+ In a trace, those are `⚿ captcha recaptcha-v2 on <url>`, `✗ captcha attempt 1 failed: the page still shows the
544
+ challenge` and `✓ captcha solved by fake (attempt 2, … ms)`. [captcha.md](../recipes/captcha.md) covers form captchas,
545
+ the manual solver and writing a solver, and [`examples/captcha-solver`](../../examples/captcha-solver) is a working
546
+ one.
547
+
548
+ ## 6. Allowed hosts
549
+
550
+ A service that runs other people's recipes must not become a way into its own network or files. `allowedHosts` limits
551
+ every request the crawler makes:
552
+
553
+ ```js
554
+ createCrawler({ allowedHosts: ['quotes.toscrape.com'] })
555
+ ```
556
+
557
+ A pattern is a host, `*.host` (the host and its subdomains), `host:port`, or `*`. It is enforced wherever a request
558
+ leaves:
559
+
560
+ | Where | How |
561
+ |---|---|
562
+ | browser navigations, and everything a page loads by itself (scripts, images, frames, `fetch`) | the context routes every request and aborts one off the list: `net::ERR_BLOCKED_BY_CLIENT` |
563
+ | web sockets | a socket to a host off the list is closed (code 1008) |
564
+ | service workers | blocked when a list is set, since their requests would bypass the routing |
565
+ | `request` steps (api, and web mode's through the page) | `HostNotAllowedError` before anything is sent |
566
+ | redirects of `request`s | followed one hop at a time (up to 20), each hop checked **before** it is followed |
567
+ | `file:` URLs | refused whatever the list says, even `*`. Without a list, a `request` to a `file:` URL reads the file. `data:`, `blob:` and `about:` never leave the page and pass |
568
+
569
+ Both refusals are `errorKind: 'host'`. The check is by name: a public name that resolves to a private address isn't
570
+ caught, so run such a service in a network that can't reach what it shouldn't.
571
+
572
+ [`quotes-api`](recipes/run-allowed-hosts/quotes-api.input.json) and
573
+ [`quotes-web`](recipes/run-allowed-hosts/quotes-web.input.json) read the first quote, then follow the footer link to
574
+ goodreads.com:
575
+
576
+ <!-- capture:run-allowed-hosts trace grep=^[^\u001b]*$ -->
577
+ ```text
578
+ ▶ quotes-api (api)
579
+ ⇄ access direct (direct)
580
+ ⇢ page 1 https://quotes.toscrape.com/
581
+ · steps.0 request home … ms
582
+ · steps.1 extract text … ms
583
+ · steps.2 extract author … ms
584
+ ✚ record ["“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"]
585
+ · steps.3 emit … ms
586
+ · steps.4 extract source … ms
587
+ ✖ step steps.5 (request) failed: https://www.goodreads.com/quotes is outside the allowed hosts (quotes.toscrape.com)
588
+ ■ quotes-api: 1 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
589
+ ✖ stopped: step steps.5 (request) failed: https://www.goodreads.com/quotes is outside the allowed hosts (quotes.toscrape.com)
590
+ ▶ quotes-web (web)
591
+ ⇄ access direct (direct)
592
+ ⇢ page 1 https://quotes.toscrape.com/
593
+ · steps.0 goto … ms
594
+ · steps.1 extract text … ms
595
+ · steps.2 extract author … ms
596
+ ≡ duplicate ["“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"]
597
+ · steps.3 emit … ms
598
+ · steps.4 extract source … ms
599
+ ```
600
+ <!-- /capture -->
601
+
602
+ The api recipe's request never left: `HostNotAllowedError` names the URL and the list. The web recipe's `goto`
603
+ failed too, with `page.goto: net::ERR_BLOCKED_BY_CLIENT at https://www.goodreads.com/quotes`. That line is left out
604
+ above because Playwright appends a call log to it. (`≡ duplicate`: the web recipe found the same quote, and under
605
+ `dedupe: 'run'` the first recipe to emit a key keeps it.)
606
+
607
+ ## 7. Concurrency and worker mode
608
+
609
+ Three dials, from the smallest to the largest.
610
+
611
+ ### Inside a recipe: `forEach` concurrency
612
+
613
+ `limits.concurrency: 3` lets a `forEach` **over a list** run three iterations at once: three requests in api mode,
614
+ three tabs of the run's browser context in web mode (each tab opens blank, so the body starts with a `goto`, and closes
615
+ when its iteration ends). A `forEach` over a `selector` stays sequential, since its elements live on one page. The limit
616
+ is per recipe run: the outermost concurrent loop takes the permits, and loops inside its iterations run one at a time,
617
+ so nesting never multiplies what is in flight.
618
+
619
+ [`travel-details.input.json`](recipes/run-concurrency/travel-details.input.json) reads the Travel listing, then each
620
+ book's own page, with `concurrency: 3`, `delayMs: 300` and `maxRecords: 6`:
621
+
622
+ <!-- capture:run-concurrency trace grep=▶|⇢|✚|forEach|■ -->
623
+ ```text
624
+ ▶ travel-details (api)
625
+ ⇢ page 1 https://books.toscrape.com/catalogue/category/books/travel_2/index.html
626
+ ⇢ page 1 https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html
627
+ ✚ record ["https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html"]
628
+ ⇢ page 1 https://books.toscrape.com/catalogue/full-moon-over-noahs-ark-an-odyssey-to-mount-ararat-and-beyond_811/index.html
629
+ ✚ record ["https://books.toscrape.com/catalogue/full-moon-over-noahs-ark-an-odyssey-to-mount-ararat-and-beyond_811/index.html"]
630
+ ⇢ page 1 https://books.toscrape.com/catalogue/see-america-a-celebration-of-our-national-parks-treasured-sites_732/index.html
631
+ ✚ record ["https://books.toscrape.com/catalogue/see-america-a-celebration-of-our-national-parks-treasured-sites_732/index.html"]
632
+ ⇢ page 1 https://books.toscrape.com/catalogue/vagabonding-an-uncommon-guide-to-the-art-of-long-term-world-travel_552/index.html
633
+ ✚ record ["https://books.toscrape.com/catalogue/vagabonding-an-uncommon-guide-to-the-art-of-long-term-world-travel_552/index.html"]
634
+ ⇢ page 1 https://books.toscrape.com/catalogue/under-the-tuscan-sun_504/index.html
635
+ ✚ record ["https://books.toscrape.com/catalogue/under-the-tuscan-sun_504/index.html"]
636
+ ⇢ page 1 https://books.toscrape.com/catalogue/a-summer-in-europe_458/index.html
637
+ ✚ record ["https://books.toscrape.com/catalogue/a-summer-in-europe_458/index.html"]
638
+ ⇢ page 1 https://books.toscrape.com/catalogue/the-great-railway-bazaar_446/index.html
639
+ ⇢ page 1 https://books.toscrape.com/catalogue/a-year-in-provence-provence-1_421/index.html
640
+ · steps.2 forEach … ms
641
+ ■ travel-details: 6 emitted, 0 rejected, 0 duplicates, 9 pages, … ms
642
+ ```
643
+ <!-- /capture -->
644
+
645
+ <!-- capture:run-concurrency summary -->
646
+ ```text
647
+ travel-details: 6 emitted, 0 rejected, 0 duplicates, 9 pages
648
+ ```
649
+ <!-- /capture -->
650
+
651
+ The trace reads in list order, each page's record before the next page, because on this site the rate, not the
652
+ concurrency, was the limit. A timed run of the same recipe shows why. The three iterations started together, 271 ms in, but
653
+ `delayMs` let their requests out one every 300 ms, and each page answered about 90 ms after its turn (at 361, 664,
654
+ 961, 1262 ms…). Each iteration had finished before the next one's request could start. `delayMs` is a rate, not a
655
+ pause: `concurrency: 3, delayMs: 300` means at most three requests in flight **and** at most one start every 300 ms.
656
+ Concurrency pays off when a response takes longer than the spacing: a slow site, or web tabs that render pages.
657
+ There, lines of different iterations interleave in the trace, all under the same path (a trace has no iteration
658
+ index), and records come out in the order iterations finish, not list order.
659
+
660
+ `9 pages` for 6 records is `maxRecords` at work: the seventh and eighth iterations had already started when the sixth
661
+ record was written. Iterations in flight finish, but emit nothing more. `maxRecords` is exact. Every detail page
662
+ is `page 1` because `page.number` counts `paginate`'s pages, and this recipe doesn't paginate.
663
+
664
+ ### Across recipes: `parallel`
665
+
666
+ `createCrawler({ parallel: 2 })` runs up to two input recipes of the set at once, each with its own session and
667
+ lease. They share the browser, the sink, the de-duplication set and the per-site throttle; the [throttle
668
+ scene](#two-limits-on-pace) above runs this way. Variants of one matrix recipe stay one after the other. Reports keep
669
+ the set's order.
670
+
671
+ ### Worker mode
672
+
673
+ `crawler.work(set, source, options)` is for a queue of items (a recipe plus vars each) rather than a fixed set:
674
+ thousands of report queries, say, fed as they come. A **pool of windows** works through them. A window is a lane with
675
+ its own browser context (or HTTP session), lease and step memory, and it runs item after item **without closing**.
676
+
677
+ - **`keep` steps.** A top-level step with `keep: true` (a `goto`, a `select`, a `fill`…) is skipped when the window
678
+ already holds the same rendered step from its last item: `≡ steps.1 select kept`. When one runs, the kept steps
679
+ after it are forgotten, so a changed filter re-applies everything below it. `window.check` is an element a reused
680
+ window must still show, or it is replaced.
681
+ - **How the pool grows and shrinks** (`windows: { min, max, start, grow, shrink, restart, idle }`). After `grow.after`
682
+ successes in a row (default 10), counted only while no window is waiting for work, one more window, up to `max`. On a
683
+ failure, half the windows (`shrink: 'half'`, the default) or one fewer, never below `min`, and only for failures of
684
+ items that started after the last shrink. A burst of failures shrinks the pool once. A window leaves only
685
+ **between** items; a busy one is never cut off. After `restart.after` failures in a row the browser is relaunched
686
+ and the pool goes back to `min`.
687
+ - **Outcomes.** An item ends as `success`, `failure` (a block, retries used up, a failed step) or `neutral` (a captcha
688
+ the solver couldn't pass, a browser that died): neutral moves nothing. Any non-success closes that window, and the
689
+ lane opens a fresh one for its next item. `classify` overrides the defaults.
690
+ - **The idle rule.** With `idle.afterMs`, a window that waited that long for an item retires, never below `min`. It
691
+ isn't a failure. A source that still hands it an item later gets a new window for it (`late`), so nothing is lost.
692
+ - **The inbox.** `createWorkInbox()` is a source fed by `submit(item)`, which returns that item's own result. An id
693
+ already queued or running is not run twice: the caller gets the same promise. Waiting windows are served last come,
694
+ first served, so the one that just finished takes the next item and long-idle ones can retire.
695
+ - **Records per item.** `dedupe` defaults to `recipe`, which here means per item: the same key in two items is each
696
+ item's record. Records are held until the item succeeds ([section 1](#worker-mode-holds-records)), and each carries
697
+ `source.item`. `matrix` isn't expanded in worker mode: the items are the variants.
698
+
699
+ **Not shown live**, since it needs a site under real load. From the engine's end-to-end test against a local report
700
+ page ([`e2e/worker.e2e.test.ts`](../../packages/core/e2e/worker.e2e.test.ts), an excerpt): 6 good items, one that
701
+ fails, then 16 more, on a pool of 1 to 3 windows that grows after 2 successes:
702
+
703
+ ```ts
704
+ const { events, result } = await work([...goods(6, 'DL', ['DL1', 'DL2']), item('GA', 'BAD'), ...goods(16, 'GA', ['GA1'])], { windows: { min: 1, max: 3, grow: { after: 2 } } })
705
+ const changes = ofType(events, 'windows:change').map(event => [event.from, event.to])
706
+ expect(changes.slice(0, 2)).toEqual([[1, 2], [2, 3]])
707
+ expect(changes.some(([from, to]) => to < from)).toBe(true)
708
+ // Only the bad item failed: the windows the shrink sent away finished their items first.
709
+ expect(result.items).toEqual({ success: 22, failure: 1, neutral: 0 })
710
+ expect(result.windows.peak).toBe(3)
711
+ expect(ofType(events, 'window:close').some(event => event.reason === 'retire')).toBe(true)
712
+ ```
713
+
714
+ The pool grew 1 → 2 → 3, shrank on the bad item, and the windows it sent away (`retire`) finished their items first:
715
+ one failure, not four. [worker-mode.md](../recipes/worker-mode.md) covers the recipe, sources, the inbox and a sample
716
+ trace.
717
+
718
+ ## 8. Events and the trace
719
+
720
+ Everything the engine does is an event, passed to `onEvent` with `type`, `recipeId`, a timestamp `at`, and in worker
721
+ mode `item` and `window`. A listener that throws is ignored. `traceLine(event)` turns one into an indented line, or
722
+ `undefined` for the two it doesn't print. The step events are indented by the step's depth (one level per `steps` or
723
+ `else` in its path), most others by one level. In worker mode each line starts with its window, `[w3]`.
724
+
725
+ | Event | `traceLine` | Emitted when |
726
+ |---|---|---|
727
+ | `recipe:start` | `▶ <recipe> [vars] (web)` | a recipe run starts; `[vars]` for a matrix variant or work item |
728
+ | `recipe:finish` | `■ <recipe>: N emitted, N rejected, N duplicates, [N skipped,] [N steps skipped,] N pages, T ms`, then `✖ stopped: <error>` if it failed | a recipe run ends |
729
+ | `access:lease` | `⇄ access <profile> (<kind> [server], [session id])` | a lease is taken: at the start, and on each rotation |
730
+ | `access:blocked` | `⛔ blocked <url>: <reason>` | a response matched the block rule |
731
+ | `access:rotate` | `↻ new access lease (attempt N)` | a block makes the run take a new lease |
732
+ | `request:retry` | `↺ <url>: <reason>, try N in T ms` | a transport retry, or a `goto.ready` reload |
733
+ | `page:visit` | `⇢ page N <url> [status]` | a navigation or request got its response; `[status]` only when it isn't 2xx |
734
+ | `captcha:detected` | `⚿ captcha <kind> on <url>` | a challenge is showing |
735
+ | `captcha:solve` | *(nothing)* | a solver starts an attempt |
736
+ | `captcha:solved` | `✓ captcha solved by <solver> (attempt N, T ms)` | the page confirmed a solve |
737
+ | `captcha:failed` | `✗ captcha attempt N failed: <reason>` | an attempt failed or was refused |
738
+ | `captcha:budget` | `⛔ captcha left unsolved: the run's N solves are spent` | `maxSolves` is spent |
739
+ | `step:start` | *(nothing)* | a step starts |
740
+ | `step:finish` | `· <path> <type> [id] T ms` | a step finished; every step prints once, here |
741
+ | `step:retry` | `↻ <path> <type> [id] retry N: <error>` | an `onError: retry` attempt |
742
+ | `step:skip` | `↷ <path> <type> [id] skipped: <error>` | `onError: skip` swallowed a failure |
743
+ | `step:branch` | `⑂ <path> then` or `else` | an `if` chose a branch |
744
+ | `step:kept` | `≡ <path> <type> kept` | worker mode: a `keep` step the window already holds |
745
+ | `record:emit` | `✚ record <key>` or `(no key)` | a record was written |
746
+ | `record:reject` | `✖ record rejected: <field>: <reason>` | a `skip-record` policy dropped a record |
747
+ | `record:duplicate` | `≡ duplicate <key>` | the key was already seen |
748
+ | `record:skipped` | `⤼ skipped <key>` | a resumed run found the key in the sink |
749
+ | `window:open`, `window:close` | `⧉ window opened (<reason>)`, `⧉ window closed (<reason>)` | worker mode: `start`, `grow`, `late`, `fresh`, `recycle`; `retire`, `idle`, `drained`, `fresh`, `recycle`, `restart`, `recipe` |
750
+ | `windows:change` | `⇅ windows A → B: <reason>` | worker mode: the pool's target changed |
751
+ | `browser:restart` | `⟳ browser restarted: <reason>` | worker mode: failures in a row |
752
+ | `item:finish` | `✓`, `✖` or `↩ item <id> <outcome>, T ms[: error]` | worker mode: an item ended (success, failure, neutral) |
753
+ | `warning` | `! <message>` | a hook's or solver's log, a country no profile applies, a solver that didn't close |
754
+ | `error` | `✖ <message>` | a run's failure, a source that threw, a hook's error log |
755
+
756
+ `step:start` prints nothing because `step:finish` carries the duration, so each step appears once, when it's done.
757
+ That is also why a loop's body lines come before the loop's own `· steps.2 forEach` line. Every iteration or page
758
+ reports the same path. `step:start` is still an event: count them, or time them, in your own listener.
759
+
760
+ ### `debug`
761
+
762
+ `createCrawler({ debug: true })` (the CLI's `--dry-run` sets it) adds two things to each `record:emit`:
763
+
764
+ - `scope`: the snapshot the record was mapped from, everything the emitting step could see. `record:reject` carries it
765
+ too.
766
+ - `mapping`: for each field, the value its `from` read and the value after each transform, in order. The values are
767
+ before coercion to the field's type: the trace shows what the transforms produced, not what reached the record.
768
+ Inside an `each` rule the keys are `<target>[<index>].<field>`. `record:reject` carries no mapping.
769
+
770
+ `traceLine` prints neither. The capture script behind this guide reads them to draw the scope blocks and value tables.
771
+ For the first record of the concurrency scene, the scope (part of it):
772
+
773
+ <!-- capture:run-concurrency scope ids=link,title,price,upc,page -->
774
+ ```json
775
+ {
776
+ "link": "../../../its-only-the-himalayas_981/index.html",
777
+ "title": "It's Only the Himalayas",
778
+ "price": "£45.17",
779
+ "upc": "a22124811bfa8350",
780
+ "page": {
781
+ "url": "https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html",
782
+ "number": 1
783
+ }
784
+ }
785
+ ```
786
+ <!-- /capture -->
787
+
788
+ and the mapping:
789
+
790
+ <!-- capture:run-concurrency mapping fields=url,price -->
791
+ | Field | Step | Value |
792
+ |---|---|---|
793
+ | `url` | read | `"https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html"` |
794
+ | | **field** | `"https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html"` |
795
+ | `price` | read | `"£45.17"` |
796
+ | | `currency` | `{"amount":45.17,"currency":"GBP"}` |
797
+ | | **field** | `{"amount":45.17,"currency":"GBP"}` |
798
+ <!-- /capture -->
799
+
800
+ `url` has no transform: `page.url` is already absolute, since a `request` sets the page to the URL it fetched.
801
+
802
+ ---
803
+
804
+ That is the last part. Back to [part 1: a run, end to end](README.md#a-run-end-to-end).