Skip to content

About

Lead Machine — finds local businesses on Google, builds a personalised demo site for each and reaches out by email and WhatsApp

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Lead Machine

Finds local businesses without a decent website, builds each one a demo from its own real data, and reaches out to them.

CI License MIT Node ≥ 20 Next.js 15 TypeScript Prisma + SQLite


🇮🇹 Leggi in italiano

Type hairdresser and Catania. The program searches Google Maps, checks whether each business already has a decent website, pulls its phone number and email, scores how good a lead it is, and builds a demo page using that shop's real photos, hours and reviews. Then it sends the email, and a few days later the WhatsApp follow-up — stopping on its own for anyone who replies, and forever for anyone who says no.

No door-to-door, no purchased lists, no invented data.

flowchart LR
  A["1 · Search<br/>type + place"] --> B["2 · Qualify<br/>site, email, score"]
  B --> C["3 · Demo<br/>six themes, real data"]
  C --> D["4 · Contact<br/>email → WhatsApp"]
  D --> E["5 · Listen<br/>opt-out = contact silenced"]
Loading

Getting started

You need Node.js 20 or later. Three commands:

npm install          # installs everything (also downloads Chromium for scraping)
npm run db:push      # creates the SQLite database
npm run dev          # http://localhost:3000

Open http://localhost:3000, type a business type and a place, press Start search. It works even with no API keys configured at all: every piece that isn't set up degrades predictably, and the Settings page always tells you what's actually active and what isn't.

Upgrading an existing install? After npm run db:push, run npm run db:backfill once: it computes scores for leads already saved and strips the Google key out of photo URLs written by earlier versions.


The pipeline, step by step

  1. Search — business type plus city, region or country. With a Places key you also get hours, reviews and photos; without one, the app falls back to direct Maps scraping.
  2. Qualify — every business gets a score from 0 to 100 (missing or poor website, reachability, reviews, reputation), and the website verdict comes with plain-language reasons — the arguments to use when someone asks "why should I redo it?"
  3. Demo — a wizard picks theme, sections, title and color; the page is generated with that business's real data and published at /demo/<slug>. Six themes, each with its own palette, typography and scroll behavior.
  4. Contact — email to anyone with an address, a WhatsApp reminder after N days to anyone who hasn't replied, a suggested phone call when even that falls flat. The run happens in the background: you can close the browser, and you can stop it halfway through.
  5. Listen — opens, bounces, spam reports and replies all feed back into the system. A "no thanks" silences that contact on every channel, permanently.

Configuration

All variables live in .env (start from .env.example) and are all optional.

Variable Used for Without it
GOOGLE_PLACES_API_KEY fast, complete search: hours, reviews, photos falls back to scraping Google Maps with Puppeteer, slower and more fragile
RESEND_API_KEY + EMAIL_FROM sending real email emails are only written to the console (dry run)
RESEND_WEBHOOK_SECRET knowing who opens, bounces or reports you as spam those events never arrive: you'd keep writing to dead addresses
ANTHROPIC_API_KEY AI-written demo copy default copy, different per category
CRON_SECRET letting sends and follow-ups run on their own they only run when you press the button
APP_PASSWORD protecting the app with a password no protection: anyone who reaches the address gets in
APP_URL the links inside emails and demos assumes http://localhost:3000
Google Places — the recommended key

Create a key at console.cloud.google.com and enable Places API (New). The monthly free credits comfortably cover normal use.

Photos are never written with the key embedded: demos point at /api/photo, which adds it server-side. A demo is a public page, and a key in the source is a key lost.

Resend — for actually sending, and for knowing how it went

Sign up at resend.com, create an API key and verify the sending domain. The free plan covers 100 emails a day.

Then create a webhook pointing at <APP_URL>/api/webhooks/resend for the opened, bounced and complained events, and put the secret in RESEND_WEBHOOK_SECRET. A permanent bounce or a spam report silences the contact on its own — it's the difference between a domain that delivers and one that ends up blacklisted.

Automatic sends — the follow-up that runs without you
curl -X POST -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron

Once or twice a day, via cron or Windows Task Scheduler. Each pass picks up searches left hanging, purges expired data, and runs the outreach cycle while respecting the daily limits.

Password — once the app leaves localhost

