@opencraw/mcp 0.1.45 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +28 -1
  2. package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
  3. package/dist/diff-tool/index.d.ts +3 -0
  4. package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
  5. package/dist/docs-tool/docs-root.client.d.ts +8 -0
  6. package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
  7. package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
  8. package/dist/docs-tool/index.d.ts +3 -0
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.esm.js +517 -1
  11. package/dist/list-tool/index.d.ts +3 -0
  12. package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
  13. package/dist/main.d.ts +7 -0
  14. package/dist/probe-tool/index.d.ts +2 -0
  15. package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
  16. package/dist/recipe-source/index.d.ts +3 -0
  17. package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
  18. package/dist/run-tool/index.d.ts +3 -0
  19. package/dist/run-tool/run-tool.handler.d.ts +51 -0
  20. package/dist/server/create-server.use-case.d.ts +11 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
  23. package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
  24. package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
  25. package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
  26. package/dist/src/docs-tool/index.d.ts +3 -0
  27. package/dist/src/server/create-server.use-case.d.ts +1 -1
  28. package/dist/src/studio-tool/index.d.ts +2 -0
  29. package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
  30. package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
  31. package/dist/src/studio-tool/studio-tools.handler.d.ts +68 -0
  32. package/dist/studio-tool/index.d.ts +2 -0
  33. package/dist/studio-tool/studio-command.client.d.ts +11 -0
  34. package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
  35. package/dist/studio-tool/studio-tools.handler.d.ts +68 -0
  36. package/dist/validate-tool/index.d.ts +3 -0
  37. package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
  38. package/docs/how-it-works/01-recipes.md +698 -0
  39. package/docs/how-it-works/02-page-steps.md +672 -0
  40. package/docs/how-it-works/03-data-steps.md +813 -0
  41. package/docs/how-it-works/04-flow-steps.md +738 -0
  42. package/docs/how-it-works/05-templates-and-scope.md +428 -0
  43. package/docs/how-it-works/06-documents.md +1413 -0
  44. package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
  45. package/docs/how-it-works/08-policies.md +405 -0
  46. package/docs/how-it-works/09-running.md +804 -0
  47. package/docs/how-it-works/README.md +225 -0
  48. package/docs/recipes/README.md +22 -0
  49. package/docs/recipes/access.md +440 -0
  50. package/docs/recipes/authoring.md +1503 -0
  51. package/docs/recipes/azure-durable.md +180 -0
  52. package/docs/recipes/captcha.md +204 -0
  53. package/docs/recipes/movies-example.md +12 -0
  54. package/docs/recipes/programmatic-authoring.md +101 -0
  55. package/docs/recipes/quick-guide.md +157 -0
  56. package/docs/recipes/vehicles-example.md +9 -0
  57. package/docs/recipes/worker-mode.md +221 -0
  58. package/docs/studio/manual.md +314 -0
  59. package/docs/studio/pdf-agreements.md +150 -0
  60. package/package.json +29 -4
