polite-crawler 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +555 -8
  2. package/build/cli-daemon-ZYRdy6EW.mjs +38 -0
  3. package/build/cli-daemon-ZYRdy6EW.mjs.map +1 -0
  4. package/build/cli-recipes-Bci6HREA.mjs +59 -0
  5. package/build/cli-recipes-Bci6HREA.mjs.map +1 -0
  6. package/build/cli.mjs +673 -26
  7. package/build/cli.mjs.map +1 -1
  8. package/build/daemon-DK6U51_v.mjs +515 -0
  9. package/build/daemon-DK6U51_v.mjs.map +1 -0
  10. package/build/{open-Ca1lcWR3.d.mts → daemon-wwIWsUmU.d.mts} +919 -17
  11. package/build/{diff-Dk2eSvi-.mjs → diff-BUM91Rev.mjs} +2 -2
  12. package/build/{diff-Dk2eSvi-.mjs.map → diff-BUM91Rev.mjs.map} +1 -1
  13. package/build/{extract-DcwNrTK7.mjs → extract-CefB5Vfn.mjs} +2 -2
  14. package/build/{extract-DcwNrTK7.mjs.map → extract-CefB5Vfn.mjs.map} +1 -1
  15. package/build/hours.d.mts +26 -0
  16. package/build/hours.mjs +48 -1
  17. package/build/hours.mjs.map +1 -1
  18. package/build/index.d.mts +491 -11
  19. package/build/index.mjs +14 -9
  20. package/build/loopback-C_QYowSf.mjs +22 -0
  21. package/build/loopback-C_QYowSf.mjs.map +1 -0
  22. package/build/mcp.d.mts +158 -2
  23. package/build/mcp.mjs +567 -18
  24. package/build/mcp.mjs.map +1 -1
  25. package/build/ollama-BJxkjny4.mjs +160 -0
  26. package/build/ollama-BJxkjny4.mjs.map +1 -0
  27. package/build/{open-DnrWgkC0.mjs → open-CbpSCEZc.mjs} +1127 -16
  28. package/build/open-CbpSCEZc.mjs.map +1 -0
  29. package/build/recipe-GiFQojmo.mjs +279 -0
  30. package/build/recipe-GiFQojmo.mjs.map +1 -0
  31. package/build/recipe-product-Dellqudu.mjs +892 -0
  32. package/build/recipe-product-Dellqudu.mjs.map +1 -0
  33. package/build/{runtime-k8mo1_7U.mjs → runtime-D_r2SYge.mjs} +3 -3
  34. package/build/{runtime-k8mo1_7U.mjs.map → runtime-D_r2SYge.mjs.map} +1 -1
  35. package/build/transformers-BQMlaGj4.mjs +133 -0
  36. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  37. package/build/{duration-iIuMMofi.mjs → version-BPem-XZd.mjs} +10 -10
  38. package/build/version-BPem-XZd.mjs.map +1 -0
  39. package/build/{wacz-sfOLT8e9.mjs → wacz-QyzLp4mC.mjs} +3 -3
  40. package/build/{wacz-sfOLT8e9.mjs.map → wacz-QyzLp4mC.mjs.map} +1 -1
  41. package/build/{warc-VIPscWxY.mjs → warc-BHcaRKHw.mjs} +2 -2
  42. package/build/{warc-VIPscWxY.mjs.map → warc-BHcaRKHw.mjs.map} +1 -1
  43. package/build/webbotauth-CjJdTAfE.mjs.map +1 -1
  44. package/package.json +10 -2
  45. package/build/duration-iIuMMofi.mjs.map +0 -1
  46. package/build/open-DnrWgkC0.mjs.map +0 -1
package/README.md CHANGED
@@ -18,6 +18,7 @@ The site has interactive demos with the real code: <https://mark1russell7.github
18
18
  - **Watch and compare.** `watch` checks pages again on a schedule and tells what changed. `diff` compares two versions, and `read` takes `at`: the page as it was at a time.
19
19
  - **Archived copies, on request.** When a site refuses a page or is down, `fallback: "archive"` gives a copy from the Wayback Machine or Common Crawl. The result marks the copy as archived, with its date.
20
20
  - **Signed requests.** With an Ed25519 key, the crawler signs each request (Web Bot Auth). A site can then verify that a request comes from your crawler.
21
+ - **Recipes.** `readVenue` reads the opening hours of a venue from its site, and `readProduct` reads a product page. `defineRecipe` makes a recipe of your own.
21
22
 
22
23
  ## Install
23
24
 
@@ -34,6 +35,8 @@ npx playwright-core install chromium-headless-shell
34
35
 
35
36
  Without the browser, the crawler reads each page as plain HTML. It checks for the browser one time, so a page that needs JavaScript gets a note at once, with no wait.
36
37
 