Set APP_PASSWORD and everything that shouldn't be public asks for a login. Only the pages that absolutely have to stay reachable without authentication remain open: the customer's demo, its photos, unsubscribe, the privacy notice, and the two automated entry points (webhook and cron, each protected by its own secret).


WhatsApp

WhatsApp page → Connect → scan the QR code with the app (Linked Devices).

This uses the unofficial method (whatsapp-web.js), so two practical warnings: the computer must stay on while sends are running, and daily limits must be respected. Between one message and the next, the system waits 30–90 seconds on its own — the only real defense against the number being blocked.

Incoming messages are read. A refusal silences the contact on email too, and anyone who replies with anything at all stops receiving the reminder.


Privacy and compliance

The data is public, but it's still personal data whenever there's a person behind a business. The program behaves accordingly — and this part isn't optional or something you can turn off:

  • One-click unsubscribe, with a cryptographically signed link and List-Unsubscribe / List-Unsubscribe-Post headers (RFC 8058), the ones Gmail and Outlook have required since 2024. The link only acts on explicit confirmation: an automated mail scan can't accidentally unsubscribe anyone, and no one can silence someone else's address.
  • Privacy notice published at /privacy and linked at the bottom of every message, as required by GDPR Art. 14 when the data subject didn't provide the data themselves.
  • Permanent opt-out, valid across every channel: anyone who asks to be left alone is never contacted again, even if manually re-approved later.
  • One contact, one message: the same business found in two different searches receives only one message.
  • Automatic deletion of contacts never reached, after the configured number of days.
  • A log of every send, open, bounce and reply, viewable on the lead's own page.

Fill in the contact name and email in Settings: they end up in the privacy notice, and they're what makes the whole thing traceable back to a real person. For ongoing professional use, sort out your own VAT registration and privacy notice.


Project structure

src/
├─ app/
│  ├─ page.tsx                  search and campaign status
│  ├─ leads/                    lead list, detail page, demo wizard
│  ├─ privacy/                  public privacy notice
│  ├─ demo/[slug]/              the page the customer sees
│  └─ api/
│     ├─ campaigns/ leads/      dashboard data and actions
│     ├─ outreach/run           start, status and stop of the send cycle
│     ├─ photo/                 photo proxy: the key stays server-side
│     ├─ unsubscribe/           signed unsubscribe (GET confirms, POST acts)
│     ├─ webhooks/resend/       opens, bounces, spam reports
│     └─ cron/                  automated entry point, secret-protected
├─ lib/
│  ├─ outreachPlan.ts           who to contact and who not to · pure function
│  ├─ leadScore.ts              how good a lead is worth · pure function
│  ├─ websiteCheck.ts           is the site poor? and why · pure function
│  ├─ replies.ts                refusal or interest · pure function
│  ├─ photos.ts                 no keys in public pages · pure function
│  ├─ signing.ts                signing links and sessions · pure function
│  ├─ demoGenerator.ts          the six themes, full HTML
│  ├─ outreach.ts  jobs.ts      background send execution
│  ├─ inbound.ts   events.ts    incoming replies and the log
│  ├─ places.ts    mapsScraper.ts   emailScraper.ts   data collection
│  └─ whatsapp.ts  email.ts     the two channels
└─ middleware.ts                password protection, when configured

Development

Command What it does
npm run dev development server on :3000
npm test 97 tests, no server and no database required
npm run build production build
npm run db:push syncs the database to the Prisma schema
npm run db:backfill fixes up data saved by earlier versions

The decisions that are expensive to get wrong — who to contact, when to stop, how to judge a website, how to sign a link — live in pure functions, with no database and no network, each with its own tests. The rest of the code just runs them. That's why npm test finishes in about half a second and still covers the parts that actually matter.


Known limitations

  • Places API: roughly 60 results per query. Covering a large city needs several searches with different terms (pizzeria, trattoria, ristorante…).
  • Maps scraping: depends on Google's layout, and can slow down or skip the occasional listing. It's far more reliable with a Places key.
  • Email: found by reading the business's own website. Businesses without a website can only be reached on WhatsApp.
  • Needs an always-on process: SQLite, Puppeteer, WhatsApp and the background send cycle all need a machine that doesn't shut down between requests. It isn't designed for serverless.
  • No sample data: whatever you see in the dashboard was collected right then.

License

MIT.

About

Lead Machine — finds local businesses on Google, builds a personalised demo site for each and reaches out by email and WhatsApp

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages