@wenathlan/saddle 1.8.7 → 1.8.8

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  <strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
7
7
  <strong>Binary computing engine, agent browser, scraper and packager.</strong><br/>
8
8
  <a href="https://github.com/wenathlan/saddle/actions/workflows/ci.yml"><img src="https://github.com/wenathlan/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
9
- <a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.7"><img src="https://img.shields.io/badge/release-v1.8.7-d35d3d" alt="Release 1.8.7" /></a>
9
+ <a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.8"><img src="https://img.shields.io/badge/release-v1.8.8-d35d3d" alt="Release 1.8.8" /></a>
10
10
  <a href="https://github.com/wenathlan/saddle/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-202a2f" alt="GPL 3.0 license" /></a>
11
11
  </p>
12
12
 
@@ -127,7 +127,7 @@ The base permission set is `activeTab`, `scripting` and `storage`. It does not r
127
127
  | Failure | retry, circuit breaker, idempotency and resume are configurable |
128
128
  | Releases | version comes from the `vX.Y.Z` tag and must match `package.json` |
129
129
 
130
- Version 1.8.7 also removes the obsolete nested `scrape` package manifests and lockfile that generated a separate stale dependency graph. The dependency-free JavaScript scrape contracts remain in `scrape/`. The root lockfile is regenerated and CI runs `npm audit --audit-level=high` plus dependency review for pull requests. See [`docs/securityaudit-1.8.7.md`](docs/securityaudit-1.8.7.md) for the baseline and remediation record.
130
+ Version 1.8.8 preserves the dependency-free JavaScript scrape contracts and groups the related crawl context in `scrape/crawl.js`. The retry and circuit context now lives in `runtime/retry.js`, while scraper-specific error classification is part of `core/errors.js`. The root lockfile and security gates remain authoritative; see [`docs/reorganization-1.8.8.md`](docs/reorganization-1.8.8.md) for the ownership decisions and [`docs/securityaudit-1.8.7.md`](docs/securityaudit-1.8.7.md) for the preceding security baseline.
131
131
 
132
132
  ## Package surfaces and release automation
133
133
 
@@ -173,12 +173,11 @@ The engine test suite is deterministic and does not require real credentials or
173
173
  ## Repository map
174
174
 
175
175
  ```text
176
- core/ errors, events, identifiers and hashing
176
+ core/ engine errors, scrape error taxonomy, events, identifiers and hashing
177
177
  domain/ jobs, artifacts, sessions and providers
178
178
  memory/ working-set bridge, modes, objects and transforms
179
179
  storage/ local, chunked, remote and file-hosting adapters
180
- scrape/ dependency-free robots, cache, extraction, schema and normalization contracts
181
- crawl/ URL normalization, crawler and persistent frontier
180
+ scrape/ robots, cache, extraction, schema, normalization and grouped crawl contracts
182
181
  queue/ queue, idempotency, saga and recovery
183
182
  browser/ fingerprint, session, agent and Playwright adapter contracts
184
183
  extension/ Manifest V3 reference surface and packager
@@ -186,6 +185,7 @@ protocol/ JSON, NDJSON, SSE and block serializers
186
185
  workflow/ manifests, templates and registry contracts
187
186
  packager/ package and publication plans
188
187
  release/ checksums, SBOM and provenance metadata
188
+ runtime/ engine orchestration, capability detection, worker and grouped retry context
189
189
  web/ root-based static marketing site
190
190
  tests/ deterministic engine and extension coverage
191
191
  docs/ architecture, API, security, release and registry notes
@@ -199,7 +199,7 @@ Earlier README snapshots remain in `docs/plans/README.md`, `docs/talks9/README.m
199
199
 
200
200
  ## Current scope
201
201
 
202
- Version 1.8.7 extends the 1.8.6 engine with dependency remediation, explicit security gates, base-aware Pages assets, removal of the obsolete public debug directory and consolidated documentation. Browser binaries, provider credentials, n8n host registration, persistent databases, captcha solvers and production deployment remain caller-selected adapters. Future work should extend contracts without coupling the core to one forge, registry, browser or storage vendor.
202
+ Version 1.8.8 extends the 1.8.7 engine with conservative context regrouping. Browser binaries, provider credentials, n8n host registration, persistent databases, captcha solvers and production deployment remain caller-selected adapters. Future work should extend contracts without coupling the core to one forge, registry, browser or storage vendor.
203
203
 
204
204
  ## License
205
205
 
package/api/service.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * saddle service exposes universal routes without choosing hono fastify express or another server.
3
3
  */
4
- import { crawl } from "../crawl/crawler.js";
4
+ import { crawl } from "../scrape/crawl.js";
5
5
  import { ratelimiter } from "./rate.js";
6
6
  import { errorresponse, jsonresponse, sseresponse } from "./http.js";
7
7
  import { assertpublicurl } from "./security.js";
package/core/errors.js CHANGED
@@ -35,3 +35,23 @@ export function aserror(error, jobid) {
35
35
  if (error?.name === "saddleerror") return error;
36
36
  return saddleerror(`job ${jobid} failed`, { code: errorcodes.jobfailed, cause: error, details: { jobid } });
37
37
  }