38
+ Hybrid search (search by meaning and by words) is optional too. It uses a local model: transformers.js in the process (`npm install @huggingface/transformers`), or a local Ollama server. [Hybrid search](#hybrid-search) tells how.
39
+
37
40
  The package is ESM only, and it includes its TypeScript declarations. It has these entry points:
38
41
 
39
42
  | Entry point | What it gives | In a browser |
@@ -67,6 +70,7 @@ The store is in `~/.polite-crawler`. Use `--dir` or `POLITE_CRAWLER_DIR` for a d
67
70
  - `fetch --archive-fallback` gives an archived copy when a site refuses a page or is down. [Archived copies after a refusal](#archived-copies-after-a-refusal) tells the rules.
68
71
  - `--sign-key <file>` and `--signature-agent <url>` sign each request. `key-directory --sign-key <file>` prints the key directory to host. [Signed requests](#signed-requests-web-bot-auth) tells how.
69
72
  - `find --rank fast` makes a long question faster, and its results can be different. [Fast ranking](#fast-ranking) gives the measured difference.
73
+ - `find --rank hybrid --embedder transformers` (or `--embedder ollama`) also finds the pages with a similar meaning. `embed --embedder <name>` gives vectors to each stored page first. [Hybrid search](#hybrid-search) tells the options.
70
74
  - `reindex` builds the index again. The documents of a recipe that the command cannot read (the recipes of another application) stay. The output counts them, and the captures that it skipped.
71
75
  - `export --out <file>` writes the stored captures as a WARC or a WACZ file. [Export to a web archive](#export-to-a-web-archive) tells what goes into the file.
72
76
  - `extract <doc> --field name=value[:kind]` checks the values that you propose for the fields of a stored page. Repeat `--field` for each field. `--capture` checks the text of one capture, and `--at` the version that was current at a time. [Check field values against a page](#check-field-values-against-a-page) tells the statuses.
@@ -96,15 +100,34 @@ npx polite-crawler watch list
96
100
  - `read --at` and `verify --at` use the version that was current at that time. [Read a page as it was at a time](#read-a-page-as-it-was-at-a-time) gives the rules.
97
101
  - `diff` prints the changed lines as a unified diff. A changed line marks its words with `[-removed-]` and `{+added+}`. `--from` and `--to` take a capture id or a time.
98
102
  - `watch add` takes `--every` (1 hour or more, `24h` by default), `--confirm 1` or `2`, `--render`, `--scope` and `--label <name=value>`.
99
- - `watch run` takes `--budget` (default `10m`) and `--max-pages` (default 100). It prints each check, then a summary. A scheduler of the operator starts it: the package has no daemon.
103
+ - `watch run` takes `--budget` (default `10m`) and `--max-pages` (default 100). It prints each check, then a summary. A scheduler of the operator starts it.
104
+ - `daemon` checks the watch list on a schedule, until you stop it. With `--mcp-http`, it also serves the MCP tools over local HTTP. [The daemon](#the-daemon) tells how.
105
+
106
+ To read the hours of a venue or a product page, use `venue` and `product`. They use the recipes of the package:
107
+
108
+ ```sh
109
+ npx polite-crawler venue https://forno-esempio.example/ --name "Forno Esempio" --pages 2
110
+ npx polite-crawler venue https://forno-esempio.example/ --compare kept-week.json
111
+ npx polite-crawler product "https://bottega-campione.example/products/olio-esempio?variant=222"
112
+ ```
113
+
114
+ - `venue` prints the week of the site and each reading with its URL, its rank and its quote. It also prints the days where the readings disagree, the menus, the phone, the pages and the notes.
115
+ - `venue` takes `--name`, `--require-name`, `--address`, `--lat` and `--lon`, `--osm <value>` and `--pages <0-8>`. [Read the hours of a venue](#read-the-hours-of-a-venue) tells what each one does.
116
+ - Write a number below zero after `=`, for example `--lon=-20.5`. Else the command line reads it as an option.
117
+ - `venue --compare <file>` compares the week of the site with a week that you keep, as JSON. It prints `same`, `changed`, `new` or `not read` for each day. The file is a week, for example `{"mon": [["07:00", "15:00"]], "sun": []}`, or the `from_site` of `venue --json`.
118
+ - `product` prints the name, the brand, the SKU and the GTIN, and the price as money with the range of the offers. It also prints the availability, the variant, the seller, and a wall or a held-back page.
119
+ - The two commands take `--render`, `--max-age`, `--budget`, `--user-provided`, `--archive-fallback` and `--archive-despite-robots`. `--on-behalf-of <agent>` applies the robots.txt rules of an agent too, for example `Claude-User`.
120
+ - `--json` prints the `VenueResult` or the `ProductResult` of the library.
100
121
 
101
122
  The command gives these exit codes:
102
123
 
103
124
  | Code | Meaning |
104
125
  | --- | --- |
105
126
  | `0` | The command did its work. |
106
- | `1` | The command did not do its work: a page that the crawler did not fetch, no search result, a quote that is not in the document, a document that is not stored or that is held back, or an error. `discover` gives 1 when it finds no URL, and `crawl` when it gets no page. `read --at` and `verify --at` give 1 for a time before the first capture. `watch run` gives 1 when a check is `refused` or an `error`, or when entries stay due. `watch remove` gives 1 for a URL that is not on the list. With `--archive-fallback`, a page with an archived copy counts as done. `extract` gives 1 when a field is not `verified` or `normalized`. |
127
+ | `1` | The command did not do its work: a page that the crawler did not fetch, no search result, a quote that is not in the document, a document that is not stored or that is held back, or an error. `discover` gives 1 when it finds no URL, and `crawl` when it gets no page. `read --at` and `verify --at` give 1 for a time before the first capture. `watch run` gives 1 when a check is `refused` or an `error`, or when entries stay due. `watch remove` gives 1 for a URL that is not on the list. With `--archive-fallback`, a page with an archived copy counts as done. `extract` gives 1 when a field is not `verified` or `normalized`. `embed` gives 1 when its budget stops it before each page has vectors. |
107
128
  | `2` | The command line is wrong. The command opens no store. |
129
+ | `3` | `venue` and `product` read nothing: no opening hours of the site or no Product data, a page that is held back, or a page that did not come. |
130
+ | `4` | `venue` and `product`: the site refused the crawler (a wall). The causes are a 401, a 402 or a 403, a challenge, a bot check, robots.txt or a stop of the host. The crawler does not try a different identity. |
108
131
 
109
132
  ## Use it from Claude (MCP)
110
133
 
@@ -149,7 +172,7 @@ The server gives nine tools:
149
172
  | Tool | What it does |
150
173
  | --- | --- |
151
174
  | `crawl_fetch` | It fetches pages into the store, and a page in the store answers with no request. It gives the age of each copy. With `archive_fallback`, a page that the site refuses gets an archived copy, marked `archived`. |
152
- | `crawl_find` | It searches the stored pages and gives ranked passages. |
175
+ | `crawl_find` | It searches the stored pages and gives ranked passages. When the server has an embedder (`--embedder`), it also takes `rank: "hybrid"`, and each hit gives its `passage`. |
153
176
  | `crawl_read` | It gives a range of a stored document, with the capture ids to cite and the position of the next range. With `at`, it reads the version that was current at that time. |
154
177
  | `crawl_verify` | It finds a quote in a stored document and gives the exact span. It takes `at` too. |
155
178
  | `crawl_extract` | It checks the values that the assistant proposes for the fields of a stored page, and gives a status and a span for each field. It does not make values. With `at`, it checks the version that was current at that time. |
@@ -162,6 +185,8 @@ The MCP server applies the robots.txt rules for `Claude-User` too, because an as
162
185
 
163
186
  No tool adds a watch. A watch commits future requests to a site, so the operator adds it with `polite-crawler watch add`. An assistant can read the list (`crawl_watch_list`) and compare the versions of a watched page (`crawl_diff`).
164
187
 
188
+ The daemon serves the same tools over local HTTP, with a bearer token (`polite-crawler daemon --mcp-http`). There, `crawl_site` and `crawl_discover` are MCP tasks: a client gets a task id at once, and gets the result later. [MCP over HTTP](#mcp-over-http) tells how.
189
+
165
190
  ### Web text is data, not instructions
166
191
 
167
192
  A page can contain text that tries to give orders to the assistant. Thus each tool gives the text of the pages in an envelope:
@@ -184,7 +209,7 @@ text (characters 0 to 4000 of 25000):
184
209
 
185
210
  When a page asks not to be AI input (`ai-input=no`), the server holds back its text. The tool `crawl_read` has the parameter `user_provided` for a URL that the user gave. A page can tell the assistant to set this parameter, so the server needs a signal from a person:
186
211
 
187
- 1. When the client can ask the user (MCP elicitation), the server asks the user to confirm. The text comes back only when the user confirms.
212
+ 1. When the client can ask the user (MCP elicitation), the server asks the user to confirm. The text comes back only when the user confirms. When the question fails, for example at a timeout, the text stays held back. The opt-in of the operator does not answer for a user who does not answer.
188
213
  2. When the client cannot ask, the server releases the text only when the operator started it with `POLITE_CRAWLER_ALLOW_USER_PROVIDED=1` or `--allow-user-provided`.
189
214
  3. Else the text stays held back, and the note tells how to permit it.
190
215
 
@@ -201,6 +226,16 @@ The crawler records each release in the capture log.
201
226
  | `--no-browser` | | Do not open a headless browser. |
202
227
  | `--sign-key <file>` | `POLITE_CRAWLER_SIGN_KEY` | Sign each request with this Ed25519 key (PKCS#8 PEM or JWK). [Signed requests](#signed-requests-web-bot-auth) tells how. |
203
228
  | `--signature-agent <url>` | `POLITE_CRAWLER_SIGNATURE_AGENT` | Where a site finds the public key of `--sign-key`: an https origin, or the URL of a JSON Web Key Set. |
229
+ | `--embedder <name>` | `POLITE_CRAWLER_EMBEDDER` | The local model of hybrid search: `transformers` or `ollama`. `crawl_find` then takes `rank: "hybrid"`. |
230
+ | `--embed-model <name>` | `POLITE_CRAWLER_EMBED_MODEL` | The model. The defaults are `Xenova/bge-small-en-v1.5` (transformers) and `nomic-embed-text` (Ollama). |
231
+ | `--ollama-url <url>` | `POLITE_CRAWLER_OLLAMA_URL` | The Ollama server. The default is `http://127.0.0.1:11434`. |
232
+ | `--allow-remote-ollama` | `POLITE_CRAWLER_ALLOW_REMOTE_OLLAMA=1` | Permit an Ollama server on a different computer. The page text goes to that server. |
233
+
234
+ For hybrid search with transformers.js, add the package to the `npx` command, as for the browser:
235
+
236
+ ```sh
237
+ claude mcp add polite-crawler -- npx -y -p polite-crawler -p @huggingface/transformers polite-crawler mcp --embedder transformers
238
+ ```
204
239
 
205
240
  ## Use it as a library
206
241
 
@@ -235,7 +270,7 @@ await crawler.close();
235
270
  - `diff(doc, { from, to })` compares two versions. [Compare two versions](#compare-two-versions) tells what it gives.
236
271
  - `watch` is the watch list: `add`, `remove`, `list` and `run`. [Watch pages](#watch-pages) tells the rules.
237
272
  - `find` reads a query syntax. Text in double quotes is a phrase, and `bake*` is a prefix. The documents that hold each word come first. Case, accents and Unicode forms do not change a match, and Chinese, Japanese and Thai words match too.
238
- - `find` takes `rank`. The default is `exact`, and `fast` makes a long question faster. [Fast ranking](#fast-ranking) tells the difference.
273
+ - `find` takes `rank`. The default is `exact`, and `fast` makes a long question faster. [Fast ranking](#fast-ranking) tells the difference. `hybrid` also finds by meaning, with the option `embedder` of `openCrawler`. [Hybrid search](#hybrid-search) tells how.
239
274
  - A page that asks not to be AI input is held back in each result. `fetch` gives no title, and `read` gives no title, no text and only the structural metadata. `verify` gives `found: false`, and `find` leaves the page out. Only `userProvided` lifts the hold, and the crawler logs it.
240
275
  - The hold applies to each version too. When a later capture does not ask it, the current text is not held back, but the earlier versions that asked it stay held back. `verify` with no `capture` does not examine them, `read` and `verify` of their capture hold them back, and `versions` shows no title for them.
241
276
 
@@ -286,7 +321,7 @@ The root entry point also exports the parts of the runtime, for an application t
286
321
  - `extractDocument` and `checkFields`: the check of field values over a runtime, and over a text and its metadata with no store.
287
322
  - `extractPage`, `typesOf`, `decodeBody` and `bodyEncoding`: the page reader and the decoder of a body in its declared encoding.
288
323
  - `readPdf` and `isPdf`: the PDF reader. `PdfOptions` sets limits on the pages, the characters, the time and the inflated bytes.
289
- - `fromText`, `fromSchemaOrg`, `fromCompact` and `describeWeek`: the opening-hours reader.
324
+ - `fromText`, `fromSchemaOrg`, `fromCompact` and `describeWeek`: the opening-hours reader. `compareHours` compares a reading with a week that you keep.
290
325
  - `parseRobots` and `decide`: the robots.txt parser and matcher.
291
326
  - `RenderRefused`: the error of a renderer that did not render a page, with the reason `refused`, `download`, `scheme` or `browser`. A crawler does not count it as a failure of the host. After the reason `browser`, the crawler renders no more pages.
292
327
 
@@ -309,7 +344,7 @@ The readers give more facts than before:
309
344
  - `signer`: the signer of each request (Web Bot Auth). [Signed requests](#signed-requests-web-bot-auth) tells its options.
310
345
  - `scope`, `labels` and `scopeRank`: the labels of each record. One fetch of a robots.txt can serve requests of different scopes. Its record takes the labels of each request and the scope with the highest `scopeRank`, the most private one.
311
346
 
312
- `openCrawler` takes `egress`, `politeness` and `hostState` too, and also `clock`, `scope`, `onEvent` and `extractors`. Its default `hostState` is the file `hosts.sqlite` in the store. Thus the processes on one store share the pace, the stops and the validator memo of each host.
347
+ `openCrawler` takes `egress`, `politeness` and `hostState` too, and also `clock`, `scope`, `onEvent`, `extractors` and `recipes` ([the recipes of an application](#writing-a-recipe)). Its default `hostState` is the file `hosts.sqlite` in the store. Thus the processes on one store share the pace, the stops and the validator memo of each host.
313
348
 
314
349
  `FetchRequest.maxAgeMs` can be `"auto"`: the crawler then uses the freshness that the server gave (`Cache-Control`, `Expires`, or 10 percent of the age of `Last-Modified`). `crawler.resetHosts(host?)` removes the stops and the failure counts of one host or of each host, for a process that runs for a long time.
315
350
 
@@ -325,6 +360,8 @@ These parts are the stable API:
325
360
  - The diff: `diffDocument`, `diffTexts`, `DIFF_LIMITS`, `DiffOptions`, `DiffResult`, `DiffSide`, `DiffSummary`, `DiffHunk`, `DiffLine`, `WordPart`, `MetaChange`, `VersionChoice`, `DiffLimits`, `DiffInput` and `TextDiff`.
326
361
  - The time travel: `versionAtTime`, `isoTime` and `VersionAt`.
327
362
  - The watch list: `WatchList`, `WatchOptions`, `WatchEntry`, `WatchPending`, `WatchKind`, `WatchRun`, `WatchRunOptions`, `WatchEvent`, `WatchSummary`, `WatchStop`, `WATCH_LIMITS` and the file `watch.sqlite` (`WATCH_SCHEMA`). For a runtime of your own, it also has `WatchStore`, `WatchStoreOptions`, `runWatch` and `WatchRunnerOptions`. The lease steps of `WatchStore` (`claim`, `release` and `settle`) are the engine.
363
+ - The daemon: `startDaemon`, `scheduleWatches`, `changeCommand`, `readDaemonState`, `daemonStateFile`, `DAEMON_DEFAULTS`, their options and results, and the state file (`DAEMON_STATE_SCHEMA`). `polite-crawler/mcp` adds `startMcpHttpServer`, `mcpHttpHost`, `CrawlTaskStore`, `TASK_LIMITS`, `MCP_HTTP_DEFAULTS`, `sameToken` and `makeToken`.
364
+ - The MCP tasks follow the task API of the MCP SDK, which the SDK marks as experimental. When the SDK changes that API, the tasks change with it, and `CHANGELOG.md` says so.
328
365
  - The options and the results of `discover` and `crawl`: `DiscoverOptions`, `DiscoverResult`, `DiscoveredUrl`, `DiscoverSource`, `DiscoverStop`, `CrawlOptions`, `CrawlRun`, `CrawlPage`, `CrawlSummary`, `CrawlStop`, `UrlPattern` and `ScopeOptions`.
329
366
  - `polite-crawler/mcp`: `createMcpServer`, `startMcpServer` and its tools.
330
367
  - The readers: `polite-crawler/robots`, `/hours`, `/html`, `/classify`, `/politeness` and `/signals`. The site and its tools use them in the browser.
@@ -333,6 +370,12 @@ These parts are the stable API:
333
370
  - The archived fallback: `fallback` and `archiveDespiteRobots` of `FetchOptions`, the field `archived` of the results, and the types `Archived` and `WebArchive`.
334
371
  - Signed requests: `createWebBotAuthSigner`, `keyDirectory`, `verifySignature`, `KEY_DIRECTORY_PATH`, `KEY_DIRECTORY_TYPE`, the option `signer`, and the types `WebBotAuthOptions`, `WebBotAuthSigner`, `RequestSigner`, `KeyDirectory`, `DirectoryKey`, `KeyInput`, `SignatureAgentType`, `SignedRequest` and `SignatureCheck`.
335
372
  - The headers of a signature follow the draft. When the draft changes, the headers change with it, and `CHANGELOG.md` says so.
373
+ - Hybrid search: the option `embedder` of `OpenOptions`, the interface `Embedder` (the extension point) with `EmbedCallOptions`, and the adapters `transformersEmbedder` and `ollamaEmbedder` with their options (`TransformersEmbedderOptions`, `OllamaEmbedderOptions`, `Pooling`, `EmbeddingModel`).
374
+ - Also stable: `HybridCrawler` (the result of `openCrawler`), `rank: "hybrid"` (`SearchRank`, `SearchOptions`), `embed`, `rankWithin` with `RankOptions` and `Ranked`, and `hybrid` (`HybridSearch`). The three members are optional in `PoliteCrawler`, and `OpenedCrawler` does not change.
375
+ - The options and the results of hybrid search are stable too: `HybridOptions`, `HybridFindOptions`, `EmbedOptions`, `EmbedResult`, `EmbedStop`, `Passage` and `Found.passage`. So are `HYBRID_DEFAULTS`, `OLLAMA_MODELS`, `TRANSFORMERS_MODELS`, `NO_EMBEDDER`, `NO_OLLAMA` and `NO_TRANSFORMERS`.
376
+
377
+ - The recipe SDK: `defineRecipe`, `runRecipe`, `registerRecipe`, `readJson`, the option `recipes` of `openCrawler` and `createRuntime`, and `RESERVED_RECIPES`. Its types are `Recipe`, `RecipeDefinition`, `RecipeExtract`, `RecipeCapture`, `RecipeRequest`, `RecipeRequestOptions`, `RecipeRunOptions`, `RecipeResult`, `RecipeDoc` and `RecipeTarget`.
378
+ - The recipes: `readVenue`, `venueReadingsOf`, `compareHours`, `readProduct` and `productOf`. Their options and results are stable too: `VenueOptions`, `VenueResult`, `VenueReading`, `VenuePage`, `VenueLink`, `VenueLocation`, `VenueOsm`, `VenueSource`, `VenueRead`, `HoursComparison`, `ProductOptions`, `ProductResult`, `ProductRead`, `ProductVariant` and `Money`.
336
379
 
337
380
  The other exports of the root entry point are the engine. Examples are `createRuntime`, `catchUpIndex`, `Crawler`, `CaptureLog`, `DocIndex`, `FileBlobStore`, `SqliteHostStateStore`, `Extractors`, `fetchAndIndex` and `makeRecord`. An application can build on them. A minor version before 1.0.0 can change them, and its section in `CHANGELOG.md` says so.
338
381
 
@@ -342,8 +385,137 @@ The engine of discovery and of the crawl is `discoverSite`, `crawlSite`, `readSi
342
385
 
343
386
  The engine of the time travel and of the watch list is the optional method `ReadingIndex.versionAt`, `DocIndex.versionAt` and `Crawler.hostStop`.
344
387
 
388
+ The engine of hybrid search is `createHybridSearch`, `HybridSearchOptions`, `VectorStore`, `VECTORS_SCHEMA` and the types of the vector store. It also includes `chunkText`, `chunkTokensOf`, `estimateTokens`, `CHUNK_LIMITS`, `fuseRanks`, `unitVector`, `isLoopback`, `FeatureExtractor`, `TransformersModule`, the optional method `ReadingIndex.digests` and `DocIndex.digests`. The file `vectors.sqlite` is a projection, as the index is: it is not part of the stable store.
389
+
390
+ The engine of the recipes is `rankHoursLinks`, `namesVenue`, `locationMatches`, `pickProduct`, `pickVariant`, `readAmount`, `VENUE_LIMITS`, `PRODUCT_LIMITS`, `RECIPE_LIMITS` and the `steps` of `VenueRead` and `ProductRead`. The words that rank a link or a label are the engine too, so a version can improve them.
391
+
345
392
  The engine of the archived fallback and of the signatures is `archiveExtractor`, `archivedDocId`, and the `fallback` and `archiveDespiteRobots` of `PageOptions`. It also includes `range`, `signalLines` and `originalHeaderPrefix` of `FetchRequest`, `ai_input` of `ExtractedDoc`, `sign` of `RenderOptions` and `signer` of `CrawlerOptions`.
346
393
 
394
+ ## Hybrid search
395
+
396
+ Hybrid search finds the pages that hold the words of a question, and also the pages with a similar meaning. It is opt-in: the default ranking stays exact BM25.
397
+
398
+ ```ts
399
+ import { openCrawler, transformersEmbedder } from "polite-crawler";
400
+
401
+ const crawler = openCrawler({ dir: "./crawl", embedder: transformersEmbedder() });
402
+ await crawler.embed({ budget: "10m" }); // vectors for each stored page
403
+
404
+ for (const hit of await crawler.find("a loaf with a dark crust", { rank: "hybrid" })) {
405
+ console.log(hit.doc, hit.score, hit.passage, hit.snippet);
406
+ }
407
+ ```
408
+
409
+ - `rank: "hybrid"` needs an embedder. Without one, the search rejects with `NO_EMBEDDER`. It does not change to BM25 in silence.
410
+ - `crawler.rankWithin(q, docs, { rank: "hybrid" })` ranks a set of stored documents in the same way.
411
+ - `openCrawler` gives a `HybridCrawler`: an `OpenedCrawler` with `hybrid`, `embed` and `rankWithin`, and a `find` that takes `rank: "hybrid"`.
412
+ - The command line has `--embedder`, `find --rank hybrid` and `embed`. The MCP tool `crawl_find` takes `rank: "hybrid"` when the server has an embedder.
413
+
414
+ ```sh
415
+ npx polite-crawler embed --embedder ollama --budget 30m
416
+ npx polite-crawler find "a loaf with a dark crust" --rank hybrid --embedder ollama
417
+ ```
418
+
419
+ ### transformers.js
420
+
421
+ This embedder uses the model in the process of the crawler. The package `@huggingface/transformers` is an optional peer, and the embedder loads it at its first text.
422
+
423
+ ```sh
424
+ npm install @huggingface/transformers
425
+ ```
426
+
427
+ ```ts
428
+ import { transformersEmbedder } from "polite-crawler";
429
+
430
+ const embedder = transformersEmbedder({ model: "Xenova/bge-small-en-v1.5", quantized: true });
431
+ ```
432
+
433
+ - The default model is `Xenova/bge-small-en-v1.5`: 384 dimensions and an input of 512 tokens. The 8-bit model (`quantized: true`, the default) is approximately 34 MB.
434
+ - The first use downloads the model from the Hugging Face Hub (`huggingface.co`). This download does not go through the polite crawler, so robots.txt, the pace and the capture log do not apply to it.
435
+ - transformers.js keeps the files in `cacheDir`. Its default is `node_modules/@huggingface/transformers/.cache/`. The command line uses `<store>/models`. With `localOnly: true`, the embedder uses only the cache and downloads nothing.
436
+ - The embedder pools the vectors of the tokens as the model expects: `cls` for BGE and `mean` for the other models. The option `pooling` changes it.
437
+
438
+ ### Ollama
439
+
440
+ This embedder sends the texts to a local Ollama server (`POST /api/embed`). The package has no dependency for it.
441
+
442
+ 1. Install Ollama from <https://ollama.com>, and start it with `ollama serve`.
443
+ 2. Pull a model: `ollama pull nomic-embed-text`.
444
+
445
+ ```ts
446
+ import { ollamaEmbedder } from "polite-crawler";
447
+
448
+ const embedder = ollamaEmbedder({ model: "nomic-embed-text", baseUrl: "http://127.0.0.1:11434" });
449
+ ```
450
+
451
+ | Model | Size | Dimensions | Input in tokens |
452
+ | --- | ---: | ---: | ---: |
453
+ | `nomic-embed-text` (the default) | approximately 274 MB | 768 | 8,192 |
454
+ | `all-minilm` | approximately 46 MB | 384 | 256 |
455
+ | `mxbai-embed-large` | approximately 670 MB | 1,024 | 512 |
456
+
457
+ - The embedder sends 32 texts in each request (`batch`). It adds the prefixes that `nomic-embed-text` and `mxbai-embed-large` expect.
458
+ - An error tells what to do: `ollama serve` when no server answers, and `ollama pull <model>` when the model is not there.
459
+ - The embedder accepts only a server on this computer (a loopback address). `allowRemote: true` (`--allow-remote-ollama`) permits a different computer.
460
+
461
+ ### An embedder of your own
462
+
463
+ An application can give an embedder of its own, as it can give a renderer:
464
+
465
+ ```ts
466
+ import type { Embedder } from "polite-crawler";
467
+
468
+ const embedder: Embedder = {
469
+ id: "my-model-v1", // the model and its version: the store keeps it with each vector
470
+ dimensions: 384,
471
+ maxInputTokens: 512,
472
+ embed: async (texts, { signal, purpose }) => myModel.embed(texts, { signal, purpose }),
473
+ };
474
+ ```
475
+
476
+ `purpose` is `"document"` for the chunks of a page and `"query"` for a question. Give a new `id` when the vectors of the model change.
477
+
478
+ ### What goes where
479
+
480
+ - The embedder gets the text of the stored pages and the words of each question. With transformers.js, the text stays in the process. With Ollama, the text goes to the Ollama server.
481
+ - A page that asks not to be AI input does not go to the embedder, and no hybrid result gives it. The rule applies to each version, as for `find`.
482
+ - Only the download of a transformers.js model goes to the internet, to the Hugging Face Hub.
483
+
484
+ ### How a hybrid search ranks
485
+
486
+ 1. Full-text search gives the BM25 candidates (`candidates`, 50 by default). A candidate whose text has no vectors gets them first.
487
+ 2. The vector search gives the documents with the closest chunks (`neighbors`, 50 by default). It compares the vector of the question with each stored vector (a brute-force cosine).
488
+ 3. Reciprocal rank fusion makes one list. Each document gets the sum of 1 / (k + rank) over the two lists, with the rank from 1. The constant k is `fusionK`, 60 by default.
489
+
490
+ - The snippet and the `passage` of each hit come from its best chunk. `passage.start` and `passage.end` are positions in the text of the capture of the hit, for `read`.
491
+ - A search gives vectors only to its candidates. Thus a page with the meaning and none of the words comes back only after `embed` gave it vectors.
492
+ - `embed` takes `budget` (`10m` by default), `maxDocs` and `kind`. Start it again to continue: it does not send a text with vectors again.
493
+ - `find` and `rankWithin` take `candidates`, `neighbors`, `fusionK` and `signal`. `HYBRID_DEFAULTS` has the defaults.
494
+
495
+ ### Chunks and storage
496
+
497
+ - A chunk is a window of whole sentences. It ends at the end of a paragraph when it can, and a longer sentence is cut at its spaces.
498
+ - A chunk has the input limit of the model in tokens with a margin of 10 percent, and 512 tokens at most. Two chunks next to each other share approximately 10 percent of their tokens. A text gives 64 chunks at most.
499
+ - The vectors go into `vectors.sqlite` in the store. The key of a row is the SHA-256 of the text, the number of the chunk and the `id` of the embedder. Thus a reindex keeps each vector, and the same text gets its vectors one time for each model.
500
+ - The file is a projection, as `index.sqlite` is. Delete it, and `embed` makes the vectors again.
501
+ - A vector takes 4 bytes for each dimension: 1.5 KiB for 384 dimensions and 3 KiB for 768. With the keys and the indexes of SQLite, a chunk of 384 dimensions took 2.4 KiB of the file in the benchmark.
502
+ - The first hybrid search reads the vectors of the embedder into memory. Thus the memory is approximately the size of the vectors.
503
+
504
+ The benchmark `bench/find.mts --hybrid` measured the time in CI ([the results](https://github.com/mark1russell7/crawler/actions/runs/39082851222)). A fake embedder of almost no cost gave the vectors, so the time of a real model comes on top. The index had 30,000 documents of 900 words, which gave 89,100 chunks of 384 dimensions. Each time is the median of 12 questions on Node.js 24:
505
+
506
+ | Step | Time |
507
+ | --- | ---: |
508
+ | The cosine of a question and each of the 89,100 vectors | 82 ms |
509
+ | `find`, `rank: "hybrid"`, 1 word | 88 ms |
510
+ | `find`, `rank: "hybrid"`, 16 words | 122 ms |
511
+ | `rankWithin` of 40,000 ids, `rank: "hybrid"`, 16 words | 528 ms |
512
+ | The first search: it reads 131 MiB of vectors into memory | 714 ms |
513
+ | `embed` of the 30,000 documents, with no time for a model | 17 s |
514
+
515
+ - The brute-force cosine is linear in the number of chunks. It is a good fit up to approximately 100,000 chunks (approximately 30,000 pages of this size). A search then takes approximately 0.1 s, and the vectors take approximately 150 MiB of memory at 384 dimensions.
516
+ - One million chunks take approximately 1 s for each search and 1.5 GiB of memory. A larger store needs an index of the vectors, and the package does not have one.
517
+ - `vectors.sqlite` was 210 MiB for the 89,100 chunks.
518
+
347
519
  ## Read a page as it was at a time
348
520
 
349
521
  Each capture of a document is a version, and the index keeps each version. `read` and `verify` take `at`, a time. They then use the version that was current at that time.
@@ -461,10 +633,181 @@ The `diff` of an event of the kind `changed` gives the counts of the diff from t
461
633
  - Each step is one SQLite transaction (`BEGIN IMMEDIATE`) in WAL mode, and it waits for the lock of a different process (`busy_timeout`).
462
634
  - A run claims each entry with a lease before its check. The lease holds until the end of the budget and 2 minutes more. Thus two processes that run at the same time check each entry one time. A process that stops holds its leases until they end.
463
635
  - The list stays after a restart, with the state of each entry: the last confirmed capture, the suspected change and the counts.
464
- - The package has no daemon. A scheduler of the operator (cron, a systemd timer or a job of an application) starts `watch.run`.
636
+ - A scheduler of the operator (cron, a systemd timer or a job of an application) starts `watch.run`. Or the daemon starts it on a schedule: [The daemon](#the-daemon).
465
637
  - The command line has `watch add <url...>`, `watch remove <url...>`, `watch list` and `watch run`. The MCP tool `crawl_watch_list` lists the entries.
466
638
  - No MCP tool adds a watch. A watch commits future requests to a site, so it is an action of the operator, not of an assistant.
467
639
 
640
+ ## The daemon
641
+
642
+ The daemon is a local process that runs for a long time. It checks the watch list on a schedule, and it can serve the MCP tools over local HTTP.
643
+
644
+ ```sh
645
+ npx polite-crawler watch add https://example.com/hours --every 6h
646
+ npx polite-crawler daemon --events ~/changes.jsonl --on-change "jq -c . >> ~/seen.jsonl"
647
+ npx polite-crawler daemon status
648
+ ```
649
+
650
+ ```ts
651
+ import { startDaemon } from "polite-crawler";
652
+
653
+ const daemon = await startDaemon({
654
+ dir: "./crawl",
655
+ tick: "1m",
656
+ onChange: (event) => console.log(event.url, event.kind, event.capture),
657
+ });
658
+ console.log(daemon.status().next_due);
659
+ await daemon.stop(); // or SIGINT and SIGTERM
660
+ ```
661
+
662
+ ### The schedule
663
+
664
+ - Each tick is one `watch run`, with the budget and the maximum of checks of a tick. Thus each polite rule applies: robots.txt, the egress policy, one request at a time to each host, the stops of a host and `Retry-After`.
665
+ - The next tick comes when the first entry is due, and one tick (`--tick`, `1m` by default) after the last tick at the latest.
666
+ - A random jitter from 0 to `--jitter` (`10s` by default) comes after it. Thus a check does not come before its time.
667
+ - When a tick stops at its budget (`--tick-budget`, `10m` by default) or at its maximum (`--tick-pages`, 100 by default), the entries that stay due wait for the next tick.
668
+ - An entry that a different process holds waits for the end of its lease. Only one daemon operates on a store: a second daemon does not start.
669
+ - `scheduleWatches(crawler.watch, options)` gives the same schedule over the watch list of a crawler of your own.
670
+
671
+ ### The changes
672
+
673
+ A change is an event of `watch run` with `confirmed: true`: the last confirmed version of the page changed. The first version of a page is not a change.
674
+
675
+ - The daemon writes each change as one JSON line, to the standard output or to `--events <file>`. The library takes `events` (a file or a function) and `onChange` (a function).
676
+ - `--on-change <command>` starts your command for each change. The command gets the event as one JSON line on its standard input.
677
+ - The commands run one at a time, in the order of the changes. A command that runs for more than 1 minute stops. A command that fails becomes the last error of the status.
678
+
679
+ `--on-change` is a command of the operator. Obey these rules:
680
+
681
+ - The daemon starts the command in the shell of the system, as the user of the daemon, with the environment of the daemon. Give only a command that you wrote or trust.
682
+ - Do not take the command from a page, from an assistant or from a different user.
683
+ - The daemon gives the event only on the standard input, not in the command line. Thus no text of a page goes into a shell command.
684
+ - The event holds URLs and notes from sites. In your command, read the event as data, for example with `jq`. Do not give its text to `eval` or to a shell.
685
+
686
+ ### The stop
687
+
688
+ - At `SIGINT` or `SIGTERM`, the daemon starts no new check. The checks in progress get `--grace` (`10s` by default) to end and write their state.
689
+ - After the grace, the checks in progress abort, and the daemon releases their leases. The entries stay due for the next start. A second signal aborts the checks at once.
690
+ - The daemon stops its MCP server and cancels its tasks. It writes the state file, closes the store and exits with code 0.
691
+ - A daemon that stops with no clean stop (a crash, or a forced stop on Windows) keeps its leases until they end: the budget of the tick and 2 minutes.
692
+
693
+ ### The status
694
+
695
+ `polite-crawler daemon status` reads the state file `<store>/daemon/state.json`. It sends no request, and it opens no store.
696
+
697
+ - It shows the next due watches, the last tick, the next tick, the counts, the MCP endpoint, the tasks that run and the last error.
698
+ - It exits with code 0 when a daemon operates on the store, and with 1 when no daemon operates. `--json` prints the JSON of the state file.
699
+ - The daemon writes the state file after each tick and at each change of a task. The file is for the owner only.
700
+ - With `--mcp-http`, `GET /status` with the bearer token gives the same JSON.
701
+
702
+ ### MCP over HTTP
703
+
704
+ ```sh
705
+ npx polite-crawler daemon --mcp-http
706
+ claude mcp add --transport http polite-crawler http://127.0.0.1:9307/mcp --header "Authorization: Bearer $(cat ~/.polite-crawler/daemon/mcp-token)"
707
+ ```
708
+
709
+ - `--mcp-http` serves the nine tools over the Streamable HTTP transport of MCP, at `http://127.0.0.1:9307/mcp`. `--mcp-port` and `--mcp-host` change the address.
710
+ - The tools, the envelope, elicitation and `--allow-user-provided` are the same as for `polite-crawler mcp`.
711
+ - `crawl_site` and `crawl_discover` are MCP tasks (the task primitive of the MCP specification 2025-11-25). The task support is native: it uses the tasks of the MCP SDK.
712
+ - A client that asks for a task gets a task id at once. It polls the status (`tasks/get`), gets the result (`tasks/result`) and can cancel the task (`tasks/cancel`). A cancel stops the requests in progress.
713
+ - A client with no tasks gets the result in the call, with a progress notification for each page, as from `polite-crawler mcp`.
714
+ - The status message of a task holds only counts, for example `3 of 10 done`, and no text of a page.
715
+ - A task and its result stay for 1 hour. The server has 8 tasks in progress at most: a task above the limit fails at once, with the reason.
716
+ - Each session has its own tasks. When a session closes, its tasks stop.
717
+
718
+ The library has `startMcpHttpServer(crawler, options)` in `polite-crawler/mcp`. The daemon reaches it through a small seam, `DaemonOptions.mcpHost`, so a different MCP host can replace it.
719
+
720
+ #### The token
721
+
722
+ - Each request needs `Authorization: Bearer <token>`. The server compares the SHA-256 digests of the two tokens in constant time.
723
+ - By default, the daemon makes a new random token at each start. It writes the token to `<store>/daemon/mcp-token`, for the owner only where the system has file modes. On Windows, the folder of the store in your profile protects it.
724
+ - To keep one token across starts, write it to a file of your own and give `--mcp-token-file <file>`. The daemon reads the file and writes nothing. It warns when other users can read the file.
725
+
726
+ ```sh
727
+ node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" > ~/.polite-crawler-token
728
+ chmod 600 ~/.polite-crawler-token
729
+ npx polite-crawler daemon --mcp-http --mcp-token-file ~/.polite-crawler-token
730
+ ```
731
+
732
+ #### The address
733
+
734
+ - The server binds to `127.0.0.1` by default, so only your computer can reach it.
735
+ - On a loopback address, the server accepts only a loopback name in the `Host` header. An `Origin` header must name the same host. Thus a web page cannot reach the server by DNS rebinding.
736
+ - A host that is not loopback, for example `--mcp-host 0.0.0.0`, needs `--allow-remote`. Without it, the command line is wrong.
737
+
738
+ > **Warning:** With `--allow-remote`, each computer that reaches the port can try the token. The server speaks plain HTTP, so a computer on the path can read the token and the pages. The tools also fetch pages from the address of your computer. Use `--allow-remote` only on a private network or behind a TLS proxy of your own. Do not open the port to the internet.
739
+
740
+ ### Start the daemon as a service
741
+
742
+ The package installs no service. These examples start the daemon at login, and they stop it with `SIGTERM`. Install the package one time (`npm install -g polite-crawler`), and change the paths.
743
+
744
+ On Linux, with a user service of systemd:
745
+
746
+ ```ini
747
+ # ~/.config/systemd/user/polite-crawler.service
748
+ [Unit]
749
+ Description=polite-crawler daemon
750
+ After=network-online.target
751
+
752
+ [Service]
753
+ ExecStart=/usr/local/bin/polite-crawler daemon --events %h/.polite-crawler/changes.jsonl
754
+ Restart=on-failure
755
+ KillSignal=SIGTERM
756
+ TimeoutStopSec=30
757
+
758
+ [Install]
759
+ WantedBy=default.target
760
+ ```
761
+
762
+ ```sh
763
+ systemctl --user daemon-reload
764
+ systemctl --user enable --now polite-crawler
765
+ journalctl --user -u polite-crawler -f
766
+ npx polite-crawler daemon status
767
+ ```
768
+
769
+ On macOS, with a launch agent of launchd:
770
+
771
+ ```xml
772
+ <?xml version="1.0" encoding="UTF-8"?>
773
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
774
+ <!-- ~/Library/LaunchAgents/io.github.polite-crawler.daemon.plist -->
775
+ <plist version="1.0">
776
+ <dict>
777
+ <key>Label</key><string>io.github.polite-crawler.daemon</string>
778
+ <key>ProgramArguments</key>
779
+ <array>
780
+ <string>/usr/local/bin/polite-crawler</string>
781
+ <string>daemon</string>
782
+ <string>--events</string>
783
+ <string>/Users/you/.polite-crawler/changes.jsonl</string>
784
+ </array>
785
+ <key>RunAtLoad</key><true/>
786
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
787
+ <key>StandardErrorPath</key><string>/Users/you/.polite-crawler/daemon.log</string>
788
+ </dict>
789
+ </plist>
790
+ ```
791
+
792
+ ```sh
793
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.github.polite-crawler.daemon.plist
794
+ launchctl bootout gui/$(id -u)/io.github.polite-crawler.daemon
795
+ ```
796
+
797
+ On Windows, with the Task Scheduler (PowerShell):
798
+
799
+ ```powershell
800
+ $action = New-ScheduledTaskAction -Execute "$env:APPDATA\npm\polite-crawler.cmd" `
801
+ -Argument "daemon --events `"$env:USERPROFILE\.polite-crawler\changes.jsonl`""
802
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
803
+ # no time limit: the default limit of 3 days stops the daemon
804
+ $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) `
805
+ -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
806
+ Register-ScheduledTask -TaskName "polite-crawler daemon" -Action $action -Trigger $trigger -Settings $settings
807
+ ```
808
+
809
+ The Task Scheduler stops a task with no signal. Thus the checks in progress stop at once, and their leases end after the budget of the tick and 2 minutes. `daemon status` then says that the daemon stopped with no clean stop.
810
+
468
811
  ## Check field values against a page
469
812
 
470
813
  A model or a person often reads a value from a page: a price, a date, opening hours, an author or a DOI. `extract` checks each proposed value against the stored copy, and it locates the value. It does not make values. It only checks and locates the values that you give, so each value has its proof.
@@ -543,6 +886,210 @@ The time of the check is linear in the length of the text, for a fixed set of fi
543
886
 
544
887
  The MCP tool `crawl_extract` gives the statuses and the positions in its JSON. The stored text, the readings and the excerpts come in the `<untrusted-web-content>` envelope. The command line has `polite-crawler extract`.
545
888
 
889
+ ## Read the hours of a venue
890
+
891
+ `readVenue` reads the opening hours of a venue (a restaurant, a shop or a museum) from its own site. It fetches a few pages, and it gives each reading with its source.
892
+
893
+ ```ts
894
+ import { compareHours, openCrawler, readVenue } from "polite-crawler";
895
+
896
+ const crawler = openCrawler({ dir: "./crawl" });
897
+ const venue = await readVenue(crawler, "https://forno-esempio.example/", {
898
+ name: "Forno Esempio",
899
+ osm: "Mo-Fr 07:00-15:00; Sa 08:00-13:00; Su off", // a value that you got from OpenStreetMap
900
+ });
901
+ console.log(venue.best?.hours, venue.best?.capture, venue.disagree);
902
+ for (const reading of venue.readings) console.log(reading.from, reading.label, reading.hours, reading.url);
903
+
904
+ const change = compareHours(keptWeek, venue.from_site!.week);
905
+ console.log(change.status, change.days); // changed [ 'sat' ]
906
+ ```
907
+
908
+ The recipe reads these sources:
909
+
910
+ 1. The home page: its JSON-LD (`openingHoursSpecification` and `openingHours`) and its text.
911
+ 2. The pages that most likely hold hours or a menu. `rankHoursLinks` ranks the links of the home page by their words, for example "hours", "menu" and "visit". `pages` sets the number of these pages: 3 by default, and 8 at most.
912
+ 3. An OpenStreetMap `opening_hours` value, when you give `osm`. The recipe sends no request to OpenStreetMap.
913
+
914
+ - Each page is a fetch of the crawler. Thus robots.txt, the pace, the stops, the egress policy and the capture log apply. A page that needs JavaScript gets the browser, as for `fetch`.
915
+ - Each reading has `from`, `label`, `week`, `hours` (the week as words), `inferred`, `partial`, `season` and `raw`. It names its `url`, `doc` and `capture`, and a reading of JSON-LD names its `path`.
916
+ - The `raw` of a reading of the text is a quote: `verify(reading.doc, reading.raw, { capture: reading.capture })` finds it.
917
+ - `rank` orders the readings. JSON-LD is 0, a text under an hours heading is 1, OpenStreetMap is 2, and a text with no other label is 3. A different schedule (a brunch, a kitchen, a happy hour or an office) is 4.
918
+ - `best` is the first reading of the opening hours. `from_site` is the best reading of the site itself, with no guessed am or pm. A check for a change compares `from_site`.
919
+ - `disagree` names the days where two readings of the whole schedule (a rank of 2 or less) disagree.
920
+ - The result also gives `menus` (the menu links that are not on a review or social site), `phone` (from the JSON-LD) and `captures`.
921
+
922
+ | Option | What it does |
923
+ | --- | --- |
924
+ | `name` | The name of the venue. `names_venue` tells if the home page names it. A domain that a venue let go can show a different business. |
925
+ | `requireName` | With `true`, a home page that does not name the venue gives no reading, and the recipe fetches no more pages. |
926
+ | `location` | The `address`, or `lat` and `lon`, of the venue. On a page that lists more than one location, the recipe reads only this location. With no `location`, it reads none of them. |
927
+ | `osm` | An OpenStreetMap `opening_hours` value, or `{ value, url, doc, capture }` with its source. |
928
+ | `pages` | The pages after the home page, from 0 to 8. The default is 3. |
929
+ | `render`, `maxAge`, `onBehalfOf`, `signal`, `scope`, `labels`, `job` | As for `fetch`. |
930
+ | `budget` | No request starts after this time, for example `"2m"`. |
931
+ | `userProvided` | Read a page that asked not to be AI input. Use it only for a URL that a person gave. The crawler logs it. |
932
+ | `fallback`, `archiveDespiteRobots` | Read an archived copy of a page that the site refuses. The reading then has `archived`. |
933
+
934
+ - A location of a page matches when it has the same street number and a word of the street, or when it is within 150 m.
935
+ - `moved` names the new site when the home page landed on a different host.
936
+ - A page that asked not to be AI input gives no reading, and `held_back` is true.
937
+ - The recipe writes nothing but the capture log. It does not decide which reading is true. That decision belongs to your application.
938
+
939
+ `venueReadingsOf(doc, { location })` reads one stored document with no request. Thus it also reads an earlier version:
940
+
941
+ ```ts
942
+ import { venueReadingsOf, versionAtTime } from "polite-crawler";
943
+
944
+ const { version } = versionAtTime(crawler.runtime, "url:https://forno-esempio.example/", "2026-10-01T00:00:00Z");
945
+ const then = venueReadingsOf(version!);
946
+ ```
947
+
948
+ `compareHours(kept, reading, { skipSplit })` compares a reading with the week that your application keeps. It is in `polite-crawler/hours` too.
949
+
950
+ - It gives `status` (`same`, `changed` or `new`), the changed `days`, the `added` days and the `skipped` days.
951
+ - It compares only the days that the two weeks state. A day that only the reading states is `added`, not a change.
952
+ - `24:00` and `00:00` are the same time, and `9:00` is `09:00`. The sequence of the intervals of a day does not matter.
953
+ - With `skipSplit`, it does not compare a day with two or more intervals, for a store that keeps one interval for each day.
954
+ - One reading can be an error of the site. Confirm a change with a second reading, as a watch does.
955
+
956
+ The command line has `polite-crawler venue`, and `venue --compare <file>` does this comparison. [Use it from the command line](#use-it-from-the-command-line) gives the options.
957
+
958
+ ## Read a product page
959
+
960
+ `readProduct` reads the schema.org Product data of a product page. Most shop systems write this data on each product page.
961
+
962
+ ```ts
963
+ import { readProduct } from "polite-crawler";
964
+
965
+ const product = await readProduct(crawler, "https://bottega-campione.example/products/olio-esempio?variant=222");
966
+ if (product.wall) console.log("the shop refused the crawler:", product.note);
967
+ else console.log(product.name, product.price, product.price_to, product.availability, product.variant);
968
+ // Olio Esempio { amount: 9.9, currency: 'EUR', raw: '9.90' } { amount: 18, ... } InStock { sku: 'OE-500', ... }
969
+ ```
970
+
971
+ - The result gives `name`, `brand`, `sku`, `gtin`, `price`, `price_to`, `availability`, `availabilities`, `variant`, `seller` and `image`. It names the `doc` and the `capture` of the page.
972
+ - A price is `{ amount, currency, raw }`, and the currency is an ISO 4217 code. `€` and `£` count too, because each one names one currency. A price with no currency, or with `$`, is not money, and a note says so.
973
+ - `price` is the lowest price. `price_to` is the highest price, when the offers give a range (sizes, colors, variants). The prices are in the first currency of the offers.
974
+ - A list price and a struck-through price (`priceType` `ListPrice` or `StrikethroughPrice`) are not the price.
975
+ - `variant` is the variant that the URL names with the parameter `variant`, `sku` or `gtin`. The recipe reads its price alone.
976
+ - `availability` is a schema.org name, for example `InStock`. When the offers do not agree, it is `InStock` if one offer is in stock.
977
+ - The options are `render`, `maxAge`, `onBehalfOf`, `signal`, `budget`, `scope`, `labels`, `job`, `userProvided`, `fallback` and `archiveDespiteRobots`, as for `readVenue`.
978
+
979
+ A page often has more than one Product record: the related products, and the products that others bought. The recipe picks the product of the page:
980
+
981
+ 1. The product whose `url` or `@id` is the page. An `@id` that is only a fragment (`#product`) does not count, because a related product can have one too.
982
+ 2. Else the largest product with offers or variants.
983
+ 3. Else the largest product. The recipe also reads the `mainEntity` of a `WebPage` or an `ItemPage`.
984
+
985
+ - The index keeps 30,000 characters of the JSON-LD of each page. A product with many variants can be larger, so the recipe reads the stored body again.
986
+ - When the plain page has no Product data, the recipe renders the page once, because some shops write that data with JavaScript. `render: "never"` stops this.
987
+ - A shop that refuses the crawler gives `wall: true`, with a note. The causes are a 401, a 402 or a 403, a challenge, a bot check, a stop of the host or robots.txt. The recipe does not try a different identity.
988
+ - A page with no Product data gives `found: false`. It is often a list of products.
989
+
990
+ `readAmount` reads an amount as a shop writes it. The last separator is the decimal point. One kind of separator before groups of three digits separates thousands, so `1.299` is 1299. A currency with three decimal digits (`KWD`) reads `1.299` as 1.299. A range, a negative amount and zero give no amount.
991
+
992
+ `productOf(records, url)` reads the JSON-LD records of a page with no request, so it also reads an earlier version. `extract` checks each value against the stored page:
993
+
994
+ ```ts
995
+ const check = await crawler.extract(product.doc!, {
996
+ price: { value: product.variant!.price!.raw, kind: "json-ld:Product.offers.price" },
997
+ });
998
+ ```
999
+
1000
+ The command line has `polite-crawler product`. It exits with 4 at a wall, and with 3 when it reads no Product data.
1001
+
1002
+ ## Writing a recipe
1003
+
1004
+ A recipe tells the crawler what to fetch and how to read it into documents. The venue and product recipes read pages with the page reader of the package. A recipe of your own reads a source that the page reader does not know, for example an API.
1005
+
1006
+ ```ts
1007
+ import { defineRecipe, openCrawler, readJson, runRecipe } from "polite-crawler";
1008
+
1009
+ interface Item {
1010
+ readonly id: string;
1011
+ readonly name: string;
1012
+ readonly price: string;
1013
+ }
1014
+
1015
+ const menu = defineRecipe({
1016
+ name: "forno-menu", // it goes into each capture record: do not change it
1017
+ mode: "api", // a documented API: robots.txt does not apply, and the pace does
1018
+ minInterval: "2s", // the documented pace of the API
1019
+ maxAge: "6h",
1020
+ fetch: (day: string) => `https://api.forno-esempio.example/menu?day=${encodeURIComponent(day)}`,
1021
+ extract: (body, capture) => {
1022
+ const { items } = readJson(body, capture) as { items: Item[] }; // a body that is not JSON throws
1023
+ return items.map((item) => ({
1024
+ doc: `menu:forno-esempio/${item.id}`,
1025
+ kind: "menu-item",
1026
+ title: item.name,
1027
+ text: `${item.name}: ${item.price}`,
1028
+ url: capture.url,
1029
+ meta: { price: item.price },
1030
+ }));
1031
+ },
1032
+ });
1033
+
1034
+ const crawler = openCrawler({ dir: "./crawl", recipes: [menu] });
1035
+ const run = await runRecipe(crawler, menu, "mon");
1036
+ console.log(run.outcome, run.capture, run.docs.map((doc) => doc.doc));
1037
+
1038
+ await crawler.find("focaccia"); // the documents of the recipe are in the index
1039
+ await crawler.read("menu:forno-esempio/focaccia", { at: "2026-10-01T00:00:00Z" }); // each version stays
1040
+ ```
1041
+
1042
+ `defineRecipe` takes these values:
1043
+
1044
+ | Value | Default | What it does |
1045
+ | --- | --- | --- |
1046
+ | `name` | (necessary) | The name of the recipe: 1 to 64 letters, digits and `. _ : -`. The names of the crawler (`RESERVED_RECIPES`) are not permitted. |
1047
+ | `extract` | (necessary) | The function that reads a body into documents. |
1048
+ | `fetch` | the input is the URL | The function that gives the request for an input: a URL, or `{ url, accept, headers, secretParams }`. |
1049
+ | `mode` | `page` | `page` is a web page, and robots.txt applies. `api` is a documented API with terms of its own. |
1050
+ | `accept` | HTML for a page, JSON for an API | The `Accept` header. |
1051
+ | `minInterval` | the policy of the host | The shortest time between two requests to the source. |
1052
+ | `maxAge` | `24h` | A stored copy younger than this age answers with no request. `auto` uses the freshness that the server gave. |
1053
+ | `maxBytes` | 5 MiB | The most bytes of a body that the crawler keeps. |
1054
+ | `description` | none | A text that tells what the recipe reads. |
1055
+
1056
+ `runRecipe(crawler, recipe, input, options)` fetches the request of an input through the crawler, and reads its capture with the extractor.
1057
+
1058
+ - The result gives `outcome`, `status`, `url`, `docs`, `capture`, `fetched_at`, `from_cache`, `held_back`, `error` and `note`. The `url` shows each secret parameter as `REDACTED`.
1059
+ - Each document gives `doc`, `kind`, `title`, `url`, `meta`, `chars`, `capture` and `fetched_at`. `read` gives its text. A held-back document shows no title and only its structural metadata.
1060
+ - The options are `maxAge`, `onBehalfOf`, `signal`, `scope`, `labels`, `job`, `budget`, `userProvided`, `fallback` and `archiveDespiteRobots`.
1061
+ - Give each recipe to `openCrawler` (or `createRuntime`) with `recipes`. Then `reindex` reads its captures from the start. `runRecipe` also registers a recipe the first time that it gets it.
1062
+ - `registerRecipe(runtime.extractors, recipe)` registers a recipe in a runtime of your own. A different extractor with the same name throws an error.
1063
+ - `recipe.request(input, options)` gives the request of the crawler, for `fetchAndIndex` in an application that builds its own steps.
1064
+ - `readJson(body, capture)` reads a body as JSON, in the encoding that the response declares. A body that is not JSON throws an error that names the URL and the content type.
1065
+ - `runRecipe`, `readVenue` and `readProduct` take a crawler or a runtime (`createRuntime`). The option `onBehalfOf` of `openCrawler` does not apply to them, so give `onBehalfOf` to each call.
1066
+
1067
+ ### The rules of an extractor
1068
+
1069
+ 1. **An extractor is pure.** The same body and capture always give the same documents. Do not read the clock, a random number or a state from an earlier call.
1070
+ 2. **An extractor sends no request.** It gets the body of the capture. To read a second source, start a second recipe. Its fetch is then polite, and it has its capture.
1071
+ 3. **The runtime reports an error, and the work continues.** An extractor that throws gives no document. `runRecipe` gives the error in `error`, and `reindex` counts it in `failed` and `errors`. The fetch and the rebuild continue.
1072
+ 4. **The SDK checks each document.** Each document needs `doc`, `kind`, `title`, `text`, `url` and `meta`, and `meta` must be JSON. Two documents with one id, the prefix `archive:` and the kind `archived` throw an error. The archived fallback uses that prefix and that kind.
1073
+ 5. **A held-back body stays held back.** Set `ai_input: "no"` when the body asks not to be AI input. An extractor cannot lift a `no` of the capture.
1074
+
1075
+ ### The rules of the capture ids
1076
+
1077
+ - The crawler makes the capture id, not the extractor. The id is a hash of the source, the kind, the external id and the payload of the capture record.
1078
+ - The payload holds the URL, the name of the recipe, the outcome, the hash of the body and the time of the fetch.
1079
+ - Thus the name of a recipe is part of each capture id. Do not change the name after the first capture. With a new name, `reindex` finds no extractor for the old captures, and it keeps their documents as they are.
1080
+ - Each version of a document names its capture. A citation names that capture, and `read`, `verify` and `extract` take `capture` to use that version.
1081
+ - `reindex` reads each capture again with the extractor. A pure extractor thus gives the same versions with the same capture ids.
1082
+ - A new version of an extractor changes the documents at the next `reindex`, not the capture ids. Keep it able to read the old captures.
1083
+ - The scope and the labels of a fetch are not part of the capture id.
1084
+
1085
+ ### The documents, the time travel and the archived fallback
1086
+
1087
+ - Give each document an id with a prefix of your own, for example `menu:`. The page reader uses `url:`. A recipe that gives a `url:` id adds versions to the document of that page, and `merge` chooses the current version.
1088
+ - Each capture of a recipe adds a version of each of its documents. `read`, `verify` and `extract` take `at`, and `diff` compares two versions.
1089
+ - With `fallback: "archive"`, a recipe of the mode `page` gets an archived copy after a refusal. The page reader reads the copy as the document `archive:<url>`, with `archived`.
1090
+ - The extractor of a recipe reads only the live captures. Thus its documents do not mix a live answer and an old copy of a third party. A recipe of the mode `api` takes no `fallback`.
1091
+ - When the extractor cannot read a stored copy, `runRecipe` asks the source one more time. Thus a bad answer (a page about maintenance) does not stay until `maxAge` ends.
1092
+
546
1093
  ## Find the pages of a site
547
1094
 
548
1095
  `discover` finds the pages of a site from its sitemaps and its feeds. It fetches the files that list the pages, not the pages.