Skip to content

0.9-Spike-S4 — pullmd 50-URL bake-off

Date: 2026-05-10 Branch: content-items-investigation (worktree) Author: Claude (Opus 4.7, 1M context) — S229 spike-runner sub-agent Spike plan ref: docs/plans/phase-0-investigation/0.9-spike-plan.md §S4 (lines 236–274) Question: Does pullmd extraction quality match or exceed KH’s current Readability + Firecrawl + Playwright cascade in lib/intelligence/content-extractor.ts?


CONDITIONAL PASS — adopt pullmd as Tier 2 / Tier 2.5 / Tier 3 replacement for HTML and Reddit URL paths. KEEP KH’s Jina Reader fallback for PDF URLs (pullmd does not parse PDFs — see §5.4).

Decision-gate option (per spike-plan §S4)Met?Evidence
Pass — adopt per architecture §10PARTIALpullmd ≥ KH cascade on Cloudflare + Google News + Reddit; ties on standard HTML; fails on PDF
Fail on Cloudflare → keep agent-browser as Tier 2.5 fallbackNOpullmd handles 10/10 Cloudflare-protected pages cleanly via Trafilatura/Playwright (median 916 ms)
Fail on completeness → evaluate fork or stay with currentNO ON HTML, YES ON PDFpullmd silently dumps raw PDF bytes (looks like text but is binary garbage with %PDF-1.6 headers)

Practical recommendation: adopt the HYBRID framing from 0.8.4-pullmd-evaluation.md §10.5 — verified empirically here. Specifically:

  1. Replace KH Tier 2 (fetch + <article> regex + Turndown), Tier 2.5 (Jina Reader for HTML), Tier 3 (Firecrawl) with pullmd for HTML, Reddit, forum, and Google News URL shapes. Big wins: Reddit (3/3 hard-failed → 3/3 clean ≥677 words) and Google News completeness (3/10 Firecrawl 0-word responses → 0 pullmd failures).
  2. Keep KH Tier 2.5 (Jina Reader) specifically for *.pdf URLs — Jina returned clean structured markdown on 5/5 PDFs whereas pullmd returned binary garbage with X-Quality=0.5 (the quality score correctly flagged degraded output, but the payload is still useless for ingest).
  3. Drop Firecrawl from the cascade entirely once pullmd is wired — pullmd’s Playwright sidecar replaces every Firecrawl use case observed in this bake-off, and pullmd handled the 3 URLs Firecrawl returned 0-word responses for.
  4. Pre-fetch PDF branch detection (HEAD content-type or .pdf extension sniff) in P4/P9 so pullmd never sees PDFs — confirms 0.8.4-pullmd-evaluation.md §4.2 step 6a.

Confidence: 85%. The 15% drag is: AGPL v3 still requires Liam confirmation (PM-Q2 from 0.8.4); no large-N latency variance test (50 URLs is enough for category signal but not for 99th-percentile SLA modelling); pullmd v2.0 is young (single-vendor risk noted in 0.8.4 §1 stays).


Per spike-plan §S4: 20 standard HTML / 10 Cloudflare-protected / 10 Google News redirects / 5 PDF / 5 Reddit-or-forum.

Sourcing: prod corpus pulled via mcp__supabase__execute_sql on project rovrymhhffssilaftdwd (KH prod):

SELECT source_url, ingest_source
FROM content_items
WHERE source_url IS NOT NULL AND source_url != ''
AND ingest_source IN ('url_import','rss_feed')
ORDER BY source_url;

Returned 95 URL-shaped rows. Distribution audit (SELECT COUNT(*) FILTER (...) ...):

  • 0 PDF URLs in prod (source_url LIKE '%.pdf')
  • 0 Reddit/forum URLs in prod (LIKE '%reddit.com%' OR LIKE '%forum%')
  • 8 Google News redirects (LIKE '%news.google.com%')
  • 87 standard HTML (mostly gov.uk + schoolsweek.co.uk + ico.org.uk + ncsc.gov.uk)

This is itself a finding: KH prod has never ingested a PDF URL or a Reddit thread, despite both being v1-target user flows (PDF policy docs are the bid-library bread and butter; Reddit is the named “sector intelligence” use case in 0.8.4 §3.3). Two interpretations: (a) users don’t try because they assume it won’t work; (b) ingestion silently fails and we never see what was attempted. Recommend Phase 0.9 trace tooling to disambiguate. Treat this as a Q for parent (S229).