38
+
39
+ /** Scrape error presets keep status, recovery and retry decisions explicit. */
40
+ export const errorcatalog = Object.freeze({
41
+ timeout: { code: "E1001", statuscode: 504, retryable: true, recovery: "WAIT_AND_RETRY" },
42
+ connectionrefused: { code: "E1002", statuscode: 503, retryable: true, recovery: "WAIT_AND_RETRY" },
43
+ dns: { code: "E1003", statuscode: 503, retryable: true, recovery: "ROTATE_PROXY" },
44
+ ratelimited: { code: "E2001", statuscode: 429, retryable: true, recovery: "WAIT_AND_RETRY" },
45
+ forbidden: { code: "E2002", statuscode: 403, retryable: false, recovery: "REVIEW_ROBOTS_TXT" },
46
+ notfound: { code: "E2003", statuscode: 404, retryable: false, recovery: "STOP_CRAWLING" },
47
+ parse: { code: "E4002", statuscode: 422, retryable: false, recovery: "STOP_CRAWLING" },
48
+ captcha: { code: "E4003", statuscode: 403, retryable: false, recovery: "REVIEW_ROBOTS_TXT" },
49
+ session: { code: "E5001", statuscode: 401, retryable: true, recovery: "ROTATE_USER_AGENT" },
50
+ config: { code: "E6001", statuscode: 400, retryable: false, recovery: "STOP_CRAWLING" }
51
+ });
52
+
53
+ /** Creates a stable scrape error with recovery metadata. */
54
+ export function webscrapeerror(kind, message, options = {}) { const preset = errorcatalog[kind] ?? errorcatalog.config; const error = new Error(message, { cause: options.cause }); error.name = "webscrapeerror"; error.code = options.code ?? preset.code; error.statuscode = options.statuscode ?? preset.statuscode; error.retryable = options.retryable ?? preset.retryable; error.recovery = options.recovery ?? preset.recovery; error.severity = options.severity ?? (error.statuscode >= 500 ? "high" : "medium"); error.details = options.details ?? {}; return error; }
55
+
56
+ /** Converts unknown failures into the stable scrape taxonomy. */
57
+ export function classifyerror(error) { if (error?.name === "webscrapeerror") return error; const message = String(error?.message ?? error); if (/timeout|aborted/i.test(message)) return webscrapeerror("timeout", message, { cause: error }); if (/dns|enotfound/i.test(message)) return webscrapeerror("dns", message, { cause: error }); return webscrapeerror("config", message, { cause: error }); }
@@ -12,17 +12,18 @@ The project has no `src` folder. The root is the map of the engine.
12
12
 
13
13
  | folder | responsibility |
14
14
  |---|---|
15
- | `core` | errors, ids, clock, events, and tracing primitives |
15
+ | `core` | engine errors, scrape error taxonomy, ids, events, and hashing primitives |
16
16
  | `domain` | jobs, sessions, artifacts, providers, and runtime records |
17
17
  | `storage` | storage adapter contract, local backend, and checksums |
18
18
  | `memory` | working set preparation, sync, and cleanup |
19
19
  | `runners` | provider factories and deterministic scheduling |
20
- | `runtime` | engine orchestration and output encoding |
20
+ | `runtime` | engine orchestration, capability detection, worker boundary, retry and circuit context |
21
+ | `scrape` | single page extraction, response normalization, robots policy and grouped crawl context |
21
22
  | `cli` | explicit command surface with local error handling |
22
23
  | `tests` | local deterministic tests without credentials |
23
24
  | `examples` | small runnable integration examples |
24
25
 
25
- All internal file names are lowercase and contain no underscore or hyphen. Related logic stays grouped and each module remains small enough to reason about in isolation.
26
+ All internal file names are lowercase and contain no underscore or hyphen. Related logic stays grouped and each module remains small enough to reason about in isolation. Version 1.8.8 groups crawl URL normalization, traversal, frontier budgets and persistence in `scrape/crawl.js`; it does not merge distinct storage cache, job queue or browser session contracts merely because they use similar words.
26
27
 
27
28
  ## public contracts
28
29
 
@@ -64,7 +65,7 @@ The same contracts support paired modes. A mode can exist without its pair, and
64
65
  | browser | headless job definition | capture and replay adapter |
65
66
  | network | local deterministic job | remote provider and storage adapter |
66
67
 
67
- The first cut implements library, cli, binary entry point, internal memory, and internal file. The other modes are extension points, not hardcoded promises.
68
+ The first cut implements library, cli, binary entry point, internal memory, and internal file. The other modes are extension points, not hardcoded promises. Version 1.8.8 preserves this boundary while reducing duplicated active context files.
68
69
 
69
70
  ## infrastructure rules
70
71
 
@@ -28,7 +28,7 @@ The public API is designed around injected transports. Consumers can use the sam
28
28
  | `workflowtriggers` / `triggermatch` | normalize and match manual, event, schedule and retry starts |
29
29
  | `resumablerun` / `transitionrun` | recover remote run state through legal transitions |
