@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,180 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Running OpenCraw as an Azure Durable Functions app
6
+
7
+ `@opencraw/azure-durable` turns a Function App into an OpenCraw service: callers send recipes (or the name of a
8
+ stored set) over HTTP and read the result from the Durable status response. This page covers deploying it. The
9
+ API itself is in the [package README](../../packages/azure-durable/README.md).
10
+
11
+ Think of it as a central kitchen. Every crawler used to own its ovens: Playwright, Chromium, the captcha reader,
12
+ the proxies. Now they send orders to one kitchen that keeps its ovens hot, and pick up the dish.
13
+
14
+ ## 1. The app
15
+
16
+ ```text
17
+ my-crawler-host/
18
+ host.json
19
+ package.json "main": "dist/index.js", dependencies: @opencraw/azure-durable, @azure/functions, durable-functions
20
+ src/index.ts registerOpenCraw({ ... })
21
+ recipes/ the named recipe sets it serves
22
+ Dockerfile
23
+ ```
24
+
25
+ `src/index.ts` is the whole app: see [Register it](../../packages/azure-durable/README.md#register-it).
26
+
27
+ ## 2. `host.json`
28
+
29
+ ```json
30
+ {
31
+ "version": "2.0",
32
+ "functionTimeout": "-1",
33
+ "extensions": {
34
+ "durableTask": {
35
+ "maxConcurrentActivityFunctions": 12,
36
+ "maxConcurrentOrchestratorFunctions": 50,
37
+ "overridableExistingInstanceStates": "NonRunningStates"
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ - **`maxConcurrentActivityFunctions`**: at least the pools' `maxWindows` plus two. An item waiting in a pool
44
+ holds an activity slot, so with fewer slots than windows a pool can never fill. The host warns at startup when
45
+ it sees fewer.
46
+ - **`overridableExistingInstanceStates: NonRunningStates`**: an item id can run again once it has finished (a
47
+ `neutral` item sent back), and the starter answers a second start while it runs with that instance's status, so the item runs once.
48
+ - **`functionTimeout: -1`**: activities run as long as a crawl needs. The Consumption plan caps them at 10
49
+ minutes and can't run Chromium anyway.
50
+
51
+ ## 3. The container
52
+
53
+ [`apps/azure-host`](../../apps/azure-host/README.md) is a complete app built this way. Its
54
+ [Dockerfile](../../apps/azure-host/Dockerfile) installs the pruned build (`npx nx run @opencraw/azure-host:prune`:
55
+ the app's own `package.json`, lockfile and the workspace packages it uses) on the Functions Node 24 image, then
56
+ Chromium:
57
+
58
+ ```dockerfile
59
+ FROM mcr.microsoft.com/azure-functions/node:4-node24
60
+ ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
61
+ AzureFunctionsJobHost__Logging__Console__IsEnabled=true \
62
+ PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
63
+ WORKDIR /home/site/wwwroot
64
+ COPY dist/package.json dist/package-lock.json ./
65
+ COPY dist/workspace_modules ./workspace_modules
66
+ RUN npm ci --omit=dev --ignore-scripts && npx playwright install --with-deps chromium
67
+ COPY host.json ./
68
+ COPY dist/*.js ./dist/
69
+ COPY dist/functions ./dist/functions
70
+ COPY dist/assets ./dist/assets
71
+ ```
72
+
73
+ The build happens before the image, so the image needs no compiler. Run it on an **Elastic Premium** or
74
+ **App Service** plan (Linux, custom container). Chromium needs about 150 to 300 MB per window: size the plan for
75
+ `maxWindows` windows plus the host.
76
+
77
+ ## 4. Scale: one pool per instance
78
+
79
+ A warm pool lives in one process's memory. Azure sends each activity to whichever instance is free, so a
80
+ Function App scaled to three instances has up to three pools for the same crawl id. Each is warm and adapts on
81
+ its own, but together they run three times the windows, and three times the traffic from the same outbound IPs.
82
+
83
+ For a job that must be a singleton (a site that blocks an IP that asks too much), pin the app to one instance:
84
+ `functionAppScaleLimit: 1` on Premium, or a fixed single instance on App Service. Scale up (a bigger instance),
85
+ not out. The per-site `throttle` is per process too, for the same reason.
86
+
87
+ ## 5. Security
88
+
89
+ - **Authentication.** Routes need a function key by default (`authLevel: 'function'`). For per-team access, turn
90
+ on App Service authentication (Entra ID) and use `identify: request =>
91
+ request.headers.get('x-ms-client-principal-name') ?? undefined`. That header is trustworthy only because App
92
+ Service authentication sets it and strips any a client sends. Without it, anyone can send that header.
93
+ - **Allowed hosts.** The host fetches whatever a recipe points at, so without a limit it is a way into your
94
+ network. `allowedHosts` is enforced on every request, redirect and web socket, and `file:` is always refused.
95
+ The check is by host name: also run the app where it can't reach anything it shouldn't (a VNet with no route
96
+ to internal services).
97
+ - **Code stays in the host.** Hooks, captcha solvers and access profiles are registered in `registerOpenCraw`.
98
+ A caller can only use them by name.
99
+
100
+ ## 6. Writing recipes on the host
101
+
102
+ With `mcp: true`, the host also serves `/api/mcp`: probe, validate, sample runs, full runs and `publish`, over MCP.
103
+ Recipes written there are written where they will run. A recipe that works in the MCP session works in
104
+ production, because it is the same IP, proxies and browser.
105
+
106
+ Keep published versions in Blob storage so they survive restarts:
107
+
108
+ ```ts
109
+ registerOpenCraw({
110
+ // ...
111
+ recipes: blobRecipes({ connectionString: process.env.AzureWebJobsStorage ?? '', shipped: [/* the sets in the repo */] }),
112
+ mcp: { sampleRecords: 20, sampleMs: 60_000 },
113
+ identify: request => request.headers.get('x-ms-client-principal-name') ?? undefined,
114
+ canPromote: caller => caller === 'lead@example.com',
115
+ })
116
+ ```
117
+
118
+ `publish` stores a draft, which production refuses. Someone whom `canPromote` allows then promotes it with
119
+ `POST /api/recipes/{name}/{version}/promote`. A version never changes once written; the next change is the next
120
+ version. A pool running version 3 keeps running it until it closes.
121
+
122
+ ## 7. A caller that keeps the pool busy
123
+
124
+ ```ts
125
+ const inFlight = new Set<Promise<void>>()
126
+ let wanted = 2
127
+ for (const item of items) {
128
+ while (inFlight.size >= wanted) await Promise.race(inFlight)
129
+ const task = (async () => {
130
+ const started = await fetch(`${host}/api/jobs/${crawlId}/items?code=${key}`, { method: 'POST', body: JSON.stringify({ recipe, item, jobSize: items.length }) })
131
+ const { statusQueryGetUri } = await started.json()
132
+ const result = await poll(statusQueryGetUri) // until runtimeStatus is Completed or Failed
133
+ const { output } = result
134
+ if (output.outcome === 'neutral') items.push(item) // send it back later
135
+ // One more in flight while windows sit idle, up to the pool's maximum.
136
+ wanted = Math.max(1, output.pool.windows + (output.pool.idle === 0 ? 1 : 0))
137
+ await save(item, output)
138
+ })()
139
+ inFlight.add(task)
140
+ void task.finally(() => inFlight.delete(task))
141
+ }
142
+ await Promise.all(inFlight)
143
+ ```
144
+
145
+ The pool grows only while every window is busy, and lets a window go once it has waited `windows.idle.afterMs`.
146
+ So a caller that keeps one more item in flight than the pool has windows lets it grow, and a caller that slows
147
+ down lets it shrink.
148
+
149
+ ## 8. Hooks that take a while
150
+
151
+ A hook behind a service (`httpHook`, or a `commandHook` that starts a job) can answer `pending`. Without
152
+ `callouts`, the activity asks again until the service settles (`maxWaitMs` on the hook, 120 s by default),
153
+ which holds the activity for that long. With `callouts` the run does not wait inside the activity:
154
+
155
+ ```ts
156
+ registerOpenCraw({
157
+ allowedHosts: ['example.com'],
158
+ hooks: { price: httpHook('price', 'https://rates.example.com/price', { tokenEnv: 'RATES_TOKEN' }) },
159
+ callouts: { signingKeyEnv: 'OPENCRAW_CALLOUT_KEY', waitMs: 2 * 60 * 60 * 1000 },
160
+ })
161
+ ```
162
+
163
+ - The request to the service carries `callback: { "url": "https://app/api/callouts/<token>/resolve" }`. The
164
+ service answers `{ "status": "pending" }` and, when it is done, posts `{ "status": "ok", "output": … }` (or
165
+ `{ "status": "error", "error": "…" }`) to that URL. The shapes are `callout-request.schema.json` and
166
+ `callout-resolution.schema.json` in `@opencraw/core`.
167
+ - The recipe stops, and the orchestration waits for that result with a durable timer beside it. When the
168
+ result is posted, the recipe **runs again from the start**, and the call that was waiting returns the result
169
+ without being made: steps before it run twice, so this suits fetch and extract steps, and a service must
170
+ answer a repeat of a call (same `idempotencyKey`) with what it already has.
171
+ - A recipe whose result does not arrive within `waitMs` (default 1 hour) fails with the handler named; the
172
+ other recipes of the job are not affected. A recipe is resumed at most 25 times.
173
+ - **The token is the credential.** The resolve route is registered with `authLevel: 'anonymous'` because the
174
+ services that post back hold no function key. The token is an HMAC (with the key in `signingKeyEnv`) of the
175
+ job, the recipe and the call, with an expiry, so a token settles that one call and nothing else, and a
176
+ second post finds the job no longer waiting and gets 410. Keep the key in the Function App settings, like the
177
+ other secrets; rotating it refuses the tokens in flight.
178
+ - `publicUrl` is where services post to (`https://app.example.com/api`). Left out, it is the start URL of the
179
+ job without `/crawl`: set it behind a proxy or a custom domain.
180
+ - Only `POST /crawl` takes it. Jobs started through `/mcp` and items sent to `/jobs` poll a pending hook, as without `callouts`.
@@ -0,0 +1,204 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Captchas
6
+
7
+ A captcha is the site asking whether a human is there. OpenCraw can hand the question to a **solver** you
8
+ register (usually a paid service) and carry on once the page accepts the answer. The engine does the parts
9
+ that are the same for every service: finding the challenge, checking the answer worked, retrying, rotating the
10
+ IP, and capping what a run may spend. The solver does one thing: get past one challenge on the live page.
11
+
12
+ Think of the engine as the driver and the solver as the passenger with the toll money: the driver spots the
13
+ toll gate, stops, and only drives on once the barrier is up. The passenger pays.
14
+
15
+ The recipe reference is [authoring §6.1](authoring.md#61-captcha-solvers). This page is the why and the how.
16
+
17
+ ## First: should you?
18
+
19
+ Solving a captcha can break the site's terms of service, and in some places it matters legally. Before adding
20
+ a solver:
21
+
22
+ - **Look for the data another way.** An official API, a bulk export, a feed, a partner agreement.
23
+ [authoring §9](authoring.md#9-method-how-to-write-a-recipe-for-a-new-site) finds JSON the page loads
24
+ anyway, which often skips the page that shows the captcha.
25
+ - **Slow down first.** Many challenges only appear under load: `limits.delayMs` and a residential proxy
26
+ (`session.access`, [access.md](access.md)) often make them go away.
27
+ - **Log in, if the data is behind an account.** A `session.bootstrap` login, solved once and saved with
28
+ `saveTo`, costs one solve instead of one per page.
29
+
30
+ ## The four places a challenge appears
31
+
32
+ | Where | Recipe | What the engine does |
33
+ |---|---|---|
34
+ | After a navigation, click or key press | `session.captcha` | Looks for a visible widget after every `goto`, `click`, `press` and pagination click, and solves it before the next step. |
35
+ | As the block page itself (a 403 with a challenge) | `session.onBlock: { solve: true }` | A block whose page shows a widget is solved instead of failing; with `rotate: true` too, rotation is the fallback. |
36
+ | At one known point (a login form, an invisible reCAPTCHA v3) | a `captcha` step | Solves whatever challenge is on the page, v3 included; none is fine. |
37
+ | In a form, checked only when the form is posted (an image code) | a `captcha` step with `image` | The solver fills the answer, the step's `submit` posts the form, the page says yes or no. [Below](#form-captchas-checked-when-the-form-is-posted). |
38
+
39
+ ```json
40
+ {
41
+ "session": {
42
+ "captcha": { "solver": "capsolver", "verify": { "selector": "#results" }, "attempts": 2, "maxSolves": 5 },
43
+ "onBlock": { "solve": true, "rotate": true, "attempts": 2 }
44
+ },
45
+ "steps": [
46
+ { "type": "goto", "url": "https://shop.example/search?q={{vars.q}}" },
47
+ { "type": "extract", "id": "items", "selector": "#results .item", "kind": "css", "many": true }
48
+ ]
49
+ }
50
+ ```
51
+
52
+ ## What a solve looks like
53
+
54
+ ```text
55
+ ⇢ page 1 https://shop.example/search?q=pandina [403]
56
+ ⚿ captcha recaptcha-v2 on https://shop.example/search?q=pandina
57
+ ✗ captcha attempt 1 failed: the page still shows the challenge
58
+ ✓ captcha solved by capsolver (attempt 2, 21480 ms)
59
+ · steps.0 goto 23105 ms
60
+ ```
61
+
62
+ 1. **Detect.** The first *visible* match of `detect.selector` (default: `.g-recaptcha`, `.h-captcha`,
63
+ `.cf-turnstile` and their iframes). The solver gets `kind`, `url`, `siteKey`, `action` and a `selector`.
64
+ 2. **Solve.** One attempt spends one of `maxSolves`. The solver has `timeoutMs` (default 2 minutes), then its
65
+ `signal` aborts.
66
+ 3. **Verify.** The solver's `solved` is only a claim. The engine waits up to 10 s for the challenge to be gone
67
+ (`verify.gone`, default) and for `verify.selector` to appear, if given. A token the site rejects is a
68
+ failed attempt, and the next one starts from what the page shows then.
69
+ 4. **Give up.** After `attempts` (default 3), the step fails with a `CaptchaError`. That is a block: with
70
+ `onBlock.rotate`, the run takes a new IP, reopens the session and retries the step. A fresh IP often gets an
71
+ easier challenge, or none.
72
+
73
+ The report counts them: `captchas: { detected, solved, failed }`.
74
+
75
+ ## Form captchas: checked when the form is posted
76
+
77
+ Some sites have no "check" for their captcha: it is a code drawn as an image, a text field, and the form's
78
+ own button. Whether the code was right is only known once the form is posted and the page comes back, with
79
+ the result or with "Invalid CAPTCHA." and a new image. The public Vahan registrations report works this way,
80
+ and so does this step:
81
+
82
+ ```json
83
+ { "type": "captcha", "solver": "tesseract",
84
+ "image": "#captchaImage",
85
+ "refresh": "#captchaImg",
86
+ "field": "#externalCaptcha",
87
+ "submit": [ { "type": "click", "selector": "#applyTrigger" } ],
88
+ "verify": { "selector": "#makerDynamicReportHeader", "failure": "#captchaMsg", "timeoutMs": 30000 },
89
+ "attempts": 5 }
90
+ ```
91
+
92
+ [`@opencraw/captcha-tesseract`](../../packages/captcha-tesseract) is a ready reader for such image codes
93
+ (Tesseract, bundled, nothing downloaded). Put the step after the steps that fill the form. Each attempt:
94
+
95
+ 1. **The solver does its part.** It gets the image (`challenge.selector`), the answer field (`challenge.field`)
96
+ and the refresh control (`challenge.refresh`). A reader reads the image, clicks refresh when it is unsure
97
+ (that costs nothing: no form is posted), and fills the field.
98
+ 2. **The engine runs `submit`**: the button, or the fills a form that empties itself after a wrong code needs
99
+ first. Clicks, fills, key presses, selects, waits and scripts; each may have a `when`.
100
+ 3. **The page answers.** `verify.selector` showing is a yes. `verify.failure` showing is a no, at once, not
101
+ after a timeout, even when the page already showed the message from the attempt before. The engine tells
102
+ the new page, or a newly drawn element, from the old one. The same holds for the yes: on a page that
103
+ already shows the last report (a [worker window](./worker-mode.md) on its next item, a site that swaps the
104
+ report in without a reload), only a report drawn after this submit counts. The page keeps a captcha after a success (the next report needs a new
105
+ one), so `gone` is not asked.
106
+ 4. **The solver hears the verdict** (`verdict`, below), and the next attempt starts on the new image.
107
+
108
+ A form captcha needs `verify.selector`. Every report costs one captcha: a crawl of many reports is many
109
+ solves, and `maxSolves` caps them.
110
+
111
+ ### The manual solver
112
+
113
+ `"solver": "manual"` is built in, for headed runs: a person types the code in the browser window and presses
114
+ the button. The solver waits (10 minutes, or the step's `timeoutMs`) until the page navigates or the success
115
+ element shows, and the engine checks the page as for any solver; the `submit` steps do not run, since the
116
+ person submitted. Paired with a reader's audit, it collects what people typed and whether the site took it:
117
+ the labelled images a reader is tuned on.
118
+
119
+ ## Money
120
+
121
+ Every attempt is paid, successful or not, and a detector that matches the wrong element would drain a balance.
122
+ Hence `maxSolves` (default 10 per recipe run, rotations and bootstrap included; `0` detects without paying).
123
+ When it runs out, the challenge is left unsolved with a `captcha:budget` event, and the step fails.
124
+
125
+ ## Writing a solver
126
+
127
+ ```ts
128
+ import type { CaptchaSolver } from '@opencraw/core'
129
+
130
+ export const mySolver: CaptchaSolver = {
131
+ name: 'my-solver',
132
+ async solve (challenge, { page, lease, attempt, signal, log }) {
133
+ const token = await myService.solve({ kind: challenge.kind, siteKey: challenge.siteKey, url: challenge.url, signal })
134
+ await page.locator('[name="g-recaptcha-response"]').evaluate((field, value) => { field.value = value }, token)
135
+ await page.locator('form').first().evaluate(form => form.submit())
136
+ return { status: 'solved' }
137
+ },
138
+ }
139
+ ```
140
+
141
+ - **Do your part, then return `solved`.** A reader fills `challenge.field`; a widget solver puts the token
142
+ where the widget would (`g-recaptcha-response`, `cf-turnstile-response`) and runs its `data-callback` or
143
+ submits its form. When the recipe has `submit` steps, the engine runs them after you; return
144
+ `{ status: 'solved', submitted: true }` when you submitted yourself (or a person did).
145
+ - **`verdict(challenge, { status: 'solved' | 'rejected', reason? })`**, optional, tells you what the page said
146
+ after a `solved`: for an audit log, or to report a bad token to a service that refunds them.
147
+ - **`close()`**, optional, runs when the crawler closes: let a worker or a connection go.
148
+ - **`timeoutMs`**, optional, is your default time per solve when the recipe does not set one.
149
+ - **Throw or return `failed`** with a reason: both count as a failed attempt, and the reason reaches the trace.
150
+ - **Honour `signal`.** Stop polling your service when it aborts.
151
+ - **Use `lease` when the service accepts a proxy**: tokens are often tied to the IP that asked for them.
152
+
153
+ [`examples/captcha-solver/capsolver.mjs`](../../examples/captcha-solver/capsolver.mjs) is a complete solver for
154
+ CapSolver: reCAPTCHA v2 and v3, Turnstile and image captchas, with tests against a faked API.
155
+
156
+ ## A solver in any language, or behind an API
157
+
158
+ A solver does not have to be JavaScript. `commandCaptchaSolver` runs a program and `httpCaptchaSolver` calls a
159
+ service; both put the answer on the page themselves, so the program or the service never needs a browser.
160
+
161
+ ```js
162
+ // solvers.mjs
163
+ import { commandCaptchaSolver, httpCaptchaSolver } from '@opencraw/core'
164
+
165
+ export const captchaSolvers = [
166
+ httpCaptchaSolver('capsolver', 'https://solver.example.com/solve', { tokenEnv: 'SOLVER_TOKEN', allowedHosts: ['solver.example.com'] }),
167
+ commandCaptchaSolver('reader', ['python3', 'solvers/read_image.py']),
168
+ ]
169
+ ```
170
+
171
+ A recipe names it as any solver (`"solver": "capsolver"`). The request is the callout request of
172
+ [authoring §6](authoring.md#6-hooks) with `kind: "captcha"`, and its `input` is published as
173
+ `schemas/callout-captcha-input.schema.json`:
174
+
175
+ ```json
176
+ { "kind": "captcha", "name": "capsolver", "idempotencyKey": "…", "args": {}, "context": { "recipeId": "shop", "scope": {} },
177
+ "input": { "challenge": { "kind": "recaptcha-v2", "url": "https://shop.example/gate", "siteKey": "6Lc…" }, "attempt": 1 } }
178
+ ```
179
+
180
+ It answers with a `token` (a widget) or the `text` of an image captcha
181
+ (`schemas/callout-captcha-output.schema.json`):
182
+
183
+ ```json
184
+ { "status": "ok", "output": { "token": "03AGdBq…" } }
185
+ ```
186
+
187
+ - **A token** goes into the widget's response fields (`g-recaptcha-response`, `h-captcha-response`,
188
+ `cf-turnstile-response`), then the widget's `data-callback` runs, or its form is submitted when it has none.
189
+ `submit: "none"` stops there, for a recipe whose `submit` steps do it.
190
+ - **`text`** is typed into the challenge's answer field (a `captcha` step with `image` names it in `field`).
191
+ - **An image challenge** carries its picture in `input.image` (PNG, base64) when it names the image's selector.
192
+ - **The proxy** of the access lease is sent in `input.proxy` only with `sendProxy: true`, since it carries the
193
+ proxy's credentials; services that tie a token to an IP need it.
194
+ - **Slow services** may answer `{ "status": "pending", "retryAfterMs": 3000 }`: the solver asks again with the
195
+ same request until `maxWaitMs` or the solve's timeout (`timeoutMs`, 120 s). A captcha is always polled, never
196
+ parked on a durable host, because the page it belongs to has to stay open.
197
+ - `{ "status": "error", "error": "out of balance" }` fails that attempt with the reason in the trace; the engine
198
+ retries, rotates and counts it against `maxSolves` as for any solver.
199
+
200
+ ## Api recipes
201
+
202
+ An api recipe has no page to solve on. With `session.captcha`, a fetched page that shows a widget fails as a
203
+ block that says so. Put the part that meets the captcha in `session.bootstrap` (a browser), keep the cookies,
204
+ and let the requests reuse them.
@@ -0,0 +1,12 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Worked example: movies
6
+
7
+ This write-up now lives with its example, so the recipes and the reasoning behind them sit together:
8
+
9
+ - [Movies from Netflix and IMDb](../../examples/movies/README.md#how-the-recipes-were-written): api mode on JSON-LD, web mode behind a WAF.
10
+ - [From a movie to its lead actor's films](../../examples/filmography/README.md#how-the-recipes-were-written): navigation across pages, the shifted-field trap, reading the trace.
11
+
12
+ Every example is listed in [examples/README.md](../../examples/README.md).
@@ -0,0 +1,101 @@
1
+ # Building recipes in code
2
+
3
+ A recipe is JSON, and a crawl never runs code you wrote into a recipe. That does not mean you have to
4
+ type every recipe by hand. When you have fifty similar sites, a list of regions or a shared login, build
5
+ the recipe objects in JavaScript or TypeScript, and let the engine see only the final JSON shape.
6
+
7
+ The loader accepts a plain object as a recipe source, so there is no file round trip:
8
+
9
+ ```js
10
+ import { createCrawler, loadRecipes, memorySink } from '@opencraw/core'
11
+
12
+ const output = {
13
+ kind: 'output',
14
+ id: 'product',
15
+ version: 1,
16
+ fields: {
17
+ url: { type: 'url', required: true, key: true },
18
+ title: { type: 'string', required: true },
19
+ },
20
+ }
21
+
22
+ // One input recipe per shop: same steps, different start URL and selector.
23
+ const shops = [
24
+ { id: 'shop-a', url: 'https://a.example/catalog', title: 'h1.name' },
25
+ { id: 'shop-b', url: 'https://b.example/products', title: '.product-title' },
26
+ ]
27
+
28
+ const inputs = shops.map(shop => ({
29
+ kind: 'input',
30
+ id: shop.id,
31
+ output: 'product',
32
+ mode: 'web',
33
+ start: [{ url: shop.url }],
34
+ steps: [
35
+ { type: 'goto', url: '{{start.url}}' },
36
+ { type: 'extract', id: 'title', selector: shop.title, kind: 'css', take: 'text' },
37
+ { type: 'emit' },
38
+ ],
39
+ mapping: { url: { from: 'start.url' }, title: { from: 'title' } },
40
+ }))
41
+
42
+ const recipes = await loadRecipes([output, ...inputs])
43
+ const report = await createCrawler({ sink: memorySink() }).run(recipes)
44
+ ```
45
+
46
+ `loadRecipes` takes the output recipe and the inputs from one array and tells them apart by `kind`. If
47
+ you keep them apart, `loadRecipeSet({ output, inputs })` takes each part separately. Both accept any
48
+ mix of objects, JSON text, JSON Lines text, file paths and directories, so you can build some recipes in
49
+ code and keep the rest as files.
50
+
51
+ ## Type checking in TypeScript
52
+
53
+ `@opencraw/core` exports the recipe types, so no builder function is needed: `satisfies` checks an object
54
+ literal against the type and keeps its own narrow type.
55
+
56
+ ```ts
57
+ import type { InputRecipe } from '@opencraw/core'
58
+
59
+ const recipe = {
60
+ kind: 'input',
61
+ id: 'shop-a',
62
+ output: 'product',
63
+ mode: 'web',
64
+ start: [{ url: 'https://a.example/catalog' }],
65
+ steps: [{ type: 'goto', url: '{{start.url}}' }, { type: 'emit' }],
66
+ mapping: {},
67
+ } satisfies InputRecipe
68
+ ```
69
+
70
+ A misspelt step type (`'gotoo'`) is a compile error that suggests `'goto'`, and the editor completes step
71
+ types and keys. `OutputRecipe` does the same for an output recipe. Template expressions and bindings are
72
+ still checked at load time, as above.
73
+
74
+ ## From Python
75
+
76
+ `python-packages/opencraw` is a small standard-library package for building the same recipe dicts in Python
77
+ and driving the real CLI: `input_recipe`, `output_recipe` and `step` save typing, `write_recipes` writes the
78
+ files, `validate` and `run` call `opencraw validate` and `opencraw run` and hand back the records as dicts.
79
+ It restates no schema, so the engine stays the one place that says what is valid. See its
80
+ [README](../../python-packages/opencraw/README.md). It is published to PyPI as `opencraw`.
81
+
82
+ ## What is checked, and when
83
+
84
+ Objects get exactly the validation a file gets: the schema, the template expressions, the binding of each
85
+ input to its output. A mistake in a generated recipe throws a `RecipeValidationError` or
86
+ `RecipeBindingError` that names the recipe by its `id` (and by its position, such as `recipes[2]`, where
87
+ there is no id yet).
88
+ Nothing is checked while you build the objects, only when you load them.
89
+
90
+ To keep the generated recipes, write them out with `JSON.stringify(recipe, null, 2)`, which is what the CLI,
91
+ the MCP server and the Studio read.
92
+
93
+ ## Boundaries
94
+
95
+ - The objects are authoring-time only. Functions, class instances and anything that does not survive
96
+ `JSON.stringify` are not recipe content.
97
+ - The template language stays sandboxed (no calls, no `new`). Real logic during a crawl belongs in a
98
+ [`hook` step](./authoring.md), the one sanctioned place for it.
99
+ - Other languages (Python, Go, a shell script) can generate recipes the same way: write JSON, then hand
100
+ the files to the CLI or the MCP server. `packages/core/schemas/` has the JSON Schemas to generate
101
+ against. There is no Python package, because Python cannot hand a live object to the engine.
@@ -0,0 +1,157 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Writing recipes
6
+
7
+ A crawl is two kinds of JSON file: **one output recipe** that says what a record looks like, and **input
8
+ recipes** that say how to get there from a site or an API. The engine runs the inputs one after another and
9
+ every record, whatever its source, matches the output. The formal specification is
10
+ [requirements.md](../requirements.md); this is the working guide. To see what the engine does with each part, on
11
+ real sites with the traces and records it produced, read [How OpenCraw works](../how-it-works/README.md).
12
+
13
+ Point `$schema` at `packages/core/schemas/output-recipe.schema.json` or `input-recipe.schema.json` and your
14
+ editor validates and completes the file as you type.
15
+
16
+ ## 1. The output recipe
17
+
18
+ ```json
19
+ {
20
+ "kind": "output", "id": "product", "version": 1,
21
+ "fields": {
22
+ "url": { "type": "url", "required": true, "key": true },
23
+ "title": { "type": "string", "required": true },
24
+ "price": { "type": "currency", "currency": "EUR", "required": true },
25
+ "inStock": { "type": "boolean", "default": false, "onMissing": "default" },
26
+ "images": { "type": "array", "items": { "type": "url" } },
27
+ "seller": { "type": "object", "fields": { "name": { "type": "string" } } },
28
+ "scrapedAt": { "type": "datetime", "generated": "now" }
29
+ }
30
+ }
31
+ ```
32
+
33
+ - `key: true` fields identify a record; a repeated key is dropped as a duplicate.
34
+ - `required` fields fail the recipe when missing, unless `onMissing` says `skip-record` (drop the record),
35
+ `null`, or `default` (use `default`).
36
+ - `generated` fields come from the engine: `now`, `uuid`, `sourceUrl`, `recipeId`. Do not map them.
37
+ - Types: `string`, `number`, `integer`, `boolean`, `date` (`YYYY-MM-DD`), `datetime` (ISO 8601), `currency`
38
+ (`{ amount, currency }`), `url` (absolute), `enum` (`values`), `array` (`items`), `object` (`fields`), `json` (any value, kept as is).
39
+
40
+ ## 2. The input recipe
41
+
42
+ ```json
43
+ {
44
+ "kind": "input", "id": "shop-web", "output": "product", "mode": "web",
45
+ "start": [{ "url": "https://shop.example/catalog?page=1" }],
46
+ "steps": [ ... ],
47
+ "mapping": { ... }
48
+ }
49
+ ```
50
+
51
+ `mode` is `web` (a real browser) or `api` (HTTP requests, no browser). `start` lists where to begin; each
52
+ start point runs the whole step list with `start.url` and its `vars` in scope.
53
+
54
+ ### Steps
55
+
56
+ Every step can have an `id` (the name of the value it produces), an `onError` policy and a `when` template.
57
+
58
+ | Step | Mode | What it does |
59
+ |---|---|---|
60
+ | `goto` | web | Navigates to `url` (a template, relative to the current page). |
61
+ | `click`, `fill`, `press`, `select`, `scroll`, `wait`, `screenshot` | web | Interacts with the page. `click` with `optional: true` skips a missing element. |
62
+ | `evaluate` | web | Runs `script` in the page and binds the result. Trusted recipes only. |
63
+ | `request` | both | Sends an HTTP request (in web mode, through the page's own session); the response becomes the current document (and the `id`, if given). `as` forces the body kind: `json`, `jsonl`, `html`, `text`, `xml`, `yaml`, `markdown`, `csv`, `xlsx`, `pptx`, `docx` or `pdf`. A relative `url` resolves against the current page, like a link. |
64
+ | `extract` | both | Selects from the current document (or the `from` id): `kind` is `css`, `xpath` (live pages, fetched HTML and XML), `jsonpath`, `regex` (group 1 of the text) or `table` (PDF, workbook and deck tables); `take` is `text` (default), `html`, `value`, `json` or `attr:href`; `many: true` gives a list. A single `extract` that matches nothing fails, so give it `onError: { "policy": "skip" }` when the element is optional. |
65
+ | | | A `jsonpath` extract whose `from` holds **text** parses it as JSON, and a **list of texts** (every `script[type="application/ld+json"]` of a page, extracted with `many`) becomes an array of the entries that parse: `$[*].actors[*].name` then finds the block that has actors wherever it sits. |
66
+ | `set` | both | Binds a literal or a rendered template. |
67
+ | `forEach` | both | Runs `steps` once per item of the list `over` (or per live element matching `selector`, web), with the item bound as `as`. `emit: true` produces one record per iteration. |
68
+ | `paginate` | both | Runs `steps` per page, then follows `next`: `{ "selector" }` (web, clicks it), `{ "url" }` (a template), `{ "jsonpath" }` (api, a URL from the document; add `"as"` to bind a cursor instead). Stops when there is no next page, when `until` is truthy, or at `maxPages`. |
69
+ | `if` | both | Runs `steps` when `test` renders truthy, else `else`. |
70
+ | `collect` | both | Appends `value` to a list bound in an enclosing scope (`into`). |
71
+ | `captcha` | web | Solves a captcha on the page, if there is one (see [captcha.md](./captcha.md)). |
72
+ | `emit` | both | Produces a record from everything in scope. |
73
+ | `hook` | both | Calls a handler registered in code by `name`, with `args`. |
74
+
75
+ ### Templates
76
+
77
+ `{{path}}` reads any id in scope, the `forEach` variable (`{{item.href}}`), `{{vars.name}}`,
78
+ `{{start.url}}`, `{{page.url}}` and `{{page.number}}`. A template that is exactly one placeholder keeps the
79
+ value's type, so `"value": "{{links}}"` in a `set` binds the list itself, not its text (`forEach.over` takes the bare id: `"over": "links"`).
80
+
81
+ ### Scope, in one paragraph
82
+
83
+ `forEach` and `paginate` open a fresh scope per iteration or page and drop it afterwards, so page 2 never
84
+ sees page 1's values. Navigations inside a `forEach` (visiting every product) are fine: `paginate` returns to
85
+ the listing page before looking for the next link. `page.url` is the current page in web mode and the last
86
+ request's URL in api mode.
87
+
88
+ ### Errors
89
+
90
+ `onError` on a step, else on the recipe, else `fail`: `{ "policy": "skip" }` leaves the id unset and
91
+ continues, `{ "policy": "retry", "attempts": 3, "backoffMs": 500 }` retries, `fail` stops the recipe. A
92
+ stopped recipe never stops the run unless the crawler was created with `onRecipeError: 'stop'`.
93
+
94
+ ## 3. Mapping
95
+
96
+ ```json
97
+ "mapping": {
98
+ "url": { "from": "page.url" },
99
+ "title": { "from": "raw_title", "transform": [{ "op": "trim" }] },
100
+ "price": { "from": "raw_price", "transform": [{ "op": "regex", "pattern": "([\\d.,]+)" }, { "op": "currency", "locale": "de-DE" }] },
101
+ "price2": { "from": ["price_int", "price_cents"], "transform": [{ "op": "join", "separator": "." }, { "op": "currency" }] },
102
+ "images": { "from": "imgs", "transform": [{ "op": "absoluteUrl" }, { "op": "unique" }] },
103
+ "variants": { "each": "variant_rows", "fields": { "size": { "from": ".", "transform": [{ "op": "regex", "pattern": "<td class=\"size\">([^<]+)" }] } } },
104
+ "seller.name": { "from": "seller" }
105
+ }
106
+ ```
107
+
108
+ - The key is an output field, dotted for nested objects.
109
+ - `from` is an id or a path into one; several sources give the chain a list.
110
+ - `each` builds an array of objects from a list; inside, `from` is relative to each item (`.` is the item).
111
+ - A scalar transform applied to a list runs on every item. A missing value passes through every transform
112
+ except `default`, `template` and `hook`, then meets the field's missing policy.
113
+ - The ops: `trim`, `lowercase`, `uppercase`, `replace`, `regex`, `split`, `join`, `first`, `last`, `nth`,
114
+ `slice`, `concat`, `coalesce`, `default`, `number`, `integer`, `boolean`, `currency`, `date`,
115
+ `absoluteUrl`, `urlEncode`, `flatten`, `unique`, `sum`, `count`, `template`, `jsonpath`, `lookup`, `group`, `hook`.
116
+
117
+ ## 4. Sessions and logins
118
+
119
+ ```json
120
+ "session": {
121
+ "bootstrap": {
122
+ "keep": ["cookies"],
123
+ "steps": [
124
+ { "type": "goto", "url": "https://shop.example/login" },
125
+ { "type": "fill", "selector": "#user", "value": "{{vars.user}}" },
126
+ { "type": "fill", "selector": "#pass", "value": "{{vars.pass}}" },
127
+ { "type": "click", "selector": "button[type=submit]" },
128
+ { "type": "wait", "selector": "#logged-in" }
129
+ ],
130
+ "saveTo": "state/shop.json"
131
+ }
132
+ }
133
+ ```
134
+
135
+ The bootstrap always runs in a browser. What it keeps is handed to the recipe: a `web` recipe starts its
136
+ page from it, an `api` recipe sends its cookies with every request. `storageStatePath` reuses a state saved
137
+ by `saveTo` on a later run. `headers`, `cookies`, `userAgent` and `viewport` apply to both modes.
138
+
139
+ ## 5. Running
140
+
141
+ ```ts
142
+ import { createCrawler, jsonLinesSink, loadRecipeSet } from '@opencraw/core'
143
+
144
+ const recipes = await loadRecipeSet({ output: 'recipes/product.output.json', inputs: ['recipes/'] })
145
+ const crawler = createCrawler({
146
+ hooks: { positive: input => Number(input) > 0 },
147
+ sink: jsonLinesSink('out/products.jsonl'),
148
+ onEvent: event => console.log(event.type, event),
149
+ })
150
+ const report = await crawler.run(recipes)
151
+ await crawler.close()
152
+ ```
153
+
154
+ `loadRecipeSet` validates every file and checks that each input can feed the output (mapped fields exist,
155
+ ids are bound, required fields are covered, web steps stay in web recipes). Errors list every problem with
156
+ its JSON path. The report says, per recipe, how many records were emitted, rejected or dropped as
157
+ duplicates, how many pages were visited, and the error that stopped it, if any.