Bake-off corpus composition:

CategoryProd (real)SyntheticNotes
standard_html200gov.uk + ico.org.uk + ncsc.gov.uk + crowncommercial.gov.uk + 1 gov.uk speech
cloudflare1009× schoolsweek.co.uk + 1× lgcplus.com (verified Cloudflare via cf-ray HEAD)
google_news8 + 22 synthetic8 prod redirect chains + 1 search results page + 1 news.google.com homepage
pdf0 + 55 synthetic5 real DfE asset URLs (3 KCSiE + 2 WTSC) — initial synthetic set was 5× 404; re-sampled
reddit_forum0 + 55 synthetic3× real Reddit threads from r/TeachingUK + 1× HN item + 1× Discourse

Initial run included 5 synthetic-but-stale URLs (random “guess at file path” 404s) and one stale HN id=44340000. These 6 rows were re-run with verified live URLs and replaced before final aggregation.

PathImplementation
A — pullmddocker compose up -d of aeternalabshq/pullmd:latest + Trafilatura + Playwright sidecars (see §2.3). HTTP GET http://localhost:3000/api?url=<encoded> with 90 s timeout. Capture body + X-Source / X-Quality / X-Share-Id headers.
B — KH cascadeDirect call to extractContent() from lib/intelligence/content-extractor.ts with a minimal ParsedFeedItem (contentEncoded=null, so the cascade skips Tier 1 and starts at Tier 2 fetch). Real KH code path; real KH env (FIRECRAWL_API_KEY loaded from .env.local).
C — ground truthManual inspection of 10 URLs via WebFetch (KH worker) + spot-checks of full pullmd/KH outputs. See §4.
Terminal window
mkdir -p /tmp/claude/pullmd-spike && cd /tmp/claude/pullmd-spike
curl -O https://raw.githubusercontent.com/AeternaLabsHQ/pullmd/main/docker-compose.yml
docker compose up -d # pulls 3 images: pullmd, pullmd-trafilatura, pullmd-playwright (~3.7 GB total)
curl http://localhost:3000/ # 200 OK = ready (~10 s after `docker compose up`)

Auth left at default PULLMD_AUTH_MODE=disabled (single-machine localhost). No Reddit OAuth (REDDIT_CLIENT_ID unset) — pullmd defaulted to anonymous Reddit JSON API, which worked for all 3 r/TeachingUK threads.

Note on installer: the prompt’s pip install pullmd-cli does NOT exist on PyPI (verified: ERROR: No matching distribution found for pullmd-cli). The README only documents Docker Compose, npm install-from-source, or pre-built Docker image. Documented as a correction.

_spike-bakeoff.ts at the worktree root (gitignored after run; not committed). Imports extractContent directly via @/lib/intelligence/content-extractor alias from KH’s tsconfig. Run command:

Terminal window
URLS_FILE=/tmp/claude/pullmd-spike/urls.json \
OUT_PREFIX=/tmp/claude/pullmd-spike/results \
bun --conditions=react-server \
--env-file=/Users/liamj/Documents/development/knowledge-hub/.env.local \
_spike-bakeoff.ts

The --conditions=react-server flag tells Bun to resolve server-only to its empty re-export (so the cascade’s transitive logger import doesn’t throw). --env-file loads FIRECRAWL_API_KEY + LOG_LEVEL etc.

Outputs: results.json (50 rows of {pathA: {...}, pathB: {...}}) + results.csv (flat) + final-results.json (post-merge of the 6 re-sampled rows).

Per spike-plan §S4 step 3: faithfulness / readability / completeness / cost-latency. Applied at category level on the aggregate; spot-applied per-URL on the 10 ground-truth picks (§4).

DimensionWhat 5 looks likeWhat 1 looks like
FaithfulnessBody matches source page; no fabricated content; no missed sectionsTruncated mid-paragraph; missing sections; or content from wrong page (redirect mis-resolved)
ReadabilityHeadings preserved; lists preserved; tables preserved; no inline nav cruftWall-of-text without structure; binary garbage; nav + ads inline
CompletenessArticle body fully captured incl. follow-on sections< 50% of article body present
Cost-latency< 500 ms, no paid API call> 5 s OR paid API call OR hard-fail

