Spec: Web Worker — Fetcher Abstraction & Web/SPA Projection¶
File: .ai/specs/core-engine/2026-07-02-web-ingestion.md Status: Draft Owners: Workers lane Related: PR-035; F5 ratified (swappable Fetcher, Playwright fallback); ingestion-provenance spec
1. Overview¶
Defines the Fetcher abstraction that makes the web-rendering backend swappable (Firecrawl hosted API vs built-in Playwright), the crawl policy, recrawl semantics, and the Markdown→graph projection shared identically by both backends.
2. Problem Statement¶
F5 established the licensing/cost constraint: Firecrawl self-host is AGPL, hosted is paid. Beyond that: web content changes under our feet (recrawl semantics must be defined), crawling has legal etiquette (robots), and two rendering backends will drift into two different graphs unless projection is backend-independent by construction.
3. Proposed Solution¶
A Fetcher protocol — fetch(url, opts) -> {markdown, title, canonical_url, fetched_at, links[], meta} — with FirecrawlFetcher (API client) and PlaywrightFetcher (headless Chromium + readability extraction + html→markdown) implementations selected by config. Everything downstream of the Fetcher return value is one shared code path, so backend-equivalence is structural. Recrawl of the same URL = new source version (content-hash dedup applies per ingestion-provenance spec). v1 scope: single-URL jobs plus bounded same-origin crawl (depth ≤ 2, page cap 50) when requested; robots.txt honored (fetch-and-cache per origin, 1h TTL); no auth-walled content.
4. Architecture¶
PollJobs(kind=ingest:web) → robots check → Fetcher.fetch → shared projection → SubmitIngestionResult; discovered same-origin links within crawl bounds enqueue child jobs (child_jobs field) rather than looping in-worker (keeps jobs small, leases honest).
5. Data Models¶
Node labels: WEB_PAGE (title, canonical_url, fetched_at), SECTION per heading (level), PARAGRAPH per block. Edges: CONTAINS (ord); REFERENCES from WEB_PAGE/PARAGRAPH to target URL — targets within the same ingestion resolve to their WEB_PAGE node; external targets become lightweight URL_REF nodes (dedup per tenant by normalized URL). Canonical text = the markdown (NFC, LF); spans per block per ingestion-provenance rules. URL normalization: lowercase host, strip fragments, strip tracking params (utm_*, fbclid, gclid), preserve everything else.
6. API Contracts¶
Fetcher protocol as §3 (Python Protocol, both impls tested against one contract suite). Job payload: {url, crawl: {depth, max_pages} | null, fetcher_override?}. Failure taxonomy: fetch_denied (robots/4xx auth — dead-letter), fetch_transient (5xx/timeout — retryable), render_failed (retryable once, then dead), resource_limit (retryable).
7. UI/UX¶
None beyond standard job status. Worker /health includes per-backend reachability (Firecrawl API ping / Chromium launch check).
8. Configuration¶
web.fetcher (playwright default — zero external dependency out of the box; firecrawl opt-in with API key), web.crawl.max_depth (2), web.crawl.max_pages (50), web.robots_ttl (3600s), web.timeout_s (60), web.user_agent (identifies Telha, contact URL).
9. Alternatives Considered¶
Firecrawl self-host (rejected default: AGPL isolation is arguable over the network but the operational burden plus license review per F5 makes Playwright the safer shipped default; hosted API remains a config away). Raw HTTP + readability without headless browser (rejected as sole path: SPAs render empty; kept internally as PlaywrightFetcher's fast path when no JS detected). Unbounded crawling (rejected: scope creep into a crawler product; depth-2/50-page bounds cover "ingest these docs pages" without becoming Heritrix).
10. Implementation Approach¶
PR-035: this spec → Fetcher protocol + both impls → shared projection → fixture-server crawl test → backend-equivalence test: both fetchers against the same fixture site must produce identical projections (the F5 gate).
11. Migration Path¶
Greenfield. Adding a third backend = new Fetcher impl passing the contract + equivalence suites; no projection changes.
12. Success Metrics¶
Backend-equivalence test green. Robots test: disallowed path never fetched. Recrawl test: changed page ⇒ new source version; unchanged ⇒ dedup no-op. Link-graph test: internal REFERENCES resolve to WEB_PAGE nodes, external to deduped URL_REFs.
13. Open Questions¶
- OQ-1: JS-heavy sites behind consent walls — auto-dismiss common consent frameworks or record fetch_denied? Stance: v1 records fetch_denied with reason=consent_wall; auto-dismissal is a legal/etiquette decision to take explicitly later.
14. Changelog¶
- 2026-07-02 — v0.1: initial draft for peer review.
- 2026-07-03 — v0.2 (PR-035 as built): (1) PlaywrightFetcher's fast path is plain HTTP + readability + markdownify; Chromium escalates only on an unrendered-SPA heuristic (short extraction + script tags). (2) Projection consumes ONLY markdown/title/canonical_url — fetcher
linksfeed crawl discovery, never the graph — so backend equivalence is structural; the offline F5 gate runs both fetchers against a local fixture site (the Firecrawl side via an API emulator that shares the html→markdown helper). (3) URL_REF dedup is per-submission in v1, not per-tenant (workers cannot query; per-tenant dedup needs deterministic node identity/upsert — deferred). Likewise cross-page REFERENCES resolve to URL_REF, self-links to the WEB_PAGE node. (4) Recrawl semantics as specced via two core changes: fetch formats skip request-level dedup, and submissions carry an optionalcontentHash(hex BLAKE3 of fetched markdown) driving submit-time dedup against the latest version (same-job retries exempt so crash recovery re-applies). (5) Crawl fan-out: submissionchildJobs[{format,name,options}]enqueued by core; the page budget (maxPages) is split deterministically across same-origin links in document order, each child at depth-1. Job options flow RESToptions→ job payloadoptions→ worker ({url?, crawl:{depth,maxPages}, fetcherOverride?}); for format=web the REST content field may be omitted (URL is the identity). (6) robots.txt: 401/403 on robots itself = deny-all (RFC 9309), other 4xx = allow, unreachable = allow; disallow ⇒ fetch_denied (permanent).