Find the owners ready to sell. DealSignal sits on top of a SaaSquatch Leads export and turns a long, half-empty list of companies into a short, ranked, explained shortlist of acquisition targets — with a first message to each owner ready to send.
Built for the Caprae Capital pre-screening challenge (5-hour build).
Live demo: https://dealsignal-bay.vercel.app (no file needed: click "Use the sample of 106 Dallas–Fort Worth contractors" on the home page)
SaaSquatch's users are searchers: people looking to buy and run one small business. A search returns hundreds or thousands of companies. Studying SaaSquatch's own demo videos showed where that leaves a searcher:
| What we saw | Why it hurts |
|---|---|
| 1,000+ results, no ranking | A searcher can only call a handful of owners a day |
Revenue, BBB, employees, year founded mostly N/A |
No way to tell a $1M shop from a $20M one |
| Revenue estimates tagged "Low confidence", no method | Hard to trust |
| "AI Company Scoring" listed but never explained | Black box |
| Nothing about who might sell | The one question a searcher actually has |
| Email generator needs 3 × 20-word context pasted by hand | Slow |
- Upload a SaaSquatch CSV (any CSV with a company column works — headers are auto-mapped).
- Set a buy-box: industries, states, revenue band, minimum years in business.
- Clean: normalises domains and phone numbers, merges duplicates (same website, or same name + city), and counts locations when one website appears in several cities.
- Check every website (≤5 pages: home, about, team, careers, contact) and domain records.
- Score each company on two axes, with the reason for every point:
- Fit — does it match the buy-box?
- Ready to sell — public signals that an owner may be open to a conversation.
- Rank into Tier A (call first), B, C, and Dropped (dead website).
- Write an opener for any lead, built on one verified fact.
- Export a full CSV or a HubSpot company-import CSV.
Changing the buy-box re-ranks the list instantly — scores are recomputed from stored signals, nothing is re-crawled.
| Signal | Points | Where it comes from |
|---|---|---|
| 20+ years in business (10–19: +12) | +25 | "Since 1986", "over 40 years", or the list's Year Founded |
| Family / owner-operated | +20 | Site wording |
| Owner identified | +10 | Site text ("our founder, Greg Coward…") |
| Runs on the owner personally | +10 | "Call me directly", "I personally…" |
| Single location | +10 | Not multi-location on the site or in the list |
| Not hiring | +10 | No careers page / "now hiring" |
| Website not updated in 3+ years | +15 | Footer copyright year |
Every signal is stored with the URL and the quote it came from, shown in the lead drawer. Nothing is inferred about an owner's age or health.
headcount × revenue per employee for the industry (US Census, Statistics of US Businesses 2022) ± 30%, e.g. 20 technicians × $260k (Plumbing, Heating & AC Contractors) ≈ $3.6M–$6.8M.
Headcount comes from the strongest evidence available, in order:
- A stated number on the site ("our 20 technicians", "a team of 25")
- The list's Employees Count column (SaaSquatch fills this from Apollo/Growjo)
- People named on the team page — a heading counts only if its first word is a real first name (SSA list), so "Hugo Sanchez" counts and "Roof Repair" does not
- Fleet size ("12 service trucks" → ~18 staff)
With no company-specific evidence, the app shows what a typical small firm in that industry earns (Census: average of firms under 20 staff → average of firms with 10–19 staff, × locations). It is shown greyed and labelled industry typical, earns only half the buy-box points, and is never exported to HubSpot as the company's revenue. See data/README.md.
public/sample-saasquatch-dfw.csv — 106 real Dallas–Fort Worth home-service businesses in SaaSquatch's export format (from OpenStreetMap; see data/README.md). Numbers are from one run; they shift slightly between runs because live websites go up and down, and when the free AI tier is busy a few companies are filled in on the next run instead.
| Rows uploaded | 106 |
| Unique companies after merging duplicates | 88 |
| Dead websites dropped | 6 |
| Tier A / B / C | 23 / 24 / 35 |
| Year founded known | 11% → 48% |
| Owner name known | 0% → 16% |
| Contact email known | 0% → 24% |
| Revenue from the company's own headcount | 0% → 9% (the rest get a labelled industry-typical range) |
| Time (cold, 88 sites) | ~3 min · re-runs are instant from cache |
flowchart LR
B[Browser<br/>Next.js UI] -- CSV rows + buy-box --> J[POST /api/jobs<br/>normalise + dedupe]
J --> DB[(Neon Postgres)]
B -- loop until done --> P[POST /api/jobs/:id/process<br/>claims 6 leads]
P --> C{domain_cache<br/>< 7 days?}
C -- hit --> DB
C -- miss --> E[enrich<br/>site crawl · RDAP · MX]
E -- rules miss owner/year --> G[Groq LLM<br/>quote-verified extraction]
E --> DB
B -- GET /api/jobs/:id --> S[score on read<br/>fit · readiness · tier]
S --> DB
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 15 (App Router, TypeScript, Tailwind CSS 4) | One codebase for UI + API routes |
| Hosting | Vercel, serverless functions (Node.js runtime), static assets on the CDN | Zero-ops, scales to zero |
| Cloud | Vercel on AWS (us-east-1), database on AWS us-east-1 via Neon | App and DB in the same region |
| Database | Neon Postgres (serverless), @neondatabase/serverless HTTP driver |
No connection pool to manage from serverless functions |
| Queue | None — the browser calls /process in a loop; leads are claimed with UPDATE … FOR UPDATE SKIP LOCKED |
No worker or cron needed; safe if two tabs run at once; stuck leads are re-claimed after 6 min |
| Caching | domain_cache table, 7-day TTL, shared across uploads |
Re-running or overlapping lists never hit the same site twice |
| Rate limiting | rate_limits table, fixed one-hour windows per visitor |
Works across serverless instances without adding a cache service |
| Scores | Computed on read, not stored | Buy-box edits re-rank instantly |
| Scraping | fetch + cheerio, 8 s timeouts, ≤5 pages per site, 6 sites in parallel |
Small and fast |
| AI | Groq free tier (openai/gpt-oss-20b, falls back to qwen/qwen3.8-27b, openai/gpt-oss-120b when rate-limited) |
Fast inference; rotating models multiplies the free per-model token budget |
| CSV | papaparse |
Robust parsing and export |
- Rules first, AI second. Regex rules extract founding year, ownership, owner name, employees, hiring and copyright year. The LLM only runs when rules miss the owner or founding year.
- No hallucinated facts. An AI-extracted owner name, year or headcount is kept only if it appears verbatim in the page text, and the quote is stored as evidence.
- Moved sites. If a domain now redirects to a different business (sold, parked, rebranded into something else), that site's facts are not used and the lead is flagged; a move to a domain carrying the same name is treated as the same business.
- Shared hosts. A Facebook, Yelp or Google listing given as the "website" is never used to merge leads or crawled as the company's site.
- Dead vs blocked. A site that does not resolve or refuses connections is dropped. A site behind bot protection (HTTP 401/403/429/503) is kept and flagged "verify manually" — it is not treated as closed.
- Deduplication by website domain, falling back to normalised name + city.
- Identifies itself honestly (
DealSignalBot/0.1) and respectsrobots.txt. - Reads only a company's own public website and public domain records (RDAP registration date, DNS MX).
- Does not scrape LinkedIn, solve CAPTCHAs, rotate IPs or spoof a browser to get past bot protection.
- If
robots.txtdisallows the homepage, nothing from the site is used and the lead is flagged "verify manually".
- No SSRF. Uploaded lists are untrusted, so every fetch (and every redirect hop, followed manually) is refused unless the host is a public domain name resolving only to public IPs — no IP literals,
localhost, private ranges or cloud metadata addresses. - Safe exports. CSV cells that start with
=,+,-or@are escaped, so scraped text can't run as a spreadsheet formula. - Per-visitor limits. Each visitor can create 10 lists and write 30 openers per hour (counted in Postgres, since serverless instances share no memory), so a public demo link can't drain the free AI quota or be used to crawl the web at scale.
- Openers always answer. One 90-second budget covers every retry and rate-limit wait, so the request ends with an opener or a clear "try again" well before the platform's time limit.
- Bounded work. Pages are read up to 1.5 MB, one batch runs per job at a time, and openers are only reachable through their job's id.
- One bad site can't stall a list. A crash while checking one company is logged and that lead is scored on its CSV data alone.
- Inputs are validated at the API boundary (row cap, buy-box sanitising, id formats). Results are reachable only through their unguessable job link; there are no user accounts in this prototype.
- Openers never speculate about an owner's age, health or retirement.
Requirements: Node 20+, a free Neon database, and a free Groq API key (optional — without it, scoring still works and openers are disabled).
npm install
cp .env.example .env.local # fill in GROQ_API_KEY, DATABASE_URL, DATABASE_URL_UNPOOLED
npm run db:migrate # creates the tables
npm run dev # http://localhost:3000
npm test # normalisation, extraction, scoring, opener and address-filter testsNo file? Click "Use the sample of 106 Dallas–Fort Worth contractors" on the home page.
npx vercel link
npx vercel env add GROQ_API_KEY production
npx vercel env add GROQ_MODEL production # optional, defaults to openai/gpt-oss-20b
npx vercel env add DATABASE_URL production
npx vercel --prodvercel.json pins the framework to Next.js, and .vercelignore keeps .env* files and local build output out of the upload. Run npm run db:migrate once against the production database before the first deploy.
app/
page.tsx upload + buy-box
sheet.tsx home-page "marked-up list" built from a real run
ui.tsx shared bits: tier badge, score bar, chips, buy-box form
theme-toggle.tsx light/dark switch (follows the device by default)
jobs/[id]/page.tsx ranked results, filters, exports
jobs/[id]/drawer.tsx evidence + opener for one lead
api/jobs/… create, read, re-rank, process, export
api/jobs/[id]/leads/[leadId]/opener AI opener (scoped to its job)
lib/
normalize.ts column mapping, cleaning, dedupe
enrich.ts website + domain signals
score.ts fit, readiness, revenue, tiers
jobs.ts storage + batch worker
groq.ts, opener.ts LLM client and prompt
ratelimit.ts per-visitor hourly limits
*.test.ts unit tests (node:test via tsx)
db/schema.sql jobs, leads, domain_cache, rate_limits
data/ Census revenue benchmarks + provenance
scripts/ sample-list builder, migration
public/sample-saasquatch-dfw.csv the sample list behind "Use the sample"
vercel.json, .vercelignore deploy config
- Company-specific revenue needs headcount. Small trade businesses rarely publish it (9% of the sample). Real SaaSquatch exports carry Apollo/Growjo employee counts, so far more rows get a company-specific range.
- Signals are public and indirect — they rank who to call first, they don't predict a sale.
- Next: state business-registry lookups (officers, entity age, active status) to reach owner-run businesses that data vendors miss; direct HubSpot API sync; scheduled re-checks to catch changes such as a site going stale or a "for sale" notice.