30
30
  | `extractsemantic` | expose bounded headings, landmarks, controls and links |
31
- | `crawlfrontier` | prioritize URLs and enforce page and domain budgets |
31
+ | `crawlfrontier` / `normalizeurl` / `persistentqueue` | grouped crawl context for URL normalization, traversal budgets and durable frontier state |
32
32
  | `provenance` / `mergeprovenance` | link context chunks to source and retrieval evidence |
33
33
  | `metricstore` | collect bounded counters and duration summaries |
34
34
  | `authorize` | verify caller credentials through an injected verifier |
@@ -58,8 +58,8 @@ The `fetcher`, browser adapter, persistence adapter, proxy pool, captcha solver,
58
58
 
59
59
  The package exposes explicit subpaths for `./browser`, `./bot`, `./captcha`, `./memory-engine`, and `./deploy`. Desktop, mobile, and n8n contracts are exported from the root entry; the root entry remains the complete JavaScript API for consumers that prefer one import.
60
60
 
61
- Version 1.1 adds `./extension`, which exposes browser-neutral message, snapshot and service-worker routing contracts. The concrete Manifest V3 files live in `extension/`; they are not imported by the core at runtime and do not require Chrome when the library is used as a Node package. The browser surface also exposes snapshots, tab/frame context, action results, bounded action batches and recording through `./browser`.
61
+ The `./extension` subpath exposes browser-neutral message, snapshot and service-worker routing contracts. The concrete Manifest V3 files live in `extension/`; they are not imported by the core at runtime and do not require Chrome when the library is used as a Node package. The browser surface also exposes snapshots, tab/frame context, action results, bounded action batches and recording through `./browser`.
62
62
 
63
- The current main branch adds `desktopmanifest`, `mobilemanifest`, `desktopadapter`, `mobileadapter`, `n8nnode`, `n8nmatch`, `n8nexecute`, `controlsurface`, `controlservice`, and `workerbridge`. These factories describe surface boundaries and invoke caller-owned handlers; they do not install a native toolkit, start an n8n server, create a dashboard, or store credentials. They will be included in the next versioned release after the compatibility gates are complete.
63
+ The current release exposes `desktopmanifest`, `mobilemanifest`, `desktopadapter`, `mobileadapter`, `n8nnode`, `n8nmatch`, `n8nexecute`, `controlsurface`, `controlservice`, and `workerbridge`. These factories describe surface boundaries and invoke caller-owned handlers; they do not install a native toolkit, start an n8n server, create a dashboard, or store credentials.
64
64
 
65
65
  The root entry is cross-runtime safe for the tested core contract. Filesystem, Node HTTP, persistent queue, file sessions, local memory and captcha evidence adapters remain available through explicit Node-only files or subpaths. `runtimecontract` reports the capabilities of the current global scope, while `memorystorage` provides a process-local backend for browser workers, Deno, Bun and deterministic tests.
package/docs/release.md CHANGED
@@ -6,10 +6,10 @@ The release path is intentionally split into source validation, package validati
6
6
 
7
7
  | step | owner | condition |
8
8
  |---|---|---|
9
- | package version | repository | `package.json` matches the release tag, for example `1.8.7` |
9
+ | package version | repository | `package.json` matches the release tag, for example `1.8.8` |
10
10
  | quality gate | GitHub Actions | `npm run pack:check` passes |
11
- | tag | repository owner | tag `v1.8.7` points to the validated release commit |
12
- | GitHub release | repository owner | release `v1.8.7` is created from the validated tag |
11
+ | tag | repository owner | tag `v1.8.8` points to the validated release commit |
12
+ | GitHub release | repository owner | release `v1.8.8` is created from the validated tag |
13
13
  | GitHub Packages | GitHub Actions | `publishgithubnpm.yml`, `publishghcr.yml`, `publishmaven.yml`, `publishnuget.yml`, and `publishrubygems.yml` use `GITHUB_TOKEN` |
14
14
  | public npmjs | owner-managed GitHub Actions secret | `publishnpmjs.yml` uses `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` and derives the version from the release tag or latest release in manual maintenance runs |
15
15
  | extension zip | GitHub Actions | `buildextension.yml` derives the version from the release tag, validates the unpacked artifact and attaches `saddle-extension-<version>.zip` |
@@ -22,8 +22,8 @@ The npm token previously sent in chat is compromised and must not be used. GitHu
22
22
 
23
23
  ```text
24
24
  npm run pack:check
25
- git tag v1.8.7
26
- git push origin v1.8.7
25
+ git tag v1.8.8
26
+ git push origin v1.8.8
27
27
  ```
28
28
 
29
29
  The release-created event is the publication and extension-asset trigger for the release workflows. A dry-run verifies package shape and local tests, but cannot verify registry ownership, Trusted Publisher configuration, package scope authorization, package visibility or browser-store submission; those remain settings controlled by the owner.
@@ -4,7 +4,7 @@ The Node-only release adapter creates deterministic metadata for caller-selected
4
4
 