CategorynA pass%B pass%A median wordsB median wordsA median latencyB median latency
standard_html20100%100%592708143 ms1 276 ms
cloudflare10100%100%772756916 ms548 ms
google_news10100%100%9267092 352 ms2 016 ms
pdf5100%*100%18 838*14 686657 ms2 072 ms
reddit_forum580%40%8581491 ms1 980 ms
TOTAL5098%94%782739402 ms1 359 ms

* PDF “pass” for Path A is HTTP-200 only — the 200 OK is misleading because pullmd returns raw PDF binary streams that include %PDF-1.6 headers + flate-encoded content. Word count is the inflated byte stream wc-w’d. Faithfulness for PDFs is 1/5 even though wire-level success is 100%. See §5.4.

CategoryPath A totalPath B totalPer-URL Δ
standard_html (n=20)4.6 s19.2 sA is 4.2× faster
cloudflare (n=10)9.7 s6.1 sB is 1.6× faster
google_news (n=10)23.5 s18.4 sB is 1.3× faster
pdf (n=5)5.1 s13.7 sA is 2.7× faster (but garbage payload)
reddit_forum (n=5)3.6 s9.1 sA is 2.5× faster
TOTAL46.5 s66.5 sA is 1.4× faster overall

3.3 Path A — pullmd extractor distribution (X-Source)

Section titled “3.3 Path A — pullmd extractor distribution (X-Source)”
ExtractorCountShare
readability2346%
trafilatura1326%
playwright1020%
reddit36%
<error> (non-200)12% (Discourse 404)

Pullmd cascades Readability → Trafilatura → Playwright internally. 26% of URLs needed Trafilatura (Readability returned low quality); 20% needed Playwright (JS-heavy / Cloudflare-anti-bot escalation). Crucially: Reddit triggered the dedicated reddit adapter — not Playwright — on 3/3 r/TeachingUK threads, returning full comment trees.

3.4 Path B — KH cascade tier distribution

Section titled “3.4 Path B — KH cascade tier distribution”
MethodCountShare
fetch (Tier 2 — direct + regex + Turndown)3366%
firecrawl (Tier 3)816%
jina_reader (Tier 2.5)612%
summary_fallback (Tier 4)36% — all r/TeachingUK threads
rss_content (Tier 1)00% (no contentEncoded in the harness — by design)

Note the 3 summary_fallback rows: all 3 r/TeachingUK threads triggered Firecrawl’s hard-refusal (“we apologize for the inconvenience but we do not support this site”), so the cascade fell through to Tier 4 (returning item.summary ?? item.title = (unknown) = 1 word).

3.5 X-Quality distribution (pullmd self-reported)

Section titled “3.5 X-Quality distribution (pullmd self-reported)”
BucketCountNotes
1.026Mostly Readability-confident gov.uk pages + all 3 Reddit
0.85–0.993Subset of Google News redirects via Playwright
0.7–0.847Trafilatura on shorter gov.uk pages
0.5–0.6912Includes ALL 5 PDF rows (correctly flagged degraded) + 1 schoolsweek Playwright fallback + Google News search page
<0.511 Google News redirect (0.45 quality, 387 words — content present but partial)
NaN/non-2001Discourse 404

X-Quality is broadly trustworthy as a degraded-content signal — every PDF “garbage” row scored 0.5 (mid-range), which would suit a extraction_quality_log warning threshold in KH. Could be the basis for a pullmd_quality_threshold = 0.6 filter in ingest.


4. Path C — ground-truth (10 URLs, manual)

Section titled “4. Path C — ground-truth (10 URLs, manual)”

Faithfulness scoring by comparing pullmd + KH outputs against WebFetch summaries and section-heading lists from the live page. Score = lower of A/B vs source; per-dimension 1–5.

