Ingested 2026-07-14 from spec-site-outreach-sop.vercel.app. Finds local businesses with NO website, auto-builds them a real site from a template, deploys it live free, emails the owner the link as the opener, tracks + follows up automatically. Foot-in-the-door offer, not a spam blast.
⚠️ HOW THIS MAPS TO UPRISE'S EXISTING INFRA (read before building ANYTHING)
Most of this pipeline already exists in Rio's stack. Do NOT rebuild from scratch — wire the missing pieces into what's live:
| SOP stage | Uprise equivalent (already built) | Gap to build |
|---|---|---|
| Lead scraper (Maps, no-website filter) | Scout/Apify pipeline + manual scrapes → Command D1 | Automate the "no website" filter + email-hunt step |
| CRM Google Sheet | Command D1 (uprise-command) — richer than a sheet |
Maybe add site-url + followup-count fields to leads |
| Site builder (template + placeholders) | The walk previews (8 live) + ~/uprise-adgap/gen.py pattern |
A per-niche HTML template + builder script for volume |
| Deploy (CF Pages) | Already the default host; <name>.pages.dev previews proven |
Batch deploy script |
| Email sender (Gmail SMTP, caps, mark-before-send) | The outbound rail (~/uprise-portal/agent/outbound_send.py): SMTP, 9-5 ET, cap 10/wk, dedupe, JSON queue |
Follow-up sequencing + followup-count cap (rail is one-shot today) |
| Daily runner + cron | launchd agents (rail fires 9:30/12:30/15:30) | A run_daily orchestrator if/when the full loop goes live |
Strategy fit note: spec sites = the old Lee/preview playbook, automated. Uprise's offer is now $1,500/mo growth engine — so the spec site is the OPENER, the pitch on reply is still Base (site included). No-website businesses are Tier-1 walk-door ICP (South End Plumbing = exactly this profile).
The pipeline (5 stages, once a day)
Scrape leads → Build site → Deploy live → Email owner → Log + follow up
Requirements
- Python 3.10+ (scraper, builder, sender) · Node + npm (deploy CLI)
- Dedicated Gmail account — never the personal/client-facing inbox
- Gmail App Password (Account → Security → 2-Step Verification → App Passwords)
- Google Sheet + service account as CRM (Uprise: use Command D1 instead)
- Free static host: Cloudflare Pages or Vercel
- Web-scraping API key (Firecrawl free tier to start)
- CAN-SPAM compliance confirmed BEFORE first send — not a fix-it-later item
Stage 1 — Scaffold
Claude Code prompt (verbatim from source):
Set up a Python project called spec-site-outreach with this structure:
- lead_scraper.py (finds businesses with no website in a given niche/city)
- site_builder.py (fills an HTML template with a business's info)
- deploy.py (pushes the built site to a free static host)
- sender.py (sends the outreach + follow-up emails)
- sequences.py (holds the email copy templates)
- run_daily.py (runs the whole pipeline once a day, end to end)
- templates/ (HTML site templates, one per niche style)
- sites/ (output folder for built sites, gitignored)
- config.json (API keys, sending account, daily limits — gitignored)
- requirements.txt
Add a .gitignore that excludes config.json, sites/, any *.token/*.pkl files,
and local logs.
Stage 2 — Lead scraper
Write lead_scraper.py using Playwright to search Google Maps for businesses
matching a --niche and --city argument. For each result:
- Skip it if it already has a website listed
- Pull business name, phone, address, and Google Maps profile URL
- Try to find a personal-domain email (gmail/yahoo/hotmail/outlook/icloud)
via a web search/scrape API — skip the lead entirely if no email is found
- Deduplicate against rows already in the tracking sheet by business name
- Write new leads as rows in the tracking sheet
Add a --headless flag and a --max flag to cap how many leads it pulls per run.
Tip: niches with LOW website adoption (handyman, cleaning, landscaping, pressure washing, small trades) yield far more usable leads per run.
Stage 3 — Tracking sheet (CRM)
Columns: Business Name · Owner Name (blank if unknown, never a placeholder) · Phone/Email/Address/City · Niche (drives template + copy) · Site URL (post-deploy) · Site Sent + Date (marked BEFORE the send fires) · Followup Count (hard cap, e.g. 5) · Status (Active/Replied/Dead).
Set up Google Sheets access using a service account (not user OAuth) via
gspread. Write a small sheet_client.py wrapper with read_rows(), append_row(),
and update_cell() helpers, with retry + exponential backoff around every
network call so a flaky connection doesn't crash a run mid-pipeline.
Stage 4 — Site builder
One clean responsive HTML template per niche style. Real site with business name, city, services, contact section — simple beats flashy.
Build a single responsive HTML template (templates/general.html) for a small
local-service business landing page: hero with business name + niche + city,
a services section, and a contact/call section. Use placeholders like
{{BUSINESS_NAME}}, {{CITY}}, {{NICHE_DISPLAY}}, {{SERVICE_1}}, {{SERVICE_2}},
{{SERVICE_3}}, {{PHONE_DISPLAY}}, {{PHONE_DIGITS}}, {{YEAR}}.
If phone digits are missing or too short, hide phone elements with JS and
point the call-to-action at the contact section instead — the site should
never render broken if a field is missing.
Then write site_builder.py that takes a row of business data, fills the
right template based on niche, and saves the output to sites/{slug}/.
Stage 5 — Deploy
Write deploy.py that takes a sites/{slug}/ directory and deploys it to
[Cloudflare Pages via wrangler CLI / Vercel CLI] — return the live URL on
success. Handle non-UTF8 CLI output safely on Windows, and treat a deploy
failure as skip-and-retry-next-run rather than a crash.
Stage 6 — Email sender + sequences
Short, plain, human. "You built them something real — say that, and give them the link."
Write sequences.py with an initial outreach email and up to 4 follow-up
variants (short, plain text, no salesy formatting). Every template must
include a real physical mailing address and a one-line opt-out
("reply STOP/unsubscribe and I'll take you off this list") — this is a
CAN-SPAM requirement, not optional copy.
Write sender.py that:
- Sends via the dedicated Gmail account (SMTP + app password, or Gmail API)
- Marks "Site Sent" = Y in the sheet BEFORE sending, not after
(so a crash mid-send can't cause a duplicate email on the next run)
- Spaces sends across a daily window (e.g. 9am-6pm) instead of firing
them all at once
- Enforces a hard daily send cap
- Logs every send locally to a JSONL file immediately, independent of the
sheet, so a crashed process still has a ground-truth record of what
actually went out
- Increments Followup Count on each follow-up and marks Status = Dead once
a lead hits the cap, skipping it on all future runs
Stage 7 — Daily runner + scheduling
Write run_daily.py that in one run: tops up new leads if the pipeline is
low, sends follow-ups to qualifying leads, then sends new outreach up to
the daily cap. Add --dry-run (must not send anything for real), --status
(prints pipeline counts only), and --force (bypass the already-ran-today
guard). Wrap the whole run in retry/backoff for network calls.
Schedule daily (cron/launchd). Start conservative: 10-15 sends/day per account while dialing in copy + deliverability.
Compliance — non-negotiable
CAN-SPAM on EVERY commercial email regardless of volume:
- Real physical postal address
- Clear, working opt-out, honored promptly
- Non-deceptive subject line + accurate sender info
The practical risk isn't a lawsuit — it's the sending account getting reported and suspended, killing the whole system overnight. Bake into templates day one.
Deliverability: dedicated sending account always · warm up a new Gmail gradually · modest daily volume · hard follow-up cap per lead (unanswered follow-up spam = complaint trigger) · check local/state law beyond CAN-SPAM.
Lessons learned (from the source's debugging)
- Mark before you send, not after — crash between send and mark = duplicate email next run.
- Dry-run means dry-run everywhere — a partial dry-run that still fires real follow-ups is a nasty surprise.
- Cap follow-ups by hard COUNT, not time — time-based re-qualify rules silently re-trigger sends.
- Local crash-safe JSONL send log beats trusting the sheet — ground truth survives a dead process.
- Windows + emoji = UnicodeEncodeError crashes — force UTF-8 stdout or decode with
errors="replace". - Wrap every external call in retry-with-backoff — transient DNS blips shouldn't kill a run.
- Build incrementally — one stage working end to end (even manually) before automating the next; test each stage on a handful of real leads before daily scheduling.
Related: _systems (the rail, Command), uprise-teardown-machine, uprise-cold-email-scale (the parked uprisegrowthteam.com sending domain fits this), apm-walk-route (manual spec-site precedent), 02-icp