5
5
  ```bash
6
6
  npm run release:assets -- \
7
- --version 1.8.7 \
7
+ --version 1.8.8 \
8
8
  --output build/release \
9
9
  --artifact build/saddle.tgz \
10
10
  --build-type caller-build \
@@ -0,0 +1,31 @@
1
+ # Reorganization 1.8.8
2
+
3
+ ## Purpose
4
+
5
+ Version 1.8.8 applies the architecture skill's grouping rule to active JavaScript contracts. The objective is not to erase historical features or flatten unrelated domains. The objective is to give each correlated responsibility one canonical owner, reduce redundant folder boundaries, and preserve public names and deterministic behavior.
6
+
7
+ ## Audit result
8
+
9
+ The active package contains a JavaScript runtime and a legacy TypeScript scrape tree. The TypeScript tree is not imported by the active root entry or package export map. It remains in place because it documents a broader historical surface and contains features that would require a separate compatibility decision before conversion to the current JavaScript library boundary.
10
+
11
+ | Context | Previous ownership | Version 1.8.8 owner | Decision |
12
+ | --- | --- | --- | --- |
13
+ | Crawl acquisition | `crawl/normalize.js`, `crawl/frontier.js`, `crawl/crawler.js`, `crawl/persistent.js` | `scrape/crawl.js` | Consolidated. URL normalization, traversal, frontier budgets and durable crawl state share one context and preserve all public function names. |
14
+ | Retry protection | `retry/policy.js`, `retry/circuit.js` | `runtime/retry.js` | Consolidated. Both modules protect caller-owned runners, storage and network handlers from transient failure storms. |
15
+ | Scrape error classification | `errors/taxonomy.js` | `core/errors.js` | Consolidated. Generic engine errors and scrape recovery metadata share one stable error boundary. |
16
+ | Page cache | `scrape/cache.js` | `scrape/cache.js` | Kept separate. It caches single-page scrape results and robots policy. |
17
+ | Storage cache | `storage/cache.js` | `storage/cache.js` | Kept separate. It manages hot and cold byte-backed tiers, encoding, eviction and revalidation. |
18
+ | Job persistence | `queue/persistent.js` | `queue/persistent.js` | Kept separate. It persists generic job lifecycle records rather than crawl URLs. |
19
+ | Browser sessions | `browser/session.js`, `domain/sessions.js`, `sessions/*` | existing owners | Kept separate. These contracts represent browser identity, domain validation and file or replay persistence. |
20
+
21
+ ## Compatibility rule
22
+
23
+ The root export barrel continues to expose `normalizeurl`, `sameorigin`, `crawlfrontier`, `persistentqueue`, `crawl`, `retrypolicy`, `circuitbreaker`, `webscrapeerror` and `classifyerror`. Internal imports, tests and package payload declarations now point to the canonical owners. No public export was renamed for the regrouping.
24
+
25
+ ## Verification rule
26
+
27
+ The reorganization is accepted only when syntax checks, deterministic tests, format checks, package dry-run, dependency audit, web typecheck and Pages build pass. A deleted file is considered redundant only after its imports, tests, package payload and documented public names have been checked.
28
+
29
+ ## Deferred historical surface
30
+
31
+ The legacy TypeScript modules under `scrape/` remain a separate historical surface. They include richer browser, renderer, cache, middleware, server, session, pool, sitemap and format contracts. They are not silently merged into the active JavaScript modules because doing so would either discard features or introduce an undeclared TypeScript and external dependency build into the published core.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Saddle browser bridge",
4
- "version": "1.8.7",
4
+ "version": "1.8.8",
5
5
  "description": "User initiated page snapshots through the Saddle browser contract.",
6
6
  "minimum_chrome_version": "110",
7
7
  "permissions": ["activeTab", "scripting", "storage"],
package/index.js CHANGED
@@ -28,7 +28,7 @@ export * from "./runners/heartbeat.js";
28
28
  export * from "./workflow/triggers.js";
29
29
  export * from "./dispatch/resumable.js";
30
30
  export * from "./scrape/semantic.js";
31
- export * from "./crawl/frontier.js";
31
+ export * from "./scrape/crawl.js";
32
32
  export * from "./ai/provenance.js";
33
33
  export * from "./observability/metrics.js";
34
34
  export * from "./api/auth.js";
@@ -73,9 +73,6 @@ export * from "./protocol/blocks.js";
73
73
  export * from "./workflow/manifest.js";
74
74
  export * from "./workflow/templates.js";
75
75
  export * from "./workflow/registry.js";
76
- export * from "./crawl/normalize.js";
77
- export * from "./crawl/crawler.js";
78
- export * from "./crawl/persistent.js";
79
76
  export * from "./scrape/schema.js";
80
77
  export * from "./scrape/normalize.js";
81
78
  export * from "./mcp/server.js";
@@ -102,9 +99,7 @@ export * from "./surfaces/adapters.js";
102
99
  export * from "./surfaces/controls.js";
103
100
  export * from "./surfaces/operations.js";
104
101
  export * from "./library/public.js";
105
- export * from "./errors/taxonomy.js";
106
- export * from "./retry/policy.js";
107
- export * from "./retry/circuit.js";
102
+ export * from "./runtime/retry.js";
108
103
  export * from "./storage/githubcontents.js";
109
104
  export * from "./storage/filehosting.js";
110
105
  export * from "./persistence/migrations.js";
package/library/public.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * public library helpers compose fetch extraction serialization chunking and crawl contracts.
3
3
  */