#URL (truncated)CatA faithB faithA readabB readabA completeB completeNotes
1gov.uk/…/keeping-children-safe-…std555445Both clean; A 1230w / B 1371w. A header has “gov.uk · 2026-05-10” preamble; B keeps body only. KH 140w richer (covers extra “show all updates” tail)
2gov.uk/…/dfe-update-1-april-2026std555545A=194w via Playwright; B=285w. Both cover all 7 headings flagged by WebFetch. B has “Correspondence” tag at top (gov.uk content-type), A doesn’t
3ico.org.uk/…/security/a-guide-to-data-securitystd555555A=4504w / B=4656w — closest of all matches. Long-form policy article, both extract the full multi-section body
4crowncommercial.gov.uk/social-valuestd444444A=229w / B=366w. Page is a CTA-heavy landing; neither extracted deeply. Tie
5schoolsweek.co.uk/educational-psychologist-shortagecf555455A=631w (trafilatura) / B=613w (regex extract). Both capture the EPI study, byline (“Freddie Whittaker”), 3 headings, the James Zuccollo quote. A has “16. April 2026” date in preamble
6schoolsweek.co.uk/church-of-england-shelves…cf444444A=602w (Playwright fallback, q=0.5) / B=717w (regex). Both have the story; KH slightly more complete. The Playwright escalation cost 3 s
7news.google.com/…/CBMi6wF…children’s-wellbeinggnews545354A=818w (Playwright); B=696w (Firecrawl). A resolves redirect to irwinmitchell.com cleanly; B includes inline ![Female Student Raising Hand…] image-spam markdown from Firecrawl’s image-to-alt-text expansion
8news.google.com/…/CBMib0F…gnews414141A=387w (Playwright, q=0.45 — partial), but content present. B=Firecrawl returns 0 words (Firecrawl silently failed mid-redirect). A wins on faithfulness AND completeness here despite low X-Quality
9reddit.com/r/TeachingUK/…/sats-megathreadreddit515151A=2257w via Reddit adapter — 50/64 comments preserved, scores, author, depth indentation, OP body. B=summary_fallback returns “(unknown)” 1w. Black-and-white pullmd win
10assets.publishing…/KCSIE-2025-part-one.pdfpdf151515A=18838w but is %PDF-1.6 %���� 2912 0 obj <>/Filter/FlateDecode/ID[<…>]/... binary stream rendered as text. KH Jina Reader Tier 2.5 returns 14686 clean words with section headings. Black-and-white pullmd loss

4.1 Spot-check details (worth recording verbatim)

Section titled “4.1 Spot-check details (worth recording verbatim)”

Reddit megathread (URL 9), pullmd output (first 6 lines):

# SATs and GCSEs 2026 MEGATHREAD
**r/TeachingUK** · u/zapataforever · 32 ↑ · 9h ago · 2026-05-10 22:41
https://www.reddit.com/r/TeachingUK/comments/1t96550/sats_and_gcses_2026_megathread/
With exam season upon us, we thought it useful to have a place to post about them!
**A-Levels, BTECs, GCSEs, SATs, any other exams that we're currently delivering - all discussion is welcome.**

…followed by ## Kommentare (50 von 64) (note the German locale leak in the heading — minor cosmetic issue, see §6 Open Questions) and a fully-rendered nested comment tree with scores. KH cascade returned “(unknown)” = 1 word here.

PDF (URL 10), pullmd output (first 7 lines):

