LeadScout discovers local businesses from Google Maps for a keyword + location search, extracts structured business details, classifies whether each business has a website, and lets you filter and export leads as CSV.
Phase 1's flagship use case: find businesses in a target market that do not have a website.
Keyword: roofing contractor
Location: Texas, USA
Filter: Businesses without websites
This is Phase 1 of a planned local-business intelligence / lead-generation
platform — see docs/roadmap.md for what's deliberately
not built yet (website analysis, AI lead scoring, CRM sync).
Chrome Extension (MV3, React, TypeScript)
│ REST API
▼
FastAPI Backend ──► Scraping Engine (Playwright) ──► SQLite ──► CSV
- The extension is a thin REST client. All scraping happens server-side.
- Jobs are asynchronous: the extension creates a job, gets a
job_idimmediately, and polls for status/results — it never blocks on a scrape. - Website presence is a tri-state (
PRESENT/ABSENT/UNKNOWN), not a boolean — a failed extraction is never silently treated as "no website."
Full design: docs/architecture.md and
docs/scraper-design.md. FOSS projects studied for
architectural patterns (not copied — clean-room implementation): see
docs/foss-references.md.
- Keyword + location search over Google Maps
- Structured extraction: name, category, address, phone, website, rating, review count, coordinates, place identifier, business status
- Tri-state website detection with a documented, conservative rule set
ALL/NO_WEBSITEfiltering (architecture supports adding more filters without touching the UI — seeapp/services/filter_service.py)- Deterministic deduplication (place ID → canonical Maps URL → normalized name+address+phone), so re-running a search doesn't create duplicate leads
- Bounded retries with exponential backoff + jitter, classified errors, configurable concurrency
- CSV export with human-readable column headers
- Structured (JSON line) logging of the scrape lifecycle
extension/ Chrome extension (MV3, React, TypeScript, Vite)
backend/ FastAPI + Playwright backend
docs/ Architecture, scraper design, API reference, FOSS references, roadmap
See docs/development.md for full setup (backend,
Playwright browser install, extension build, load-unpacked instructions,
testing, configuration).
Quick start:
# Backend
cd backend
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
python -m playwright install chromium --with-deps
cp ../.env.example .env
uvicorn app.main:app --reload --port 8000
# Extension (separate terminal)
cd extension
npm install
npm run build
# chrome://extensions → Developer mode → Load unpacked → extension/distcd backend && pytest
cd extension && npm run lintUnit tests never touch a browser or network. Integration tests exercise the
FastAPI app with the scraper engine mocked out. See
docs/development.md for the opt-in live Google Maps
test.
See .env.example for every backend environment variable
(concurrency limits, retry/backoff, timeouts, database URL).
GET /api/jobs/{job_id}/export.csv — see docs/api.md for
the full API reference and column list.
backend/ and extension/ version independently via mise +
semantic-release, driven by Conventional Commits.
See docs/RELEASE.md for the two release streams, tag
formats, and the release:drift / release:dry / release:full commands.
- Google Maps markup is undocumented and can change. All selectors are
centralized in
backend/app/scraper/google_maps/selectors.py; when Maps changes, that's the one file that needs updating. - English (US) UI assumed. Text-based signals ("Permanently closed",
address/phone
aria-labelprefixes) are matched against the English UI and will need adjustment for other Maps locales. rating/review_countare best-effort. Google Maps hydrates its review summary slightly after the core panel fields; these are leftnullrather than guessed when not captured in time.website_statusdoes not have this issue — it has a strict, documented tri-state rule set.- Narrow single-result searches (a keyword+location combination that matches essentially one business) render that business's detail panel directly instead of a results list. LeadScout handles this, but the Google-assigned place identifier and coordinates aren't recoverable from the URL in that case, so deduplication falls back to the name+address+phone composite key for that business.
- No street/city/state/zip address parsing — only whitespace normalization is applied to addresses.
- Single-machine, single-user Phase 1. Jobs run as in-process asyncio
tasks (see
docs/architecture.md§6 for the documented path to out-of-process workers); there is no authentication or multi-tenancy; backend CORS is permissive, appropriate for a local tool, not a hosted multi-user service. - No extension icon — Chrome shows its default icon for the unpacked extension.
LeadScout automates browsing publicly viewable Google Maps listings via a standard browser (Playwright/Chromium) at a configurable, bounded concurrency. It does not attempt to bypass CAPTCHAs, authentication, or any other access control, and it is not designed to. You are responsible for complying with applicable laws and the terms governing any service you use LeadScout against, and for the concurrency/rate settings you configure.
MIT — see LICENSE.