Finds local businesses without a decent website, builds each one a demo from its own real data, and reaches out to them.
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"]
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:3000Open 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, runnpm run db:backfillonce: it computes scores for leads already saved and strips the Google key out of photo URLs written by earlier versions.
- 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.
- 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?"
- 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. - 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.
- Listen — opens, bounces, spam reports and replies all feed back into the system. A "no thanks" silences that contact on every channel, permanently.
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/cronOnce 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 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.
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-Postheaders (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
/privacyand 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.
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
| 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.
- 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.
MIT.