@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.
- package/README.md +27 -1
- package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
- package/dist/diff-tool/index.d.ts +3 -0
- package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/docs-tool/index.d.ts +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.esm.js +430 -1
- package/dist/list-tool/index.d.ts +3 -0
- package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
- package/dist/main.d.ts +7 -0
- package/dist/probe-tool/index.d.ts +2 -0
- package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
- package/dist/recipe-source/index.d.ts +3 -0
- package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
- package/dist/run-tool/index.d.ts +3 -0
- package/dist/run-tool/run-tool.handler.d.ts +51 -0
- package/dist/server/create-server.use-case.d.ts +11 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/src/docs-tool/index.d.ts +3 -0
- package/dist/src/server/create-server.use-case.d.ts +1 -1
- package/dist/src/studio-tool/index.d.ts +2 -0
- package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/studio-tool/index.d.ts +2 -0
- package/dist/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/validate-tool/index.d.ts +3 -0
- package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
- package/docs/how-it-works/01-recipes.md +698 -0
- package/docs/how-it-works/02-page-steps.md +672 -0
- package/docs/how-it-works/03-data-steps.md +813 -0
- package/docs/how-it-works/04-flow-steps.md +738 -0
- package/docs/how-it-works/05-templates-and-scope.md +428 -0
- package/docs/how-it-works/06-documents.md +1413 -0
- package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
- package/docs/how-it-works/08-policies.md +405 -0
- package/docs/how-it-works/09-running.md +804 -0
- package/docs/how-it-works/README.md +225 -0
- package/docs/recipes/README.md +22 -0
- package/docs/recipes/access.md +440 -0
- package/docs/recipes/authoring.md +1503 -0
- package/docs/recipes/azure-durable.md +180 -0
- package/docs/recipes/captcha.md +204 -0
- package/docs/recipes/movies-example.md +12 -0
- package/docs/recipes/programmatic-authoring.md +101 -0
- package/docs/recipes/quick-guide.md +157 -0
- package/docs/recipes/vehicles-example.md +9 -0
- package/docs/recipes/worker-mode.md +221 -0
- package/docs/studio/manual.md +312 -0
- package/docs/studio/pdf-agreements.md +150 -0
- package/package.json +29 -4
|
@@ -0,0 +1,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.
|