Video walkthrough: Watch the 90-second Loom demo
One company domain -> static scrape -> DeepSeek JSON -> Zod-validated lead card with evidence, confidence notes, and eval metrics.
Live demo: https://coldl.vercel.app
Cold Lead Decoder is a single-route service for B2B outbound research. A user pastes a company domain; the app statically scrapes the homepage (and, conditionally, /about), runs the extracted text through DeepSeek in strict JSON mode, validates the response against a Zod contract, and returns a lead card containing a one-paragraph summary, positioning signals, likely pain points, a personalized opener grounded in a concrete trigger from the company's own pages, and two follow-up angles. There is no database, no auth, and no queue — the whole pipeline lives in one Node route handler (POST /api/decode) and runs a linear five-stage flow: fetch → extract → generate → validate → guard.
The pipeline accepts an arbitrary, user-supplied domain and forwards parts of a third-party HTML response to an LLM. Defense-in-depth controls live at every layer:
-
SSRF Protection —
safeFetchresolves every host with bothdns.resolve4anddns.resolve6and rejects the request if any resolved address falls in a private, loopback, link-local, RFC-6598 CGNAT, or ULA (fc00::/7) range; IP literals in the URL are checked directly against the same ranges. Redirects are followed manually withredirect: "manual", and the full DNS + IP-literal check is re-applied on every hop, so a redirect pointed at a blocked address is rejected before it's followed. This is not complete protection against DNS-rebinding attacks: the validation lookup and the actualfetch()connection are two separate DNS resolutions, so a record with a very short TTL that changes between them could still let a connection reach a private address after the check passed. Source:lib/scraper/fetch.ts. -
Prompt Injection — scraped page text is wrapped in
<website_content>…</website_content>tags inside the user message, and</>inside the payload are entity-escaped (escapeXmlTagsinlib/llm/utils.ts) so an attacker cannot close the tag from inside the page. The system prompt has an explicit security clause instructing the model to treat everything inside the tags as data, never as instructions, and to ignore role declarations or admin overrides embedded in the page. Source:lib/llm/repair.ts. -
Rate Limiting — an in-memory sliding-window limiter keyed on client IP, defaulting to 5 requests per 60 seconds (overridable via
RATE_LIMIT_MAXandRATE_LIMIT_WINDOW_MS). Buckets are cleaned via a lazy sweep that runs at most once per window, but memory is bounded by two explicit caps, not by the sweep alone: per identifier, a bucket never stores more thanRATE_LIMIT_MAXaccepted timestamps — once a client is over the limit, further blocked requests don't grow its array; and process-wide,RATE_LIMIT_MAX_BUCKETS(default 10,000, overridable) caps the number of distinct identifiers tracked at once — once reached, a brand-new identifier is denied fail-closed rather than allocating another bucket, while identifiers already tracked are never evicted to make room. Worst case, that's at mostRATE_LIMIT_MAX × RATE_LIMIT_MAX_BUCKETStimestamps per process. The identifying IP is chosen byextractClientIp(lib/security/rateLimiter.ts): it prefersx-vercel-forwarded-for(Vercel's documented, most-tamper-resistant client-IP header), then the rightmost entry ofx-forwarded-for— never the leftmost, which a client can freely set to rotate through unlimited fake identities — thenx-real-ip, validating IP syntax and canonicalizing the result (so re-encoding the same address can't mint a new bucket) at every step, falling back to a single shared"unknown"bucket rather than trusting anything unparseable. This trust is a documented platform assumption, not a runtime-verified guarantee: nothing confirms the request actually traversed Vercel's edge, so outside of Vercel (local dev, another host, or direct exposure with no proxy at all) every one of these headers must be treated as attacker-controlled — there is no dependable runtime signal (Vercel's ownVERCELenv var is only set when a project opts in to "System Environment Variables") that can gate this trust. This is still a process-local, best-effort limiter, not distributed rate limiting: state lives in a plainMapinside a single function instance, resets on every cold start, and is not shared across concurrent Vercel instances — the effective global limit scales with however many instances are warm, not with the configured value. Not production-grade on its own; a production deployment would need a shared store (e.g. Redis/Upstash) as the actual cost-control mechanism. Source:lib/security/rateLimiter.ts. -
Cost Protection — every outbound fetch has an 8-second timeout, follows at most 3 redirects, and is capped at 1,500,000 bytes (~1.5 MB) of body — enforced both via the
content-lengthheader and a streaming guard that aborts mid-read. Combined homepage +/abouttext is truncated to 12,000 characters before reaching the LLM (TEXT_BUDGETinlib/scraper/extract.ts). A per-domain LRU output cache (lru-cache, max 500 entries, 24-hour TTL) eliminates redundant DeepSeek calls. Sources:lib/scraper/fetch.ts,lib/scraper/extract.ts,lib/cache/domainCache.ts.
-
Automated Testing — 209 unit and integration tests passing in Vitest, spanning the schema, the SSRF + body-cap fetch layer, the Readability/cheerio extractor, the DeepSeek wrapper with backoff, the validate-and-repair loop, the rate limiter, the LRU cache, the pipeline orchestrator, the API route, and the React components. Run with
npm test. -
Qualitative Eval — a property-based eval harness at
tests/eval/harness.test.tsruns DeepSeek against five hand-built fixture types intests/eval/golden_set.json:Fixture What it stresses normalrich homepage with multiple concrete triggers (launch, customer, fundraise) degradedthin "coming soon" page → must mark degraded: trueand avoid fabricationinjectionembedded IGNORE ALL PREVIOUS INSTRUCTIONSpayload — schema must holdno_triggervague consulting boilerplate — opener must not invent a trigger strong_signalexplicit recent launch — opener must reference the trigger keyword Each fixture asserts: Zod-shape validity, a non-empty
evidence.opener_basis, exactly 2follow_up_angles, banned-phrase compliance on the opener, and (forstrong_signal) a regex match on the trigger keyword. The eval suite is gated onDEEPSEEK_API_KEYand is skipped automatically when the key is absent.A committed run of this harness is recorded in
docs/eval-results.md. -
Operational Metrics — a live
/evaldashboard (coldl.vercel.app/eval) is backed by Neon Postgres: a nightly cron decodes 20 domains and writes one row per run, and the page surfaces success rate, p50/p95 latency, and run count. These are operational health metrics (does the pipeline run, and how fast), not accuracy evals.
The full set of decisions — framework, runtime, scraping strategy, LLM contract, schema authority, SSRF policy, persistence, failure UX, UI dependencies, rate limiting — is recorded in docs/architecture-decisions.md as ADR-001 through ADR-010.
The linear pipeline:
- fetch —
lib/scraper/fetch.ts— SSRF guard + timeout + body cap + per-hop redirect re-check. - extract —
lib/scraper/extract.ts—@mozilla/readability(primary),cheerio(fallback), conditional/aboutretry, 12k-character text budget. - generate —
lib/llm/repair.ts+lib/llm/deepseek.ts— DeepSeek call injson_objectmode with thinking disabled, plus one repair attempt on Zod failure. - validate —
lib/schema/leadCard.ts— the Zod schema is the contract; the model never has the last word on shape. - guard —
lib/opener/guard.ts+lib/pipeline/decode.ts— banned-phrase check on the opener; failures stampconfidence_notesrather than retry.
- Next.js 15.5.20 (App Router, Node runtime)
- TypeScript
- DeepSeek
deepseek-chat(intentionally used overdeepseek-v4-flashfor JSON mode reliability; v4-flash can be re-evaluated via A/B eval harness when needed) via the OpenAI SDK (response_format: { type: "json_object" }, thinking disabled, exponential backoff on 429/500/503) - Zod — single source of truth for the API and UI contract
@mozilla/readability+jsdom, withcheerioas fallbacklru-cache— in-memory, 24-hour per-domain output cache (not Vercel KV — process-local, feature-flagged viaENABLE_CACHE)- Neon (Postgres) — used exclusively for eval-run metrics persistence via
@neondatabase/serverless; no user data, sessions, or app state ever touch a database - Vitest 3.2.7 + React Testing Library
Requirements: Node.js 20+ and a DeepSeek API key.
npm ci
cp .env.example .env.local
# Windows PowerShell: Copy-Item .env.example .env.localEdit .env.local and set DEEPSEEK_API_KEY (get one at https://platform.deepseek.com/api_keys).
npm run devOpen http://localhost:3000 and paste a domain like stripe.com. To run the test suite:
npm testMIT — see LICENSE.