4
- import { crawl } from "../crawl/crawler.js";
4
+ import { crawl } from "../scrape/crawl.js";
5
5
  import { chunkmarkdown } from "../ai/chunk.js";
6
6
  import { estimatetokens } from "../ai/tokens.js";
7
7
  import { extracthtml } from "../scrape/extract.js";
package/mcp/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * mcp server implements a small JSON RPC tool surface and remains transport agnostic.
3
3
  */
4
- import { crawl } from "../crawl/crawler.js";
4
+ import { crawl } from "../scrape/crawl.js";
5
5
  import { extractwithschema } from "../scrape/schema.js";
6
6
  import { jsonencode } from "../protocol/json.js";
7
7
  import { browsertools } from "./browser.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/saddle",
3
- "version": "1.8.7",
3
+ "version": "1.8.8",
4
4
  "description": "binary computing engine that turns distributed storage into a publishable working set",
5
5
  "type": "module",
6
6
  "private": false,
@@ -84,7 +84,6 @@
84
84
  "queue",
85
85
  "dispatch",
86
86
  "scrape",
87
- "crawl",
88
87
  "api",
89
88
  "mcp",
90
89
  "browser",
@@ -94,8 +93,6 @@
94
93
  "webhook",
95
94
  "surfaces",
96
95
  "library",
97
- "errors",
98
- "retry",
99
96
  "server",
100
97
  "binary",
101
98
  "format",
@@ -113,7 +110,7 @@
113
110
  "LICENSE"
114
111
  ],
