An AI lead-generation engine for freelancers. Hand it a niche + a city; it queries Google Places, crawls each lead's website with Playwright, scores them across 23 factors, drafts personalised outreach with Gemini, and streams everything into a Next.js dashboard backed by Supabase real-time. ~7k LOC across a CLI scraper + dashboard. Built for the freelance prospecting workflow I wished existed.
niche + city
│
▼
┌─────────────────────────────┐
│ Google Places (new API) │ ──┐
│ → 60 raw candidates │ │ failover between
└──────────────┬──────────────┘ │ 2 API keys
│ │
▼ │
┌─────────────────────────────┐ │
│ Playwright crawler │ │
│ → homepage + about + contact│ │
│ resource-blocked, 2× retry │ │
└──────────────┬──────────────┘ │
│ │
▼ │
┌─────────────────────────────┐ │
│ PageSpeed Insights API │ ◀──┘
│ → perf · a11y · SEO · BP │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ 23-factor opportunity score │ no-website leads get
│ (web · seo · social · cwv) │ max points on web factors
└──────────────┬──────────────┘ (they need the most help)
│
▼
┌─────────────────────────────┐
│ Gemini 2.5 Flash — drafts │
│ per-channel (email · WA · │
│ DM) personalised outreach │
└──────────────┬──────────────┘
│ supabase realtime
▼
┌─────────────────────────────┐
│ Next.js dashboard │
│ Kanban · KPIs · Analytics · │
│ Insights · Lead Detail │
└─────────────────────────────┘
Cold outreach for freelance work usually breaks down at the same two places: finding the right businesses (the ones who actually need what you sell, not random names from a directory) and writing the message (anything generic gets ignored). LeadSniper attacks both. The 23-factor scoring surfaces leads where the gap is visible — slow site, no SSL, missing meta, no social, dated stack — and the AI drafter consumes that same signal so the message you send actually references what's wrong instead of saying "I'd love to chat."
The scraper runs locally (Playwright + browser, your IP, your API keys). The dashboard runs anywhere (Vercel free tier). Supabase wires them together with real-time so you watch leads scoring live as the scraper crunches.
| 23-factor scoring | Web presence (has site, HTTPS, mobile-responsive), SEO (title, description, schema, sitemap), social (FB/IG/LinkedIn/X presence), Core Web Vitals (LCP, FID, CLS), credibility (Google rating, review count), engagement (last update, contact methods). Each factor is a 0–10 sub-score; weighted into a 0–100 opportunity score. |
| No-website logic | A lead with no website gets the maximum score on every web factor — they're the customer who needs you most. Counter-intuitive but correct: a perfect 100/100 site doesn't need a developer. |
| Real-time pipeline | The scraper writes to Supabase as each lead is processed; the dashboard subscribes via postgres_changes and streams rows in. No polling, no refresh button. |
| Multi-channel drafts | Gemini drafts a cold_email, a whatsapp_dm, and a linkedin_dm per lead. Tone + service-type modifiers (gentle / direct / playful · website-build / SEO / ads). Regenerate any channel with custom prompt overrides. |
| Failover Google Places keys | Supply two GOOGLE_PLACES_API_KEY_1/2; the scraper rotates on 429 / quota errors and logs failed runs back to Supabase so the dashboard shows the actual failure mode. |
| PageSpeed status-checked | Calls pagespeedonline/v5/runPagespeed per lead with a 45s timeout. HTTP status is checked before JSON parse (avoids the silent crash on 4xx/5xx). |
| Watch mode | LeadSniper.command (double-click) → scraper polls ms_search_requests every 30s. Click "New search" in the dashboard, it dispatches a request, your local machine processes it. The dashboard never needs to ship Playwright. |
| Resilient watch-mode polling | Transient network errors (TypeError, AbortError, ECONNRESET, ETIMEDOUT, ENOTFOUND) are silently retried; only real errors hit the log. |
| Local-first + cloud-native | Your IP, your keys, your data — Playwright runs on your machine, never in a hosted browser farm. Supabase holds the persistent state. |
Web presence ━━━━━━━━━━ 4 factors (has site, HTTPS, mobile, modern stack)
SEO basics ━━━━━━━━━━ 4 factors (title, description, schema, sitemap)
Social proof ━━━━━━━━━━ 4 factors (Google rating, review count, response rate, recency)
Social media presence ━━━━━━━━━━ 4 factors (FB, IG, LinkedIn, X)
Core Web Vitals ━━━━━━━━━━ 3 factors (LCP, FID, CLS)
Contact / engagement ━━━━━━━━━━ 4 factors (email exposed, phone, contact form, last update)
───────────
23 factors → 0–100 opportunity score
See scraper/src/scorer.js for the actual weighting.
git clone https://github.com/hatimhtm/LeadSniper.git
cd LeadSniper
cp .env.example .env # fill in Supabase URL + anon key (required)
# add Google Places + Gemini keys, OR set them
# later in the dashboard Settings page
# 1) Supabase schema
# Open your Supabase project's SQL editor, paste supabase/schema.sql, run.
# 2) Dashboard
cd dashboard
npm install
npm run dev # http://localhost:3000
cd ..
# 3) Scraper (separate terminal — keep it running)
cd scraper
npm install
npx playwright install chromium
node src/index.js watch # idles until you trigger a search from the dashboardOr double-click LeadSniper.command (macOS) to launch the scraper in watch mode.
LeadSniper/
├── dashboard/ Next.js 14 app
│ ├── src/
│ │ ├── app/dashboard/ overview · search · snipe · leads · pipeline ·
│ │ │ analytics · insights · settings · lead/[id]
│ │ ├── components/
│ │ │ ├── dashboard/ KPICard · LeadCard · LeadDetail
│ │ │ ├── ui/ ScoreGauge · SlideDrawer · LeadHoverCard · …
│ │ │ └── layout/ Sidebar · Navbar
│ │ ├── lib/ supabase client · hooks · themes · utils · settings
│ │ ├── types/ Lead · Search · LeadStatus · SearchPreset · …
│ │ └── app/api/regenerate/ Gemini regenerate endpoint
│ └── package.json
├── scraper/
│ ├── src/
│ │ ├── index.js commander CLI · 'snipe' + 'watch' + 'rescore' commands
│ │ ├── places.js Google Places API + failover key rotation
│ │ ├── crawler.js Playwright homepage + about + contact crawl
│ │ ├── analyzer.js PageSpeed Insights + tech-stack detection
│ │ ├── scorer.js 23-factor scoring
│ │ ├── ai-drafter.js Gemini per-channel message drafting
│ │ └── config.js ENV_MAP — .env first, then ms_settings fallback
│ └── package.json
├── supabase/
│ └── schema.sql ms_leads · ms_searches · ms_search_requests ·
│ ms_settings · ms_contact_logs (+ RLS, indexes)
├── LeadSniper.command macOS double-click launcher (watch mode)
├── .env.example every required var, with comments
└── .github/workflows/ci.yml builds dashboard + lints scraper
- No secrets committed.
.envis gitignored; the public.env.exampleis the only env file in version control. - API keys never leave your machine. Google Places and Gemini are called from the local scraper, not from the dashboard. The dashboard only reads/writes Supabase.
- Anon key only. Supabase URL + anon key are in the dashboard env; service-role key isn't used anywhere. RLS policies in
schema.sqlkeep the data scoped to the authenticated user. - Polite scraping. Configurable
SCRAPER_DELAY_MIN/MAXbetween Google Places calls, Playwright resource-blocking (no images/fonts), 2-retry with backoff, 30s page timeout.
# One-shot scrape
node scraper/src/index.js "dental clinic" "Casablanca" --max 30
# Skip the expensive parts for a fast price-discovery pass
node scraper/src/index.js "real estate" "Paris" --skip-crawl --skip-ai
# Re-score existing leads in DB (after tweaking scorer.js)
node scraper/src/index.js rescore
# Watch mode — runs forever, polls the dashboard for queued searches
node scraper/src/index.js watch --interval 30The Places pipeline above finds local businesses. Profile mode finds funded,
founder-led companies matching a buyer profile (ICP), with verified people-facts.
It exists because keyword-searching Google Places for "AI healthcare startup"
returns storefronts and lookalikes, and asking an LLM to recall founders from
memory ships wrong CEOs and [Founder Name] placeholders. Profile mode does
neither: every fact must come from a search-grounded call, and every lead must
survive hard QA gates before it can reach the export.
buyer profile (JSON: ICP + voice + exclusions)
│
▼
discover ──► verify ──► refute ──► personalize ──► QA gates ──► styled xlsx + csv
(grounded) (grounded, (grounded, (copy only, (reject on (client-ready,
current- tries to voice locked ANY defect) Business sheet)
role check) DISPROVE) to template)
- Grounded or rejected — responses without search-grounding metadata are discarded.
- Refute pass — a second adversarial call per lead hunts for acquisitions, CEO departures, and shutdowns. (In testing it caught Workday/Sana, Handshake/Uplimit, Commure/Augmedix.)
- Site-intel insight — fetches each lead's live homepage and extracts not just what they're announcing but what that means they need next (a fresh model launch usually means positioning, site, and press have to catch up) — and the outreach references it.
- SMTP mailbox probe — beyond MX records, the QA layer does an RCPT-TO handshake against the domain's mail server: explicit rejections kill the lead, accept-all domains are flagged in the audit trail. (Inconclusive probes — port 25 blocked — never fail a lead on their own.)
- QA gates (
profile/qa.js) — placeholders, generic inboxes (info@…), emails that don't match the contact's name or the company's domain, dead-MX domains, SMTP-refused mailboxes, funding without numbers, non-buyer titles, malformed LinkedIn URLs, duplicates, and previously-delivered companies are all hard rejects with logged reasons. - Deterministic voice — greeting/intro/sector-line/ask/signoff come verbatim from the profile JSON; the model only writes the personalized fragments, which are QA'd.
- Signal scoring — every lead is ranked Hot / Warm / Steady from its evidence (live site announcement, raise recency, server-confirmed mailbox, war chest) and the sheet ships sorted hottest-first, with a "What's happening now" column making the intent visible.
- Client-ready file — company names hyperlink to their homepages, a Funding column
shows round + amount at a glance, the profile's brand icon is embedded in the
title block, and a Report sheet summarizes sector mix, signal bands, and
verification stats for the end client. A
.audit.jsonships next to every export: per-lead verification evidence (sources, dates, SMTP status, site news) so results are reviewable without re-research. - Checkpointed — reruns resume the same day's progress; delivered companies are registered per-profile and auto-excluded from future runs.
cd scraper
npm run profile -- ../profiles/example.json --count 25
# --out file.xlsx --no-refute --no-mx --no-smtp --no-intel --discover-batch 20
npm run test:profile # offline QA-gate + exporter tests (no API key needed)Needs only GEMINI_API_KEY (grounding via Google Search tool). Output lands on
the Desktop as <profile>-<date>-leads.xlsx + .csv; rejects and their reasons
are kept in runs/<profile>/checkpoint-<date>.json.
.env.examplenow lists every required var (Google Places × 2, Gemini, USER profile, scraper tuning) — was Supabase-only before.scraper/src/config.js: extractedENV_MAPconstant (was duplicated betweengetConfig+getAllConfig).scraper/src/analyzer.js: PageSpeed response now status-checked beforeresponse.json()(was a silent SyntaxError on 4xx/5xx).scraper/src/index.jswatch loop: transient network errors broadened beyond the brittle'fetch'substring check.dashboard/src/lib/hooks.ts: silent error catches now log viaconsole.errorso they're at least debuggable.dashboard/src/components/dashboard/LeadDetail.tsx: regenerate failures surface as inline rose-tinted error messages with auto-clear (was silentcatch {}).dashboard/src/components/dashboard/LeadCard.tsx:hover:scale-110(imperceptible on 14px icons) →hover:opacity-70(consistent muted-feedback pattern).- New brutalist hero banner SVGs + README + CI workflow.
All Rights Reserved — Source-Visible.
This is not an open-source repository. The code is on GitHub for the limited purpose of letting you read it — evaluate the engineering, study the 23-factor scoring, see how Playwright + Gemini + Supabase real-time fit together. That's it.
Not allowed: running it, deploying it, copying it into another project, redistributing it, modifying it, sublicensing it, or commercially exploiting it — even for free. "Free" doesn't equal "permitted."
If you want a commercial licence or a custom LeadSniper-style build for your agency, get in touch.
/// OPEN FOR NEW WORK /// CONTRACT & FREELANCE /// REMOTE WORLDWIDE ///