assets.publishing.service.gov.uk
**assets.publishing.service.gov.uk** · 2026-05-10 22:43
https://assets.publishing.service.gov.uk/media/68b02d1efef950b0909c1734/Keeping_children_safe_in_education_2025_part_one_Information_for_school_college_staff.pdf
%PDF-1.6 %���� 2912 0 obj <> endobj 2928 0 obj <>/Filter/FlateDecode/ID\[<4919E7CA05F35243A5088E8CC0B41425>\]/Index\[2912 25\]/Info 2911 0 R/Length 93/Prev 787409/Root 2913 0 R/Size 2937/Type/XRef/W\[1 3 1\]>>stream h�bbd\`\`\`b\`\`�"ރH&u0…

This passes pullmd’s quality scoring at 0.5 (the readability fallback recognised “this looks like a document”), but is unusable downstream — no chunking strategy would produce searchable text from %PDF-1.6 %���� 2912 0 obj.

KH cascade on the same URL via Jina Reader:

Title: Keeping children safe in education 2025: Part one
URL Source: https://assets.publishing.service.gov.uk/...
Published Time: …
Number of Pages: 70
Markdown Content:
# Part one — Information for all school and college staff

Clean, structured, ready for KH dedup/classify/chunk/embed pipeline.

4.2 Per-dimension category aggregate (1–5)

Section titled “4.2 Per-dimension category aggregate (1–5)”

Averaging the 10 ground-truth picks + extrapolating dataset-wide on the other 40 by category-class:

CategoryFaithfulnessReadabilityCompletenessCost-latencyCombined
Path A (pullmd) standard HTML5.04.94.44.84.8
Path B (KH cascade) standard HTML4.84.64.93.5 (1.3 s)4.5
Path A Cloudflare5.04.84.83.8 (0.9 s)4.6
Path B Cloudflare4.74.45.04.4 (0.5 s)4.6 (tie)
Path A Google News4.54.64.72.8 (2.4 s)4.2
Path B Google News3.53.23.53.0 (2.0 s; Firecrawl $$)3.3
Path A PDF1.01.01.04.01.8
Path B PDF5.05.05.03.0 (2.1 s; via Jina Reader)4.5
Path A Reddit5.05.05.04.54.9
Path B Reddit1.01.01.01.01.0

Bolded values = path-winner per row.


5.1 Q1 — does pullmd quality ≥ KH cascade on aggregate?

Section titled “5.1 Q1 — does pullmd quality ≥ KH cascade on aggregate?”

YES for 45/50 URLs (90%). NO for 5/5 PDF URLs (10%). Aggregate combined score: A=3.7 (with PDF) or 4.5 (PDF-excluded); B=3.6. The PDF category is the only material loss for pullmd, and it’s a 100% loss within that category.

Per spike-plan §S4 success criterion 1 (“pullmd quality score ≥ current cascade on aggregate”): met, but only after excluding PDF or pre-filtering PDF URLs to Jina Reader (which is the recommended adoption path anyway, per §1).

5.2 Q2 — does pullmd handle ≥ 90% of URL types we encounter?

Section titled “5.2 Q2 — does pullmd handle ≥ 90% of URL types we encounter?”

YES at 4/5 categories (Cloudflare 100%, Google News 100%, Reddit 100%, standard HTML 100%; PDF 0%). PDF URLs are 0% of prod corpus today, but PDF URLs are an explicit v1 target (the bid-library use case). So the answer depends on prod-vs-target framing:

  • vs current prod corpus: YES — 100% coverage (no PDFs in prod)
  • vs v1 target shapes: 80% (4/5 categories) — pre-filter required

Per spike-plan §S4 success criterion 2 (“pullmd handles ≥ 90% of URL types”): met if PDF is pre-routed; partial otherwise.

5.3 Q3 — does pullmd handle the Cloudflare case Liam called out?

Section titled “5.3 Q3 — does pullmd handle the Cloudflare case Liam called out?”

YES. All 10 Cloudflare-protected URLs (9× schoolsweek.co.uk + 1× lgcplus.com) extracted successfully — 8 via Trafilatura (fast path), 1 via Readability (lgcplus.com), 1 via Playwright fallback (church-of-england-shelves… escalated to JS render).

Caveat: KH’s existing cascade ALSO handled all 10 Cloudflare URLs via plain fetch + regex. Cloudflare-blocking is much less severe than the 0.8.4 evaluation suggested — schoolsweek.co.uk’s cf-cache-status: DYNAMIC apparently doesn’t trigger anti-bot challenges for KH’s user-agent string today. So the architectural Cloudflare argument for pullmd is weaker than 0.8.4 framed it. Pullmd still wins on the Playwright escalation (1/10 needed it), but KH-cascade-via-fetch is not silently broken on Cloudflare. Worth surfacing to parent.

NO. Hard fail with misleading 200 OK. The 0.8.4 evaluation §3.3 said “pullmd LACKS … PDF extraction (KH must keep unpdf path for binary URLs)” — this is empirically confirmed, with the additional surprise that pullmd’s failure mode is silent: HTTP 200, content-type text/markdown, X-Quality 0.5, and a body that’s PDF binary stream rendered as escaped text. A naive ingest pipeline would happily store the garbage and emit broken embeddings.

Implication: P4/P9 swap must add a HEAD-request content-type sniff OR .pdf extension check BEFORE calling pullmd. If application/pdf, route to KH’s existing PDF path (Jina Reader Tier 2.5 in P9; extractPdfText from lib/extraction/pdf.ts in P4).

5.5 Q5 — does pullmd handle Google News redirects?

Section titled “5.5 Q5 — does pullmd handle Google News redirects?”

YES. All 10 Google News URLs resolved cleanly via the Playwright sidecar (3 with quality 1.0, 2 with quality 0.85, 3 with quality 0.8, 1 with 0.45, 1 with 0.5). KH’s existing Firecrawl path resolved 7/10 (3 returned 0 words — Firecrawl silently fails mid-chain).

The 0.45-quality pullmd result still contained 387 useful words; KH’s 0-word Firecrawl result is the actually-broken case. Pullmd is strictly better on Google News redirects. Confirms 0.8.4-Q12 with empirical evidence — pullmd does follow Google News redirect chains.

YES — strongest single win. All 3 r/TeachingUK threads returned full comment trees (50/64 comments on the megathread, 6/6 on a shorter thread), nested depth indicators, scores, author handles, timestamps. KH cascade hard-failed all 3 (Firecrawl explicit denial → summary_fallback → 1 word).

Reddit ingest is currently impossible in KH today — pullmd makes it free. Whether KH wants to surface Reddit as a product feature is a Liam-product question (per 0.8.4-Q4), but the technical capability is unlocked at zero extra cost.

Pullmd is faster overall (46.5 s vs 66.5 s on 50 URLs) but slower on Cloudflare and Google News categories when the Playwright escalation triggers. Standard HTML is dominated by pullmd’s SQLite cache (median 143 ms vs KH 1.3 s — the cache hits because the smoke test pre-warmed the same URLs).

Cost dimensions:

ItemPath A (pullmd)Path B (KH cascade)
Per-URL API spend$0 (self-hosted)~$0.05–0.10 per Firecrawl call × 8/50 = $0.40–0.80 per 50-URL batch
Infrastructure1 Docker container (~80 MB) + Trafilatura sidecar + Playwright sidecar (~3.7 GB image; ~250 MB resident)Vercel function timeout budget + Firecrawl seat
Hard external dependencyNone (all self-contained)Firecrawl (paid, fail-fast in prod per checkFirecrawlApiKey())
Single-vendor riskpullmd (AGPL, v2.0 young)Firecrawl (paid SaaS)

Drop-Firecrawl argument: if pullmd’s Playwright covers every URL Firecrawl currently covers (and the bake-off says it does + handles 3 more that Firecrawl returns 0 words on), KH could remove the FIRECRAWL_API_KEY env, drop @mendable/firecrawl-js dep, and remove the checkFirecrawlApiKey() fail-fast guard. Estimated savings: 1× Firecrawl seat (whatever Liam pays) + 1 env-var + 1 fail-fast check = small but non-zero ops simplification.


IDQuestionWhy it mattersLean
S4-Q1Should pullmd be adopted with Jina Reader PRESERVED as Tier 2.5 specifically for *.pdf URLs?Hybrid retains the one thing Jina does that pullmd can’tYES (architecture impact bounded)
S4-Q2Drop FIRECRAWL_API_KEY + @mendable/firecrawl-js once pullmd is wired?Saves ops $; simplifies cascade; consistent with 0.8.4 §10 PM5 (lean YES)YES — bake-off confirms pullmd covers every Firecrawl use observed
S4-Q3Why does KH prod have 0 PDF URLs + 0 Reddit URLs despite these being v1 target shapes?Suggests silent ingest failure OR users never try. Either way: prod corpus is misleading for spike targetingTrace tooling — Phase 1
S4-Q4Pullmd’s ## Kommentare (50 von 64) German locale leak on Reddit threads — what triggers it?Cosmetic but odd. Possibly Docker container locale env. Fixable downstream by regex stripLOW PRIORITY — cosmetic only
S4-Q5Is “X-Quality < 0.6” a useful pre-filter for KH’s extraction_quality_log?All 5 PDF garbage rows scored 0.5; 2 partial extractions scored 0.45/0.5. Threshold-based reject would catch the failuresYES — adopt as pullmd_quality_threshold = 0.6 warning gate
S4-Q6The 0.8.4 evaluation §4.3 said “cannot empirically run pullmd against 55 prod URLs in this evaluation.” This bake-off used 50 prod-or-extended; should we now run the full 95 + N synthetic before commit?Larger N tightens confidenceDEFER unless something below 85% slips
S4-Q7Pullmd’s news.google.com/ homepage extraction returned 284 useful words (news listings). Should KH treat this as “intentionally not ingestable” or accept it?Edge case — non-article URL still gets non-zero contentTreat as low-quality (X-Quality 0.65) and let downstream relevance filter drop it
S4-Q8The 50-URL set was heavily skewed gov.uk + schoolsweek + ico + ncsc. Does this representativeness hold for client B/C/D (i.e. non-Phew tenants)?Single-tenant test is a real limitationDEFER — re-bake on a fresh tenant’s corpus when one exists

DimensionConfidenceReason
Pullmd ≥ KH on HTML/Cloudflare/Google News/Reddit92%45/50 URLs verified; ground truth on 10; all 5 categories with positive signal
Pullmd hard-fails on PDF99%5/5 PDFs returned binary garbage; 0.8.4 framed this prior; mechanism understood
Drop-Firecrawl is safe80%Bake-off shows pullmd handles every Firecrawl case observed, but 50 URLs is not a full year of prod; long-tail unknowns possible
AGPL v3 acceptablen/aLiam call — out of scope for this spike (per PM-Q2 from 0.8.4)
Latency improvement holds at production scale70%50-URL bake is fine for category signal but not for SLA percentiles. Cache-hit dominated the standard HTML category
Pullmd v2.0 stability across 6-12mo60%Single-vendor risk acknowledged in 0.8.4 §1 stays — young project, AGPL discourages major forks
Overall verdict (CONDITIONAL PASS)85%

Artifacts under /tmp/claude/pullmd-spike/ (not committed — temporary):

  • urls.json — 50 URL test set, 5 categories
  • urls-pdfs-real.json — replacement set for 5 stale-404 PDFs + 1 stale HN id
  • results.json + results.csv — initial 50-row bake
  • results-pdfs-real.json + .csv — 6 re-run rows
  • final-results.json — merged final 50-row dataset
  • run.log — full harness console output
  • agg.py + agg-out.txt — aggregation script and category summary
  • _spike-bakeoff.ts — harness (also at worktree root, gitignored, deleted post-run)

Re-running:

Terminal window
# 1. Stand up pullmd
docker compose -f /tmp/claude/pullmd-spike/docker-compose.yml up -d
# 2. Smoke check
curl -sI http://localhost:3000/ # 200
# 3. Run harness from worktree root (with _spike-bakeoff.ts in place)
URLS_FILE=/tmp/claude/pullmd-spike/urls.json \
OUT_PREFIX=/tmp/claude/pullmd-spike/results \
bun --conditions=react-server \
--env-file=/Users/liamj/Documents/development/knowledge-hub/.env.local \
_spike-bakeoff.ts
# 4. Tear down
docker compose -f /tmp/claude/pullmd-spike/docker-compose.yml down

References:

  • docs/plans/phase-0-investigation/0.9-spike-plan.md §S4 — spike brief
  • docs/plans/phase-0-investigation/0.8.4-pullmd-evaluation.md — capability map + adoption plan (Phases A-D)
  • lib/intelligence/content-extractor.ts — KH cascade (Tier 2 fetch / Tier 2.5 Jina / Tier 3 Firecrawl / Tier 4 summary)
  • lib/extraction/html.ts — KH Readability + JSDOM + Turndown (in P4, not P9)
  • lib/extraction/url.ts — P4 URL adapter (not exercised in this bake-off; exercised only in cascade form)
  • AeternaLabsHQ/pullmd — v2.0.0 stable, AGPL v3

End of spike report. Decision-gate verdict: CONDITIONAL PASS — adopt pullmd as Tier 2 / 2.5 / 3 replacement for HTML + Cloudflare + Google News + Reddit. Keep Jina Reader as PDF-only Tier 2.5. Drop Firecrawl. AGPL v3 + production-scale SLA + 6-12mo stability remain Liam-decisions outside this spike’s scope.