115
112
  "scripts": {
116
- "check": "node --check index.js && node --check runtime/engine.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/pagebridge.js && node --check extension/popup.js && node --check extension/permissions.js && node --check extension/build.js && node --check release/assets.js && node --check sessions/replay.js && node --check browser/recorder.js && node --check scrape/normalize.js",
113
+ "check": "node --check index.js && node --check runtime/engine.js && node --check runtime/retry.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/pagebridge.js && node --check extension/popup.js && node --check extension/permissions.js && node --check extension/build.js && node --check release/assets.js && node --check sessions/replay.js && node --check browser/recorder.js && node --check scrape/normalize.js && node --check scrape/crawl.js",
117
114
  "formatcheck": "node format/check.js",
118
115
  "test": "node --test tests/*.test.js",
119
116
  "run": "node cli/main.js",
@@ -0,0 +1,27 @@
1
+ /**
2
+ * retry context groups transient retry policy and circuit protection for runners,
3
+ * storage adapters and network-facing surfaces.
4
+ */
5
+
6
+ /** Creates bounded exponential retry behavior for retryable failures. */
7
+ export function retrypolicy(options = {}) {
8
+ const maxattempts = options.maxattempts ?? 3;
9
+ const base = options.base ?? 1000;
10
+ const factor = options.factor ?? 2;
11
+ const cap = options.cap ?? 30000;
12
+ return { async run(handler) { let last; for (let attempt = 1; attempt <= maxattempts; attempt += 1) { try { return await handler(attempt); } catch (error) { last = error; if (error?.retryable !== true || attempt === maxattempts) throw error; const wait = Math.min(cap, base * factor ** (attempt - 1)) + Math.floor(Math.random() * (options.jitter ?? 0)); options.onretry?.({ attempt, wait, error }); await delay(wait); } } throw last; } };
13
+ }
14
+
15
+ /** Creates a circuit breaker that opens after repeated handler failures. */
16
+ export function circuitbreaker(options = {}) {
17
+ const threshold = options.failurethreshold ?? 5;
18
+ const resettimeout = options.resettimeout ?? 60000;
19
+ let failures = 0;
20
+ let openedat = 0;
21
+ let state = "closed";
22
+ async function execute(handler) { if (state === "open") { if (Date.now() - openedat < resettimeout) throw new Error("circuit breaker is open"); state = "halfopen"; } try { const result = await handler(); failures = 0; state = "closed"; return result; } catch (error) { failures += 1; if (failures >= threshold) { state = "open"; openedat = Date.now(); } throw error; } }
23
+ return { execute, status() { return { state, failures, openedat }; }, reset() { failures = 0; openedat = 0; state = "closed"; } };
24
+ }
25
+
26
+ /** Waits between retry attempts without introducing an external timer dependency. */
27
+ function delay(milliseconds) { return milliseconds ? new Promise((resolve) => setTimeout(resolve, milliseconds)) : Promise.resolve(); }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * scrape crawl context owns URL normalization traversal frontier and durable crawl state.
3
+ *
4
+ * The context keeps single page acquisition injectable while grouping the correlated
5
+ * crawl responsibilities that previously lived in four separate top level files.
6
+ */
7
+
8
+ /** Removes fragments and tracking parameters before crawl deduplication. */
9
+ export function normalizeurl(value) {
10
+ const url = new URL(value);
11
+ url.hash = "";
12
+ for (const key of [...url.searchParams.keys()]) if (/^(utm_|fbclid$|msclkid$|gclid$|gclsrc$|dclid$|gbraid$|wbraid$|twclid$|campaign$|content$|term$|source$|medium$|ref$|share_id$)/i.test(key)) url.searchParams.delete(key);
13
+ if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");
14
+ return url.href;
15
+ }
16
+
17
+ /** Compares two crawl targets by origin. */
18
+ export function sameorigin(left, right) { return new URL(left).origin === new URL(right).origin; }
19
+
20
+ /** Creates a bounded priority frontier for crawler and queue adapters. */
21
+ export function crawlfrontier(options = {}) {
22
+ const maxpages = Number(options.maxpages ?? 20);
23
+ const maxperdomain = Number(options.maxperdomain ?? maxpages);
24
+ const queue = [];
25
+ const seen = new Set();
26
+ const completed = new Set();
27
+ const domains = new Map();
28
+
29
+ /** Adds a URL once while respecting the global page budget. */
30
+ function add(input = {}) {
31
+ const url = String(input.url ?? "");
32
+ if (!url || seen.has(url) || seen.size >= maxpages) return false;
33
+ seen.add(url);
34
+ queue.push({ url, depth: Number(input.depth ?? 0), priority: Number(input.priority ?? 0), discoveredat: Number(input.discoveredat ?? Date.now()) });
35
+ queue.sort((left, right) => right.priority - left.priority || left.discoveredat - right.discoveredat);
36
+ return true;
37
+ }
38
+
39
+ /** Removes the next URL that still fits its per-domain budget. */
40
+ function next() {
41
+ while (queue.length) {
42
+ const item = queue.shift();
43
+ const domain = new URL(item.url).hostname;
44
+ if ((domains.get(domain) ?? 0) >= maxperdomain) continue;
45
+ domains.set(domain, (domains.get(domain) ?? 0) + 1);
46
+ return { ...item };
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /** Records a completed URL for diagnostics and persistence-friendly state. */
52
+ function complete(url) { completed.add(String(url)); }
53
+
54
+ /** Returns stable frontier diagnostics without exposing mutable collections. */
55
+ function state() { return { maxpages, maxperdomain, queued: queue.length, discovered: seen.size, completed: completed.size, domains: Object.fromEntries(domains) }; }
56
+
57
+ return { add, next, complete, state, list() { return queue.map((item) => ({ ...item })); } };
58
+ }
59
+
60
+ /** Creates a durable crawl queue around a caller-owned store. */
61
+ export function persistentqueue(options = {}) {
62
+ const store = options.store;
63
+ const values = [];
64
+ const seen = new Set();
65
+
66
+ /** Restores unfinished crawl records from the injected store. */
67
+ async function restore() { if (typeof store?.list !== "function") return; for (const item of await store.list()) if (!seen.has(item.url) && item.status !== "done") { seen.add(item.url); values.push(item); } }
68
+
69
+ /** Adds a crawl record and persists it when the store supports writes. */
70
+ async function add(item) { if (!item?.url || seen.has(item.url)) return false; const value = { url: item.url, depth: item.depth ?? 0, status: "queued", createdat: Date.now(), metadata: item.metadata ?? {} }; seen.add(value.url); values.push(value); if (typeof store?.save === "function") await store.save(value); return true; }
71
+
72
+ /** Claims the next queued crawl record. */
73
+ async function next() { const item = values.find((value) => value.status === "queued"); if (!item) return null; item.status = "running"; if (typeof store?.update === "function") await store.update(item.url, item); return item; }
74
+
75
+ /** Completes a crawl record and persists the resulting status. */
76
+ async function complete(url, patch = {}) { const item = values.find((value) => value.url === url); if (!item) return null; Object.assign(item, patch, { status: patch.status ?? "done", processedat: Date.now() }); if (typeof store?.update === "function") await store.update(url, item); return item; }
77
+
78
+ return { restore, add, next, complete, list() { return values.map((value) => ({ ...value })); } };
79
+ }
80
+
81
+ /** Runs bounded breadth first traversal through the injected single page scrape contract. */
82
+ export async function crawl(start, options = {}) {
83
+ const maxdepth = options.maxdepth ?? 1;
84
+ const maxpages = options.maxpages ?? 20;
85
+ const sameDomain = options.samedomain ?? true;
86
+ const frontier = crawlfrontier({ maxpages, maxperdomain: options.maxperdomain ?? maxpages });
87
+ frontier.add({ url: normalizeurl(start), depth: 0, priority: options.startpriority ?? 0 });
88
+ const results = [];
89
+ while (frontier.state().queued && results.length < maxpages) {
90
+ const current = frontier.next();
91
+ if (!current || current.depth > maxdepth) continue;
92
+ const result = await options.scrape(current.url);
93
+ results.push({ ...result, depth: current.depth });
94
+ frontier.complete(current.url);
95
+ if (current.depth >= maxdepth) continue;
96
+ for (const link of result.links ?? []) {
97
+ let url;
98
+ try { url = normalizeurl(link); } catch { continue; }
99
+ if (sameDomain && !sameorigin(start, url)) continue;
100
+ frontier.add({ url, depth: current.depth + 1, priority: Number(options.priority?.(url, result) ?? 0) });
101
+ }
102
+ }
103
+ return { results, stats: { ...frontier.state(), completed: results.length, maxdepth, maxpages } };
104
+ }
package/crawl/crawler.js DELETED
@@ -1,29 +0,0 @@
1
- /**
2
- * crawler performs bounded breadth first traversal through the scraper contract.
3
- */
4
- import { normalizeurl, sameorigin } from "./normalize.js";
5
- import { crawlfrontier } from "./frontier.js";
6
-
7
- export async function crawl(start, options = {}) {
8
- const maxdepth = options.maxdepth ?? 1;
9
- const maxpages = options.maxpages ?? 20;
10
- const sameDomain = options.samedomain ?? true;
11
- const frontier = crawlfrontier({ maxpages, maxperdomain: options.maxperdomain ?? maxpages });
12
- frontier.add({ url: normalizeurl(start), depth: 0, priority: options.startpriority ?? 0 });
13
- const results = [];
14
- while (frontier.state().queued && results.length < maxpages) {
15
- const current = frontier.next();
16
- if (!current || current.depth > maxdepth) continue;
17
- const result = await options.scrape(current.url);
18
- results.push({ ...result, depth: current.depth });
19
- frontier.complete(current.url);
20
- if (current.depth >= maxdepth) continue;
21
- for (const link of result.links ?? []) {
22
- let url;
23
- try { url = normalizeurl(link); } catch { continue; }
24
- if (sameDomain && !sameorigin(start, url)) continue;
25
- frontier.add({ url, depth: current.depth + 1, priority: Number(options.priority?.(url, result) ?? 0) });
26
- }
27
- }
28
- return { results, stats: { ...frontier.state(), completed: results.length, maxdepth, maxpages } };
29
- }
package/crawl/frontier.js DELETED
@@ -1,34 +0,0 @@
1
- /**
2
- * crawl frontier provides priorities, per-domain budgets and persistent-friendly queue state.
3
- */
4
-
5
- /** Creates a bounded priority frontier for crawler and queue adapters. */
6
- export function crawlfrontier(options = {}) {
7
- const maxpages = Number(options.maxpages ?? 20);
8
- const maxperdomain = Number(options.maxperdomain ?? maxpages);
9
- const queue = [];
10
- const seen = new Set();
11
- const completed = new Set();
12
- const domains = new Map();
13
- function add(input = {}) {
14
- const url = String(input.url ?? "");
15
- if (!url || seen.has(url) || seen.size >= maxpages) return false;
16
- seen.add(url);
17
- queue.push({ url, depth: Number(input.depth ?? 0), priority: Number(input.priority ?? 0), discoveredat: Number(input.discoveredat ?? Date.now()) });
18
- queue.sort((left, right) => right.priority - left.priority || left.discoveredat - right.discoveredat);
19
- return true;
20
- }
21
- function next() {
22
- while (queue.length) {
23
- const item = queue.shift();
24
- const domain = new URL(item.url).hostname;
25
- if ((domains.get(domain) ?? 0) >= maxperdomain) continue;
26
- domains.set(domain, (domains.get(domain) ?? 0) + 1);
27
- return { ...item };
28
- }
29
- return null;
30
- }
31
- function complete(url) { completed.add(String(url)); }
32
- function state() { return { maxpages, maxperdomain, queued: queue.length, discovered: seen.size, completed: completed.size, domains: Object.fromEntries(domains) }; }
33
- return { add, next, complete, state, list() { return queue.map((item) => ({ ...item })); } };
34
- }
@@ -1,14 +0,0 @@
1
- /**
2
- * url normalization removes tracking noise before frontier deduplication.
3
- */
4
- const tracking = /^(utm_|fbclid$|msclkid$|gclid$|gclsrc$|dclid$|gbraid$|wbraid$|twclid$|campaign$|content$|term$|source$|medium$|ref$|share_id$)/i;
5
-
6
- export function normalizeurl(value) {
7
- const url = new URL(value);
8
- url.hash = "";
9
- for (const key of [...url.searchParams.keys()]) if (tracking.test(key)) url.searchParams.delete(key);
10
- if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");
11
- return url.href;
12
- }
13
-
14
- export function sameorigin(left, right) { return new URL(left).origin === new URL(right).origin; }
@@ -1,13 +0,0 @@
1
- /**
2
- * persistent crawl queue uses an injected store and falls back to memory when no store exists.
3
- */
4
- export function persistentqueue(options = {}) {
5
- const store = options.store;
6
- const values = [];
7
- const seen = new Set();
8
- async function restore() { if (typeof store?.list !== "function") return; for (const item of await store.list()) if (!seen.has(item.url) && item.status !== "done") { seen.add(item.url); values.push(item); } }
9
- async function add(item) { if (!item?.url || seen.has(item.url)) return false; const value = { url: item.url, depth: item.depth ?? 0, status: "queued", createdat: Date.now(), metadata: item.metadata ?? {} }; seen.add(value.url); values.push(value); if (typeof store?.save === "function") await store.save(value); return true; }
10
- async function next() { const item = values.find((value) => value.status === "queued"); if (!item) return null; item.status = "running"; if (typeof store?.update === "function") await store.update(item.url, item); return item; }
11
- async function complete(url, patch = {}) { const item = values.find((value) => value.url === url); if (!item) return null; Object.assign(item, patch, { status: patch.status ?? "done", processedat: Date.now() }); if (typeof store?.update === "function") await store.update(url, item); return item; }
12
- return { restore, add, next, complete, list() { return values.map((value) => ({ ...value })); } };
13
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * scrape errors carry a stable code status retry flag severity and recovery hint.
3
- */
4
- export const errorcatalog = Object.freeze({
5
- timeout: { code: "E1001", statuscode: 504, retryable: true, recovery: "WAIT_AND_RETRY" },
6
- connectionrefused: { code: "E1002", statuscode: 503, retryable: true, recovery: "WAIT_AND_RETRY" },
7
- dns: { code: "E1003", statuscode: 503, retryable: true, recovery: "ROTATE_PROXY" },
8
- ratelimited: { code: "E2001", statuscode: 429, retryable: true, recovery: "WAIT_AND_RETRY" },
9
- forbidden: { code: "E2002", statuscode: 403, retryable: false, recovery: "REVIEW_ROBOTS_TXT" },
10
- notfound: { code: "E2003", statuscode: 404, retryable: false, recovery: "STOP_CRAWLING" },
11
- parse: { code: "E4002", statuscode: 422, retryable: false, recovery: "STOP_CRAWLING" },
12
- captcha: { code: "E4003", statuscode: 403, retryable: false, recovery: "REVIEW_ROBOTS_TXT" },
13
- session: { code: "E5001", statuscode: 401, retryable: true, recovery: "ROTATE_USER_AGENT" },
14
- config: { code: "E6001", statuscode: 400, retryable: false, recovery: "STOP_CRAWLING" }
15
- });
16
-
17
- export function webscrapeerror(kind, message, options = {}) { const preset = errorcatalog[kind] ?? errorcatalog.config; const error = new Error(message, { cause: options.cause }); error.name = "webscrapeerror"; error.code = options.code ?? preset.code; error.statuscode = options.statuscode ?? preset.statuscode; error.retryable = options.retryable ?? preset.retryable; error.recovery = options.recovery ?? preset.recovery; error.severity = options.severity ?? (error.statuscode >= 500 ? "high" : "medium"); error.details = options.details ?? {}; return error; }
18
- export function classifyerror(error) { if (error?.name === "webscrapeerror") return error; const message = String(error?.message ?? error); if (/timeout|aborted/i.test(message)) return webscrapeerror("timeout", message, { cause: error }); if (/dns|enotfound/i.test(message)) return webscrapeerror("dns", message, { cause: error }); return webscrapeerror("config", message, { cause: error }); }
package/retry/circuit.js DELETED
@@ -1,15 +0,0 @@
1
- /**
2
- * circuit breaker protects providers from repeated failure storms.
3
- */
4
- export function circuitbreaker(options = {}) {
5
- const threshold = options.failurethreshold ?? 5;
6
- const resettimeout = options.resettimeout ?? 60000;
7
- let failures = 0;
8
- let openedat = 0;
9
- let state = "closed";
10
- async function execute(handler) {
11
- if (state === "open") { if (Date.now() - openedat < resettimeout) throw new Error("circuit breaker is open"); state = "halfopen"; }
12
- try { const result = await handler(); failures = 0; state = "closed"; return result; } catch (error) { failures += 1; if (failures >= threshold) { state = "open"; openedat = Date.now(); } throw error; }
13
- }
14
- return { execute, status() { return { state, failures, openedat }; }, reset() { failures = 0; openedat = 0; state = "closed"; } };
15
- }
package/retry/policy.js DELETED
@@ -1,12 +0,0 @@
1
- /**
2
- * retry policy handles transient errors and keeps non retryable failures terminal.
3
- */
4
- export function retrypolicy(options = {}) {
5
- const maxattempts = options.maxattempts ?? 3;
6
- const base = options.base ?? 1000;
7
- const factor = options.factor ?? 2;
8
- const cap = options.cap ?? 30000;
9
- return { async run(handler) { let last; for (let attempt = 1; attempt <= maxattempts; attempt += 1) { try { return await handler(attempt); } catch (error) { last = error; if (error?.retryable !== true || attempt === maxattempts) throw error; const wait = Math.min(cap, base * factor ** (attempt - 1)) + Math.floor(Math.random() * (options.jitter ?? 0)); options.onretry?.({ attempt, wait, error }); await delay(wait); } } throw last; } };
10
- }
11
-
12
- function delay(milliseconds) { return milliseconds ? new Promise((resolve) => setTimeout(resolve, milliseconds)) : Promise.resolve(); }