@@ -0,0 +1,672 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Page steps
6
+
7
+ Part 3 of [How OpenCraw works](README.md). Next: [4. Data steps](03-data-steps.md).
8
+
9
+ A `web` recipe drives a real browser. Its page steps do what a person does: open a page, type, press a key,
10
+ click, pick from a list, scroll, wait. This page shows each of them on a real page, with the elements the step
11
+ acts on outlined, the trace lines it produces and what it binds. The fields of every step are in the reference,
12
+ [authoring.md §3.1](../recipes/authoring.md#31-web-steps-browser-page); this page explains what happens when
13
+ they run.
14
+
15
+ ## What web mode is underneath
16
+
17
+ The engine runs web steps with [Playwright](https://playwright.dev). Three objects matter:
18
+
19
+ - **The browser.** One per crawler, launched the first time a recipe needs it (headless Chromium unless the
20
+ crawler's `browser` options say otherwise), and launched again if it crashes. `crawler.close()` closes it.
21
+ - **The context.** A browser context is a private profile: its own cookies, storage and cache. Each recipe run
22
+ gets **a new context with one page (tab)** when it starts, before its first start point. Every start point
23
+ of that run uses the same page, so a login made on the first start point is still there on the second. When
24
+ the run ends, the context is closed, and everything in it is gone.
25
+ - **The page.** Every step acts on that one tab. The browser moves from page to page on it, the way a person's
26
+ tab does.
27
+
28
+ When a context opens, and which:
29
+
30
+ | Situation | What opens |
31
+ |---|---|
32
+ | A `web` recipe run starts | A new context and page on the shared browser, with the recipe's `session` (cookies, headers, user agent, viewport) and access (proxy). |
33
+ | `session.bootstrap` | A throwaway context of its own, closed once the bootstrap ran; only what `keep` lists is carried into the run's context ([part 10](09-running.md#2-sessions)). |
34
+ | `session.browserProfile` | A persistent context in the profile's folder instead: cookies and storage survive between runs. |
35
+ | The access lease is a remote browser (`cdp`) | The provider's own context, over the DevTools protocol. |
36
+ | A block with `onBlock.rotate` | The context is closed and a new one opened with a new lease; the bootstrap runs again. |
37
+ | `forEach` with `limits.concurrency` above 1 | More tabs **in the same context**, one per iteration running at once: they share the cookies. |
38
+
39
+ An `api` recipe opens no browser for its steps: it has an HTTP client instead, and page steps are refused when
40
+ the recipes are loaded. Only its bootstrap runs in a browser.
41
+
42
+ After **every** web step, whatever it was, the engine reads the tab's URL and, if it changed, writes it to
43
+ `page.url`. A click or a key press that navigates therefore keeps `page.url` right. `page.number` is not
44
+ touched: it is the pagination counter, and only `paginate` advances it.
45
+
46
+ ### Timeouts
47
+
48
+ Each step waits for something, and not every step takes its limit from the same place:
49
+
50
+ | Step | How long it waits |
51
+ |---|---|
52
+ | `goto` | `limits.timeoutMs`, else the browser's default |
53
+ | `wait` (`selector`, `state`) | the step's `timeoutMs`, else `limits.timeoutMs`, else the browser's default |
54
+ | `select` with `values`, `multiple`, `force` or `search` | the step's `timeoutMs`, else `limits.timeoutMs`, else 30 s |
55
+ | `click` with `download` | `download.timeoutMs`, else `limits.timeoutMs`, else 30 s |
56
+ | `click` with `optional: true` | 2 s for the element to show, then the step does nothing |
57
+ | `click`, `fill`, `press`, a plain `select` | the browser's default only: the crawler's `browser.timeoutMs`, else Playwright's 30 s |
58
+
59
+ The last row is the one that surprises: `limits.timeoutMs` does not bound a click or a fill in a local browser.
60
+
61
+ ### `extract`: the live page, or a fragment
62
+
63
+ In web mode an `extract` reads one of two things:
64
+
65
+ - **The live page**, when it has no `from`: `css` and `xpath` go through Playwright, one round trip that reads
66
+ every match at once; `regex` and `table` read the page's current HTML. This is the DOM as it is now, after
67
+ scripts ran and after every click.
68
+ - **A value in scope**, when it has `from` (an HTML fragment an earlier extract took, a downloaded file) or when
69
+ it is `jsonpath` (the JSON the last `request` fetched): the same reader api mode uses, with no browser.
70
+
71
+ Neither waits. Playwright's actions (`click`, `fill`) wait for their element; an `extract` does not. If the
72
+ elements are not on the page yet, a single `extract` fails and a `many` one binds `[]`. [`wait`](#wait) shows
73
+ it on a real page. [Part 4](03-data-steps.md#which-engine-reads-the-live-page-or-a-document) covers the two
74
+ readers in depth.
75
+
76
+ ## `goto`
77
+
78
+ `goto` opens a URL in the tab. Before it loads the page, it resolves the URL against the scope's `page.url`,
79
+ so `"js"` and `"/"` are relative links. After it loads, `page.url` is **where the browser ended up**, after
80
+ redirects, and it emits a `page:visit` event: the `⇢` line in the trace.
81
+
82
+ The recipe [`goto-pages`](recipes/page-goto/goto-pages.input.json) opens three targets from
83
+ [quotes.toscrape.com](https://quotes.toscrape.com), one per `forEach` iteration, and emits where each landed:
84
+
85
+ ```json
86
+ { "type": "set", "id": "targets", "value": ["/", "js", "does-not-exist"] },
87
+ { "type": "forEach", "over": "targets", "as": "target", "emit": true, "steps": [
88
+ { "type": "goto", "url": "{{target}}" },
89
+ { "type": "extract", "id": "heading", "selector": "h1", "kind": "css", "take": "text" }
90
+ ]}
91
+ ```
92
+
93
+ <!-- capture:page-goto records n=3 -->
94
+ ```json
95
+ {"asked":"/","url":"https://quotes.toscrape.com/","number":1,"heading":"Quotes to Scrape"}
96
+ {"asked":"js","url":"http://quotes.toscrape.com/js/","number":1,"heading":"Quotes to Scrape"}
97
+ {"asked":"does-not-exist","url":"https://quotes.toscrape.com/does-not-exist","number":1,"heading":"Not Found"}
98
+ ```
99
+ <!-- /capture -->
100
+
101
+ <!-- capture:page-goto trace lines=16 -->
102
+ ```text
103
+ ▶ goto-pages (web)
104
+ ⇄ access direct (direct)
105
+ · steps.0 set targets … ms
106
+ ⇢ page 1 https://quotes.toscrape.com/
107
+ · steps.1.steps.0 goto … ms
108
+ · steps.1.steps.1 extract heading … ms
109
+ ✚ record ["https://quotes.toscrape.com/"]
110
+ ⇢ page 1 http://quotes.toscrape.com/js/
111
+ · steps.1.steps.0 goto … ms
112
+ · steps.1.steps.1 extract heading … ms
113
+ ✚ record ["http://quotes.toscrape.com/js/"]
114
+ ⇢ page 1 https://quotes.toscrape.com/does-not-exist [404]
115
+ · steps.1.steps.0 goto … ms
116
+ · steps.1.steps.1 extract heading … ms
117
+ ✚ record ["https://quotes.toscrape.com/does-not-exist"]
118
+ · steps.1 forEach … ms
119
+ …
120
+ ```
121
+ <!-- /capture -->
122
+
123
+ What the three records show:
124
+
125
+ - **Redirects.** `https://quotes.toscrape.com/js` answers with a redirect to `http://quotes.toscrape.com/js/`.
126
+ `page.url` is the final URL, scheme change and trailing slash included. A later relative `goto`, a
127
+ `request` and the `absoluteUrl` transform all resolve against it.
128
+ - **Relative to the scope, not to the tab.** `does-not-exist` resolved against `https://quotes.toscrape.com/`,
129
+ not against the `http://…/js/` the tab was showing. Each iteration runs in its own child scope, and the
130
+ `goto` writes its page there; when the iteration ends, that page state goes with it, and the next iteration
131
+ starts again from the parent's `page.url`, the start URL.
132
+ - **`page.number` stays 1.** Opening a page is not turning one. Only `paginate` counts pages.
133
+ - **A 404 is a page.** The third `goto` succeeded: the trace marks the visit `[404]`, and the `extract` read the
134
+ error page's heading.
135
+
136
+ <!-- capture:page-goto screenshot alt=The_404_page_the_third_goto_opened,_with_what_the_extract_read -->
137
+ ![The 404 page the third goto opened, with what the extract read](../assets/how-it-works/page-goto.png)
138
+ <!-- /capture -->
139
+
140
+ A `goto` fails on a response only when the recipe's block rule says it is a block (by default 403, 429 or an
141
+ AWS WAF challenge: [part 10](09-running.md#3-access-proxies-and-blocks)). A 408, 425, 429, 500, 502, 503 or
142
+ 504, or a connection that fails in passing, is loaded again first, after a pause (the `↺` lines of a trace).
143
+ When the tries run out, the last answer is the page: a 503 error page loads like a 404 one, and a 429 counts
144
+ as a block. That is what a browser does, and a site's "not found" page is sometimes the data you want. When it
145
+ is not, check for an element the real page has.
146
+
147
+ A `request` is different: it is a data fetch, and a 4xx or 5xx fails it. The second recipe,
148
+ [`request-missing`](recipes/page-goto/request-missing.input.json), fetches the same missing page with a
149
+ `request` from the start page:
150
+
151
+ <!-- capture:page-goto summary -->
152
+ ```text
153
+ goto-pages: 3 emitted, 0 rejected, 0 duplicates, 3 pages
154
+ request-missing: 0 emitted, 0 rejected, 0 duplicates, 2 pages, stopped: step steps.1 (request) failed: HTTP 404 for https://quotes.toscrape.com/does-not-exist
155
+ ```
156
+ <!-- /capture -->
157
+
158
+ The run stopped at the `request`. The failed fetch still counts as a page (`2 pages`): the engine reports the
159
+ visit, with its status, before it fails the step.
160
+
161
+ Two more options change when `goto` returns. `waitUntil` picks the load event it waits for: `load` (the
162
+ default: the page and its images, scripts and stylesheets), `domcontentloaded` (the HTML parsed), `networkidle`
163
+ (no requests for 500 ms) or `commit` (the response started). `ready: { selector }` is for a site that sometimes
164
+ serves its shell without the content: the page is loaded again, up to `reloads` times (default 2), until the
165
+ element shows.
166
+
167
+ ## `fill`, `press`, `click` and `wait`: a login
168
+
169
+ quotes.toscrape.com has a login form that accepts any name and password, and shows each author's Goodreads link
170
+ only to a logged-in visitor. The recipe [`login`](recipes/page-login/login.input.json) logs in, moves to page
171
+ 2, and reads those links:
172
+
173
+ ```json
174
+ { "type": "goto", "url": "{{start.url}}" },
175
+ { "type": "fill", "selector": "#username", "value": "{{vars.user}}" },
176
+ { "type": "fill", "selector": "#password", "value": "{{vars.password}}" },
177
+ { "type": "press", "selector": "#password", "key": "Enter" },
178
+ { "type": "wait", "selector": "a[href='/logout']" },
179
+ { "type": "click", "selector": "li.next a" },
180
+ { "type": "wait", "selector": ".quote" },
181
+ { "type": "screenshot", "path": "docs/assets/how-it-works/page-login-step.png" },
182
+ { "type": "extract", "id": "quotes", "selector": ".quote", "kind": "css", "take": "html", "many": true },
183
+ { "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "steps": ["…text, author, goodreads…"] }
184
+ ```
185
+
186
+ <!-- capture:page-login screenshot n=1 alt=The_login_form:_the_two_fields_filled,_and_the_button_Enter_submits -->
187
+ ![The login form: the two fields filled, and the button Enter submits](../assets/how-it-works/page-login.png)
188
+ <!-- /capture -->
189
+
190
+ - **`fill`** finds the first element matching `selector`, waits until it can be typed into, clears it and sets
191
+ the value (a template: `{{vars.user}}` becomes `scraper`). It fires the `input` event, as typing does, but it
192
+ types nothing key by key; a widget that listens for key presses needs `press`.
193
+ - **`press`** sends one key (`Enter`, `Tab`, `ArrowDown`, `Control+a`) to the element, or to the page when the
194
+ step has no `selector`. Enter in a form field submits the form, as it does for a person.
195
+ - **`wait`** here is what makes the next step safe. A `press` or a `click` returns once the navigation it
196
+ started is under way (Playwright waits for the new page to commit), not once that page has loaded. Waiting for an element only the logged-in page has
197
+ (the Logout link) proves the login worked, and fails the run with a clear timeout if it didn't.
198
+ - **`click`** clicks the first match, after Playwright checks it is visible, stable and not covered. The "Next"
199
+ link navigates, so a `wait` follows it too.
200
+
201
+ <!-- capture:page-login screenshot n=2 alt=After_the_login:_the_Logout_link_the_wait_looks_for,_and_the_Goodreads_links -->
202
+ ![After the login: the Logout link the wait looks for, and the Goodreads links](../assets/how-it-works/page-login-2.png)
203
+ <!-- /capture -->
204
+
205
+ <!-- capture:page-login screenshot n=3 alt=The_link_the_click_follows -->
206
+ ![The link the click follows](../assets/how-it-works/page-login-3.png)
207
+ <!-- /capture -->
208
+
209
+ None of these steps binds a value, so the trace shows them without an id. There are three `⇢` lines: the
210
+ `goto`'s, then one for the navigation the key press caused (the form posts, and the site redirects to `/`) and one
211
+ for the click's (page 2). A navigation a `click`, `press` or `select` causes is followed like a `goto`: the engine
212
+ waits for its response and `load`, retries a failure in passing, applies the block rule, emits `page:visit` and
213
+ counts the page, so the run reports `3 pages`. Its `⇢` line comes before the step's own `·` line, which is written
214
+ when the step ends. `page.number` stays 1 throughout: only `paginate` moves it.
215
+
216
+ <!-- capture:page-login trace lines=12 grep=^(?!.*↺) -->
217
+ ```text
218
+ ▶ login (web)
219
+ ⇄ access direct (direct)
220
+ ⇢ page 1 https://quotes.toscrape.com/login
221
+ · steps.0 goto … ms
222
+ · steps.1 fill … ms
223
+ · steps.2 fill … ms
224
+ ⇢ page 1 https://quotes.toscrape.com/
225
+ · steps.3 press … ms
226
+ · steps.4 wait … ms
227
+ ⇢ page 1 https://quotes.toscrape.com/page/2/
228
+ · steps.5 click … ms
229
+ · steps.6 wait … ms
230
+ …
231
+ ```
232
+ <!-- /capture -->
233
+
234
+ The records carry the Goodreads links, and `page.url` followed the click to page 2 while `page.number` stayed 1:
235
+
236
+ <!-- capture:page-login records n=3 -->
237
+ ```json
238
+ {"text":"“This life is what you make it. No matter what, you're going to mess up sometimes, it's a universal truth. But the good part is you get to decide how you're …","author":"Marilyn Monroe","goodreads":"http://goodreads.com/author/show/82952.Marilyn_Monroe","pageUrl":"https://quotes.toscrape.com/page/2/","page":1}
239
+ {"text":"“It takes a great deal of bravery to stand up to our enemies, but just as much to stand up to our friends.”","author":"J.K. Rowling","goodreads":"http://goodreads.com/author/show/1077326.J_K_Rowling","pageUrl":"https://quotes.toscrape.com/page/2/","page":1}
240
+ {"text":"“If you can't explain it to a six year old, you don't understand it yourself.”","author":"Albert Einstein","goodreads":"http://goodreads.com/author/show/9810.Albert_Einstein","pageUrl":"https://quotes.toscrape.com/page/2/","page":1}
241
+ ```
242
+ <!-- /capture -->
243
+
244
+ `selector` in these steps is a CSS selector (Playwright also takes `text=…` and `xpath=…`). It is never a
245
+ template: a selector that depends on a value goes in `target` instead (`"target": "#row-{{item.id}}"`), which
246
+ also takes a live element from a `forEach` over `selector` ([authoring.md §3.7](../recipes/authoring.md#37-live-elements-driving-a-configurator)).
247
+ `click` with `optional: true` waits 2 s for the element and does nothing if it never shows: a cookie banner
248
+ that appears on some visits only.
249
+
250
+ A login usually belongs in `session.bootstrap` rather than in the steps: it runs once before the crawl, and an
251
+ `api` recipe can use the cookies it leaves ([part 10](09-running.md#2-sessions)). In the steps, as here, it runs
252
+ again for every start point.
253
+
254
+ ## `click` with `download`: a file the page hands over
255
+
256
+ Some pages give their data as a file behind a button: "Export to Excel", "Download CSV". `click` with
257
+ `download` clicks, catches the file the browser receives, and reads it like a fetched document: CSV,
258
+ spreadsheet, PDF, Word, JSON and the rest, by `as` or by the file's name. The step's `id` binds the result,
259
+ and it becomes the current document.
260
+
261
+ None of the practice sites has an export button, so [`export`](recipes/page-download/export.input.json) makes
262
+ one: an `evaluate` puts a link on the page to a CSV of the ten quotes it shows (the script builds the file in
263
+ the page, and the link carries `download="quotes.csv"`). From there on, the recipe is what it would be on a site
264
+ with a real button:
265
+
266
+ ```json
267
+ { "type": "goto", "url": "{{start.url}}" },
268
+ { "type": "evaluate", "id": "rowsWritten", "script": "(() => { …adds a#export, returns the row count… })()" },
269
+ { "type": "click", "id": "export", "selector": "#export", "download": {} },
270
+ { "type": "extract", "id": "table", "from": "export", "kind": "table", "selector": "^text author$",
271
+ "columns": { "text": "^text$", "author": "^author$" } },
272
+ { "type": "set", "id": "rows", "value": "{{table.rows}}" },
273
+ { "type": "forEach", "over": "rows", "as": "row", "emit": true, "steps": [] }
274
+ ```
275
+
276
+ <!-- capture:page-download screenshot alt=The_page_the_script_turns_into_a_CSV,_and_where_it_puts_the_link -->
277
+ ![The page the script turns into a CSV, and where it puts the link](../assets/how-it-works/page-download.png)
278
+ <!-- /capture -->
279
+
280
+ What the click bound: the CSV, read into a workbook with one sheet named after the file.
281
+
282
+ <!-- capture:page-download scope ids=rowsWritten,export -->
283
+ ```json
284
+ {
285
+ "rowsWritten": 10,
286
+ "export": {
287
+ "kind": "workbook",
288
+ "sheets": [
289
+ {
290
+ "name": "quotes",
291
+ "rows": [
292
+ [
293
+ "text",
294
+ "author"
295
+ ],
296
+ [
297
+ "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”",
298
+ "Albert Einstein"
299
+ ],
300
+ [
301
+ "“It is our choices, Harry, that show what we truly are, far more than our abilities.”",
302
+ "J.K. Rowling"
303
+ ],
304
+ "… 8 more"
305
+ ]
306
+ }
307
+ ],
308
+ "csv": {
309
+ "encoding": "utf-8",
310
+ "delimiter": ","
311
+ }
312
+ }
313
+ }
314
+ ```
315
+ <!-- /capture -->
316
+
317
+ <!-- capture:page-download trace lines=9 -->
318
+ ```text
319
+ ▶ export (web)
320
+ ⇄ access direct (direct)
321
+ ⇢ page 1 https://quotes.toscrape.com/
322
+ · steps.0 goto … ms
323
+ · steps.1 evaluate rowsWritten … ms
324
+ · steps.2 click export … ms
325
+ · steps.3 extract table … ms
326
+ · steps.4 set rows … ms
327
+ ✚ record ["“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"]
328
+ …
329
+ ```
330
+ <!-- /capture -->
331
+
332
+ The `extract` has `from: "export"`, and it must. In web mode an `extract` without `from` reads the live page,
333
+ even right after a download, so a `table` extract would look for an HTML table on the quotes page. The
334
+ download is only read when named. `download.saveTo` (a template) keeps a copy of the file; `download.timeoutMs`
335
+ bounds the wait for it to start.
336
+
337
+ ## `select`: a form that posts itself back
338
+
339
+ [quotes.toscrape.com/search.aspx](https://quotes.toscrape.com/search.aspx) works like an old ASP.NET form. The
340
+ author list has `onchange="__doPostBack()"`: picking an author submits the whole form, with a hidden
341
+ `__VIEWSTATE` field that carries the page's state, and the server answers with a new page whose tag list holds
342
+ that author's tags. Only then can a tag be picked and the search run.
343
+
344
+ <!-- capture:page-select screenshot n=1 alt=The_search_form:_an_author_list,_and_a_tag_list_with_no_tags_yet -->
345
+ ![The search form: an author list, and a tag list with no tags yet](../assets/how-it-works/page-select.png)
346
+ <!-- /capture -->
347
+
348
+ The recipe, [`search`](recipes/page-select/search.input.json):
349
+
350
+ ```json
351
+ { "type": "goto", "url": "{{start.url}}" },
352
+ { "type": "select", "selector": "#author", "value": "{{vars.author}}" },
353
+ { "type": "select", "selector": "#tag", "values": ["{{vars.tag}}"] },
354
+ { "type": "click", "selector": "input[name=submit_button]" },
355
+ { "type": "wait", "selector": ".results .quote" },
356
+ { "type": "extract", "id": "quotes", "selector": ".results .quote", "kind": "css", "take": "html", "many": true },
357
+ { "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "steps": ["…text, author, tag…"] }
358
+ ```
359
+
360
+ The two `select` steps take the two paths the step has:
361
+
362
+ - **One `value`, `label` or `index`** goes straight to Playwright's `selectOption`, which picks the option as
363
+ a person does and fires `input` and `change`. The page's `change` handler posts the form back. The step does
364
+ not wait for the new page.
365
+ - **`values`** (or `multiple`, `force`, `search`) resolves each wanted entry to an option first, by value, then
366
+ by label, and **waits until every one exists**, checking every 250 ms, up to `timeoutMs`. That is what the
367
+ tag list needs: its options arrive with the page the postback brings back. When the time is up, the step
368
+ fails and names what matched nothing, with the first options it did find.
369
+
370
+ <!-- capture:page-select screenshot n=2 alt=After_the_postback:_the_author_kept,_and_the_author's_tags_in_the_tag_list -->
371
+ ![After the postback: the author kept, and the author's tags in the tag list](../assets/how-it-works/page-select-2.png)
372
+ <!-- /capture -->
373
+
374
+ Each postback is a navigation to `/filter.aspx`, and after each step the engine re-reads the URL:
375
+
376
+ <!-- capture:page-select scope ids=page,text,author,tag -->
377
+ ```json
378
+ {
379
+ "page": {
380
+ "url": "https://quotes.toscrape.com/filter.aspx",
381
+ "number": 1
382
+ },
383
+ "text": "“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”",
384
+ "author": "Albert Einstein",
385
+ "tag": "life"
386
+ }
387
+ ```
388
+ <!-- /capture -->
389
+
390
+ <!-- capture:page-select screenshot n=3 alt=The_results_the_search_returned,_which_the_wait_and_the_extract_read -->
391
+ ![The results the search returned, which the wait and the extract read](../assets/how-it-works/page-select-3.png)
392
+ <!-- /capture -->
393
+
394
+ <!-- capture:page-select trace lines=9 -->
395
+ ```text
396
+ ▶ search (web)
397
+ ⇄ access direct (direct)
398
+ ⇢ page 1 https://quotes.toscrape.com/search.aspx
399
+ · steps.0 goto … ms
400
+ ⇢ page 1 https://quotes.toscrape.com/filter.aspx
401
+ · steps.1 select … ms
402
+ · steps.2 select … ms
403
+ ⇢ page 1 https://quotes.toscrape.com/filter.aspx
404
+ · steps.3 click … ms
405
+ …
406
+ ```
407
+ <!-- /capture -->
408
+
409
+ The browser carries the ViewState from page to page without the recipe seeing it, which is the reason to drive
410
+ this form in a browser at all. A `request` with `form` (web mode, [part 4](03-data-steps.md#a-form-post-from-a-web-page))
411
+ can post the same form from the page instead, ViewState included, when the result is easier to read as a
412
+ response than as the next page.
413
+
414
+ The other options, for pages that hide their `<select>`:
415
+
416
+ | Option | What it does |
417
+ |---|---|
418
+ | `values: ["a", "b"]`, `multiple: true` | Picks several; `multiple` adds to what is already chosen. An item that renders a list adds each element. |
419
+ | `force: true` | Sets the options on the element itself, visible or not, and fires `input` and `change`: a hidden `<select>` behind a widget built by a script. |
420
+ | `search: { input, open?, close? }` | Types each value missing from the options into the widget's search box, key by key, and picks it once the page lists it. |
421
+ | `clear: true` | `values` that render to nothing clear the control instead of leaving it alone. |
422
+ | `ignoreCase: true` | Matches values and labels without case. |
423
+
424
+ ## `scroll`: infinite lists
425
+
426
+ [quotes.toscrape.com/scroll](https://quotes.toscrape.com/scroll) has no "next" link. It fetches ten quotes from
427
+ `/api/quotes?page=1` when it loads, and ten more each time the window reaches the bottom.
428
+
429
+ <!-- capture:page-scroll screenshot alt=The_infinite_scroll_page_with_its_first_ten_quotes -->
430
+ ![The infinite scroll page with its first ten quotes](../assets/how-it-works/page-scroll.png)
431
+ <!-- /capture -->
432
+
433
+ `scroll` moves the window to the bottom (`to: "bottom"`) or brings an element into view (`to: "<selector>"`),
434
+ `times` times (default 1). After each round it waits `settleMs` (default **300 ms**) and reads the page's
435
+ height. With `untilStable: true` it keeps going until the height stops changing: the page has nothing more to
436
+ add, or `maxScrolls` rounds (default 50) have run, which ends the step with a `warning` event rather than a
437
+ failure.
438
+
439
+ That wait is the whole of its patience. A page whose next batch takes longer to arrive looks finished, and
440
+ the step ends early. Two recipes on the same page:
441
+
442
+ - [`scroll-until-stable`](recipes/page-scroll/scroll-until-stable.input.json): `wait` for the first quote, then
443
+ `scroll` with `untilStable`.
444
+ - [`scroll-and-wait`](recipes/page-scroll/scroll-and-wait.input.json): `scroll`, `wait` for the 11th quote,
445
+ `scroll`, `wait` for the 21st. Each round waits for the batch it asked for, however long it takes.
446
+
447
+ <!-- capture:page-scroll summary -->
448
+ ```text
449
+ scroll-and-wait: 30 emitted, 0 rejected, 0 duplicates, 1 pages
450
+ scroll-until-stable: 10 emitted, 0 rejected, 0 duplicates, 1 pages
451
+ ```
452
+ <!-- /capture -->
453
+
454
+ <!-- capture:page-scroll trace grep=▶|■|✖|·_steps\.[0-9]+__ -->
455
+ ```text
456
+ ▶ scroll-and-wait (web)
457
+ · steps.0 goto … ms
458
+ · steps.1 wait … ms
459
+ · steps.2 scroll … ms
460
+ · steps.3 wait … ms
461
+ · steps.4 scroll … ms
462
+ · steps.5 wait … ms
463
+ · steps.6 extract quotes … ms
464
+ · steps.7 forEach … ms
465
+ ■ scroll-and-wait: 30 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
466
+ ▶ scroll-until-stable (web)
467
+ · steps.0 goto … ms
468
+ · steps.1 wait … ms
469
+ · steps.2 scroll … ms
470
+ · steps.3 extract quotes … ms
471
+ · steps.4 forEach … ms
472
+ ■ scroll-until-stable: 10 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
473
+ ```
474
+ <!-- /capture -->
475
+
476
+ On the network this guide was captured on, the API took longer than 300 ms to answer, so `untilStable` saw
477
+ the same height twice and stopped before the second batch arrived. On a fast connection it may read further.
478
+ That is the point: `untilStable` measures the page, not the site's API, and the result depends on how fast the
479
+ site answers. When the count matters, pair each `scroll` with a `wait` for what it should bring, as the second
480
+ recipe does. Two more details:
481
+
482
+ - The first `wait` matters too. Right after `goto`, the first batch may still be on its way; a page shorter than
483
+ the window cannot scroll, so the scroll fires no event and nothing more loads.
484
+ - On a slow site, raise `settleMs`. On a list that never ends, `maxScrolls` is what stops the step
485
+ (`limits.timeoutMs` does not apply to `scroll`); set it to the number of batches you want.
486
+
487
+ A page like this one always has an API behind it. Reading `/api/quotes?page=N` with `request` and
488
+ `paginate` ([part 5](04-flow-steps.md)) is faster and gets every quote; scroll when the API is out of reach.
489
+
490
+ ## `wait`
491
+
492
+ `wait` does one or more of three things, in this order, each only if given: `selector` waits for an element to
493
+ be **visible**, `ms` sleeps, `state: "networkidle"` waits until the page has made no request for 500 ms.
494
+ `timeoutMs` bounds the first and the last.
495
+
496
+ [quotes.toscrape.com/js](https://quotes.toscrape.com/js/) builds its quotes with a script that runs while the
497
+ page loads; [/js-delayed](https://quotes.toscrape.com/js-delayed/) runs the same script ten seconds later.
498
+ Three recipes read the first quote right after `goto`, two of them without a `wait`:
499
+
500
+ | Recipe | Page | Steps |
501
+ |---|---|---|
502
+ | [`js-no-wait`](recipes/page-wait/js-no-wait.input.json) | `/js/` | `goto`, `extract` |
503
+ | [`delayed-no-wait`](recipes/page-wait/delayed-no-wait.input.json) | `/js-delayed/` | `goto`, `extract` |
504
+ | [`delayed-wait`](recipes/page-wait/delayed-wait.input.json) | `/js-delayed/` | `goto`, `wait` for `.quote` (up to 15 s), `extract` |
505
+
506
+ <!-- capture:page-wait screenshot n=1 alt=js-delayed_when_goto_returns:_the_page_has_loaded,_the_quotes_have_not -->
507
+ ![js-delayed when goto returns: the page has loaded, the quotes have not](../assets/how-it-works/page-wait.png)
508
+ <!-- /capture -->
509
+
510
+ <!-- capture:page-wait screenshot n=2 alt=js-delayed_ten_seconds_later,_when_the_wait_returns -->
511
+ ![js-delayed ten seconds later, when the wait returns](../assets/how-it-works/page-wait-2.png)
512
+ <!-- /capture -->
513
+
514
+ <!-- capture:page-wait summary -->
515
+ ```text
516
+ delayed-no-wait: 0 emitted, 0 rejected, 0 duplicates, 1 pages, stopped: step steps.1 (extract) failed: no match for .quote .text
517
+ delayed-wait: 2 emitted, 0 rejected, 0 duplicates, 1 pages
518
+ js-no-wait: 2 emitted, 0 rejected, 0 duplicates, 1 pages
519
+ ```
520
+ <!-- /capture -->
521
+
522
+ <!-- capture:page-wait trace grep=▶|■|✖|·_steps\.[0-9]+__ -->
523
+ ```text
524
+ ▶ delayed-no-wait (web)
525
+ · steps.0 goto … ms
526
+ ✖ step steps.1 (extract) failed: no match for .quote .text
527
+ ■ delayed-no-wait: 0 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
528
+ ✖ stopped: step steps.1 (extract) failed: no match for .quote .text
529
+ ▶ delayed-wait (web)
530
+ · steps.0 goto … ms
531
+ · steps.1 wait … ms
532
+ · steps.2 extract first … ms
533
+ · steps.3 extract quotes … ms
534
+ · steps.4 forEach … ms
535
+ ■ delayed-wait: 2 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
536
+ ▶ js-no-wait (web)
537
+ · steps.0 goto … ms
538
+ · steps.1 extract first … ms
539
+ · steps.2 extract quotes … ms
540
+ · steps.3 forEach … ms
541
+ ■ js-no-wait: 2 emitted, 0 rejected, 0 duplicates, 1 pages, … ms
542
+ ```
543
+ <!-- /capture -->
544
+
545
+ `goto` waits for the `load` event: the HTML, scripts and stylesheets. On `/js/` the quotes are written while
546
+ the page is parsed, so they are there when `goto` returns. On `/js-delayed/` the load event comes ten seconds
547
+ before the quotes, so the `extract` right after `goto` found nothing and failed. With the `wait`, the same
548
+ `extract` ran once the first quote showed.
549
+
550
+ Waiting for the element you are about to read is the reliable form. `ms` is a guess that is either too long or
551
+ too short; `networkidle` fails on pages that poll or stream forever. `goto` with `ready` covers the other
552
+ case: a page that sometimes never shows the element, and needs loading again.
553
+
554
+ ## `screenshot`
555
+
556
+ `screenshot` saves the whole page (`fullPage`, not just the window) as a PNG at `path`, a template, relative to
557
+ the directory the crawler runs in. It binds nothing and changes nothing: it is for seeing what the browser saw
558
+ when a recipe misbehaves. The `login` recipe takes one after its `wait` for `.quote`:
559
+
560
+ <p align="center">
561
+ <img src="../assets/how-it-works/page-login-step.png" alt="The full page the login recipe's screenshot step saved" width="480">
562
+ </p>
563
+
564
+ ## `evaluate`
565
+
566
+ `evaluate` runs JavaScript in the page and binds what it returns under its `id`. The result must be
567
+ JSON-serialisable: numbers, strings, lists and plain objects, not DOM elements or functions. A promise is
568
+ awaited.
569
+
570
+ [quotes.toscrape.com/js](https://quotes.toscrape.com/js/) writes its quotes from a variable in one of its
571
+ scripts, `var data = [ … ]`. `data` holds the same quotes as objects, with what the markup leaves out: each
572
+ author's slug and Goodreads path. The recipe [`js-data`](recipes/page-evaluate/js-data.input.json) reads the
573
+ variable instead of the markup:
574
+
575
+ ```json
576
+ { "type": "goto", "url": "{{start.url}}" },
577
+ { "type": "evaluate", "id": "total", "script": "data.length" },
578
+ { "type": "evaluate", "id": "quotes", "script": "(options) => data.slice(0, options.count)",
579
+ "args": { "count": "{{vars.count}}" } },
580
+ { "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "steps": [] }
581
+ ```
582
+
583
+ <!-- capture:page-evaluate screenshot alt=The_quotes_the_page_wrote_from_its_data_variable -->
584
+ ![The quotes the page wrote from its data variable](../assets/how-it-works/page-evaluate.png)
585
+ <!-- /capture -->
586
+
587
+ <!-- capture:page-evaluate scope ids=total,quote -->
588
+ ```json
589
+ {
590
+ "total": 10,
591
+ "quote": {
592
+ "tags": [
593
+ "change",
594
+ "deep-thoughts",
595
+ "thinking",
596
+ "world"
597
+ ],
598
+ "author": {
599
+ "name": "Albert Einstein",
600
+ "goodreads_link": "/author/show/9810.Albert_Einstein",
601
+ "slug": "Albert-Einstein"
602
+ },
603
+ "text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"
604
+ }
605
+ }
606
+ ```
607
+ <!-- /capture -->
608
+
609
+ <!-- capture:page-evaluate trace lines=6 -->
610
+ ```text
611
+ ▶ js-data (web)
612
+ ⇄ access direct (direct)
613
+ ⇢ page 1 https://quotes.toscrape.com/js/
614
+ · steps.0 goto … ms
615
+ · steps.1 evaluate total … ms
616
+ · steps.2 evaluate quotes … ms
617
+ …
618
+ ```
619
+ <!-- /capture -->
620
+
621
+ The two forms:
622
+
623
+ - **Without `args`**, `script` is an expression, evaluated as it is: `data.length` gives `10`.
624
+ - **With `args`**, `script` is a function, called with `args` after its templates are rendered. A string that is
625
+ one placeholder keeps its type: `"{{vars.count}}"` passes the number `3`, not `"3"`.
626
+
627
+ The mapping then reads the objects directly: `quote.author.name`, `quote.tags`. No selector touched the markup.
628
+
629
+ `script` is rendered as a template **before** it runs, so a literal `{{` in the JavaScript is read as a
630
+ placeholder. Put such values in `args`. And `evaluate` runs whatever the recipe says in the page, with the
631
+ page's cookies: run it only from recipes you trust.
632
+
633
+ ## `captcha`
634
+
635
+ A captcha is a challenge a site puts between a visitor and the page. OpenCraw does not solve captchas itself:
636
+ it finds them, hands them to a **solver** you register with the crawler (`captchaSolvers`), checks the page
637
+ afterwards, retries, and caps what a run spends. Solving one can break a site's terms; read
638
+ [captcha.md](../recipes/captcha.md) first.
639
+
640
+ Three places start a solve, all in web mode:
641
+
642
+ - **`session.captcha`**: after every `goto`, every page `paginate` opens, every `click` and every `press`, the
643
+ engine looks for a visible challenge (reCAPTCHA, hCaptcha, Cloudflare Turnstile) and solves it before the next
644
+ step. Not after `fill`, `select` or `evaluate`, which rarely bring one up.
645
+ - **`onBlock.solve`**: a page the block rule calls a block, which shows a challenge, is solved on the spot.
646
+ - **The `captcha` step**: at one known point, such as a login form. It also detects reCAPTCHA v3, which has no
647
+ widget, and a page with no challenge is fine: the step does nothing. With `image`, `field` and `submit`, it
648
+ handles a picture captcha checked when its form is posted.
649
+
650
+ Each challenge gets 3 attempts by default, 120 s each, and a run gets 10 solves in all. The loop and its events
651
+ (`⚿`, `✓`, `✗` in a trace) are in [part 10](09-running.md#5-captchas).
652
+
653
+ This guide solves no real captcha. [`examples/captcha-solver`](../../examples/captcha-solver) is a working
654
+ solver for a paid service, with a demo that runs the whole loop on your machine: a local page with a
655
+ reCAPTCHA-shaped challenge and a fake solving API, so nothing leaves the machine.
656
+
657
+ ## Summary: what each step leaves behind
658
+
659
+ | Step | Binds under `id` | Changes |
660
+ |---|---|---|
661
+ | `goto` | nothing | `page.url` (the final URL); emits `page:visit` (`⇢`) |
662
+ | `click` | nothing; with `download`, the file | `page.url` if it navigated, and then a `page:visit` (`⇢`), checked like a `goto`'s; with `download`, the current document |
663
+ | `press`, `select` | nothing | as `click`, without the download |
664
+ | `fill`, `scroll`, `wait` | nothing | `page.url` if the page navigated |
665
+ | `screenshot` | nothing | a file at `path` |
666
+ | `evaluate` | the script's result | whatever the script does to the page |
667
+ | `captcha` | nothing | the page, once solved |
668
+
669
+ Every one of them runs on the recipe's single tab, and after every one of them `page.url` is the tab's URL.
670
+ `page.number` belongs to `paginate`.
671
+
672
+ Next: [4. Data steps](03-data-steps.md), where the values come from.