Skip to content

Lead Audit

Module id: lead-audit · Public router: /lead-audit/* (pre-CORS, own SSRF/rate guards) · Ops routes: /api/modules/lead-audit (internal users) · No settings row, no crons (workspace-less).

Purpose

Public site-audit lead magnet: any chat frontend (luniq.io first) starts an audit for a visitor's domain, streams a tightly-budgeted teaser (one money headline, one scoreboard, an email gate), then — only after an email is captured — runs a deeper tier and emails a link to the interactive hosted report. No LLM writes teaser copy; every SSE event carries server-templated copy in the visitor's language.

Features

  • Chat tier (startLeadAuditrunChatTier, ~30–60s, no spend gate): homepage reachability, site checks, rendered parity, CWV (field data only — a site without CrUX data gets no cwv-poor; the homepage lab reading is stored on results.data.cwv.lab and no longer written as a finding or headline copy since 2026-08-19), market footprint (DataForSEO Labs domain rank + backlinks summary in the site's own inferred market — ccTLD first, then the homepage <html lang> region subtag like nl-BE (en-US is treated as CMS boilerplate, not a declaration), then the business country from the Haiku homepage read (siteFacts), then the language's biggest market), synthesized showstopper findings → SSE ticks, ONE opportunity/money headline (computeOpportunity: missed visits/revenue, ad equivalent, deal-value slider. Site type is classified from cheap on-page signals (classifySiteType) with a transactional guard — a shop or repair business carrying retail/repair vocabulary can never inherit the B2B deal value off a shared word like "diensten" — and deal-value defaults sit at the conservative end of each type's range so the slider only ever moves the number UP toward the owner's real value; every estimate carries a basis object naming its site-type reason, conversion benchmark, deal assumption and the authority discount applied (the ad-equivalent is the one figure with no assumptions stacked). The model COMBINES two non-overlapping stories: ranking headroom (positions 4-15, gain × CPC ranked) plus the category demand the site is absent from (nothing ranked ≤15 counts twice; source: headroom|category|combined). Category candidates come seeds-first — the site's title/H1 n-grams plus Haiku-proposed 2-3-word customer concepts (siteFacts) expanded through Keyword Planner — with keywords_for_site as fallback; word-order variants dedupe by token set; a winnability curve prices them (15% tail / 5% mid / 1% head) further scaled by the domain's EARNED authority — referring domains + existing rankings — so a link-less, rankless site is discounted toward a floor, and a site that ranks for nothing with almost no links (0 keywords, <10 referring domains) skips the category source entirely and lets its honest invisible headline carry it (a fabricated euro figure there read as hype); for a location-bound business a mechanical national-head guard drops "near me" and placeless >5k-volume terms — applied to the category demand AND to ranking headroom, so a local business is never credited a top-3 jump on a national head term it only ranks page-2 for. Finally the Haiku buyer gate prunes every keyword a customer of THIS business wouldn't search before the sums are computed — services/relevance.js now builds only the audit-specific CONTEXT (siteFacts; the homepage reader itself is core/services/business-identity.js#businessContextFromHtml, re-exported here as buildSiteContext) and delegates the judgment to core/services/keyword-relevance.js, shared with the market module, which asks the same question of a workspace profile (40-keyword batches, best-effort: any failure keeps the mechanical rows) — the same gate filters the deep tier's keyword-gap table (site context stored on deepInput.siteContext)), ONE headline, score/verdicts (computed, shown only in the report; the stored lead-facing score is computeLeadScore: the worst verdict-level penalty (max 50, never stacked) for site-blocks-crawlers/no-content-engine/csr-content-missing or opportunity mode invisible, stored as data.scorePenalty {id, points} and rendered next to the score on the PDF and the interactive page — so the score, the penalty and the category bars always reconcile), email gate. Parks as status: 'teased'. The row doubles as a 7-day per-domain cache (per language) with paced replay.
  • Deep tier (runDeepAudit, triggered only by /claim email; race-safe teased → deep_running flip): 50-page crawl, buying-intent keyword gap (competitor-brand keywords excluded — token match against the rival's domain name), LLM mentions, final scores → status: 'done'maybeSendReport (idempotent, personal Resend email whose one visual element is the button linking appReportUrl() — no PDF attachment; the page's own Download button covers that).
  • Outreach batch (runOutreachAudit + services/batch.js): the internal mass surface — fed by the CRM over the API key, or by hand from the internal dashboard's Growth → Audits tab (AuditBatchTrigger: paste domains, pick en/nl, queue; the submissions table below shows progress and report links, outreach rows labeled). POST /batch queues up to 100 domains; an in-memory queue (concurrency 2 — the spend throttle) runs the SSRF gate then BOTH tiers per domain, awaited, with no claim and no email (maybeSendReport refuses unclaimed rows). Rows carry source: 'outreach' (vs default 'inbound') so they stay out of the public daily-cap ledger (countToday) and are labeled in the leads list. Same 7-day cache both directions — re-POSTing a batch after a restart is safe, and an inbound visitor reuses a fresh outreach row. DNS-failed domains get a visible failed row.
  • Hosted report (public, capability-URL): GET /lead-audit/:auditId/report serves the report HTML, .pdf the download, and report.json (services/report-data.js) the data behind the app frontend's interactive page at /audit/:auditId (all copy resolved server-side in the row's language; the deal-value slider recomputes missed revenue client-side) — only for status: 'done' rows; anything else 404s. The uuid is the secret. Both outreach emails (via the CRM) and the inbound report email link this interactive page; nothing is attached anywhere.
  • Guards: domain normalization, SSRF (assertPublicDns — also run per-domain in the batch queue), rate limits, Cloudflare Turnstile (no-op until key set), email validation, per-IP SSE cap.
  • Failed scans are a meeting hook: when the homepage fetch dies (timeout / TLS / refused / blocked — HTTP-status failures instead continue as the site-blocks-crawlers showstopper), the error event names the most likely cause from the real fetch error and offers a free manual review at LEAD_AUDIT_BOOKING_URL (default https://pulse.luniq.io/book/leonmissoul). The same link powers the interactive report's "Schedule a free 30-min call" CTA (labels.bookingUrl). Browser callers must be on the CORS allowlist (LEAD_AUDIT_ALLOWED_ORIGINS; defaults cover luniq.io/www, orbit.luniq.io, app.luniq.io and localhost dev ports).

Routes

Public:

MethodPathPurpose
POST/lead-audit/start{domain} → 202 {auditId, cached}
GET/lead-audit/:auditId/streamSSE: tick / opportunity / headline / scoreboard / gate / done / error
GET/lead-audit/:auditId/events?after=Polling fallback
POST/lead-audit/:auditId/claim{email} → 202 {message, reportUrl}, triggers the deep tier
GET/lead-audit/:auditId/reportHosted report page (finished audits only, else 404)
GET/lead-audit/:auditId/report.pdfThe same report as a PDF download; ?deal= mirrors the page's slider choice (clamped to the slider bounds, money recomputed) so the export shows exactly what the visitor saw
GET/lead-audit/:auditId/report.jsonReport data for the interactive app page /audit/:auditId

Ops (internal): GET /leads?claimed=true, GET /leads/:id (includes sell), GET /leads/:id/report.pdf, POST /batch ({domains[], language?} → 202 queued/rejected/queueDepth), GET /batch/status?domains=a.com,b.com (per-domain status + reportUrl/pdfUrl once done). Every result surface carries reportUrl (batch status, leads list + detail, public claim) = the interactive app page appReportUrl() (report.js): LEAD_AUDIT_APP_BASE (default https://orbit.luniq.io) + /audit/:auditId. pdfUrl stays on the backend host (LEAD_AUDIT_PUBLIC_BASE, defaulting to the request host).

The two batch routes accept either an internal-user session or the machine key x-api-key: LEAD_AUDIT_OUTREACH_KEY (requireBatchAccess; constant-time compare in guard.js, deliberately not the Authorization header — that carries the session JWT). The key opens only the batch surface; the CRM contract is the repo-root LEAD_AUDIT_OUTREACH_API.md (like LEAD_AUDIT_API.md for the website).

Finished audits carry sell: 'launched' | 'orbit' | 'both' | 'nothing'recommendOffer (report.js) folds the report's own data-picked pitch (pickPitch: technical damage → Launched, content/demand gap → Orbit; a "gap" is ANY unanswered demand the audit surfaced — gap keywords, a no-content-engine verdict, a thin footprint, invisible mode, or an opportunity model with real missed visits) into one signal for the CRM. null until status: 'done' (pre-deep rows are missing the crawl findings that can flip it). Batch status reads it via narrow jsonb projections, never the full results blob.

Data

Owns lead_audits (domain, status, language, source 'inbound'|'outreach', email, score, results jsonb: events/data/findings). Uses core DataForSEO + the workspace/site-audit/ spine + core PDF/Resend.

Key files

index.js, public-routes.js, routes.js (ops + batch), services/engine.js (two-tier engine + SSE registry + runOutreachAudit), services/batch.js (outreach queue), store.js (lead_audits CRUD + claimDeepRun), services/guard.js, services/copy.js (i18n events), services/opportunity.js, services/showstoppers.js, services/keyword-gap.js (since 2026-08-22 its comparability filters — self-drop, ≥2 intersections, size-ratio cap — and brand-token exclusion live in core/services/keyword-field.js, shared with the market module; since 2026-08-23 the LLM judgment moved to core/services/keyword-relevance.js too — this module keeps the homepage context it feeds it), services/report.js (PDF HTML), services/email.js.