Self-hosted Playwright scraper that discovers businesses on Google Maps, checkpoints to JSONL per category, optionally extracts emails from websites, and exports a CSV of the fields you enable.
No Monid, Apify, Bright Data, or other paid scrape APIs.
Quick start
npm i && npx playwright install chromium
# edit config/scraper.json — toggle fields + categories
npm run scrape -- --states all
npm run enrich
npm run export
Default category is marketing-agency (enabled in config). Pilot scrape uses CA + NY unless you pass --states all or a custom list.
Configuring output fields
Edit config/scraper.json → fields. Each key is true / false.
| Field | Default | Notes |
|---|---|---|
name |
true |
Business name |
email |
true |
From website enrich; export keeps rows with email only when this is true |
phone |
false |
From Maps place details |
address |
false |
From Maps |
website |
false |
From Maps |
category |
true |
Harvest category slug |
maps_url |
false |
Google Maps URL |
place_id |
false |
Durable place id |
city |
false |
Parsed when available |
state |
false |
Parsed when available |
country |
false |
Country code from harvest |
Behavior
- CSV columns = only fields set to
true, in the order above. - Email enrich (
npm run enrich) runs only whenfields.emailistrue; otherwise it skips. - Maps scrape still stores a full place record in
places.jsonlfor resume/debugging; export and docs emphasize the toggled fields.
Example (defaults):
{
"fields": {
"name": true,
"email": true,
"phone": false,
"address": false,
"website": false,
"category": true,
"maps_url": false,
"place_id": false,
"city": false,
"state": false,
"country": false
}
}
Configuring search categories
Same file → search.categories[]:
| Property | Meaning |
|---|---|
slug |
Data folder under data/<country>/<slug>/ |
enabled |
Pipeline / default runs only enabled categories when --category is omitted |
label |
Human label on logs / records |
queries |
Google Maps search phrases (tried in order per geo tile) |
Defaults shipped
- Enabled:
marketing-agency— queriesmarketing agency,advertising agency - Disabled examples:
book-publisherplus writing/audio categories (writers-association,writing-workshop,self-publishing-service,book-publishing-consultant,literary-agency,audiobook-production,podcast-production,narrator-voiceover)
Flip enabled to true or add your own category objects. Starter example:
{
"slug": "marketing-agency",
"enabled": true,
"label": "marketing agency",
"queries": ["marketing agency", "advertising agency"]
}
CLI reference
# Full US scrape for the default (first enabled) category
npm run scrape -- --states all
# Or an explicit category
npm run scrape -- --country us --category marketing-agency --states all
# Standalone scrape entry (preferred for long runs)
npx tsx src/geo_harvest.ts --country us --category marketing-agency --states all
# Enrich websites → emails (no-op if fields.email is false)
npm run enrich -- --country us --category marketing-agency
# Write leads.csv (enabled fields only)
npm run export -- --country us --category marketing-agency
# Full pipeline over enabled categories
python3 scripts/run_category_pipeline.py
# Or a subset (ignores enabled flags):
python3 scripts/run_category_pipeline.py marketing-agency book-publisher
Options
| Flag | Commands | Default | Notes |
|---|---|---|---|
--country |
all | us |
Loads src/countries/<code>.ts |
--category |
all | first enabled in config | Slug from config/scraper.json |
--states |
scrape | CA,NY |
Comma-separated codes, or all |
--max-places |
scrape | 50000 |
Global unique place cap per category |
--max-per-tile |
scrape | 120 |
Soft cap per geo tile per query |
--headed |
scrape | off | Visible browser (debug / CAPTCHA) |
--dry-run |
scrape | off | First tile, 5 places, no writes |
--concurrency |
enrich | 4 |
Parallel website fetches |
--timeout |
enrich | 10000 |
Per-request timeout (ms) |
Data layout
data/<country>/<category-slug>/
places.jsonl # append-only checkpoint (scrape + enrich)
progress.json # completed tiles + CAPTCHA pause flag
leads.csv # export (enabled fields only)
data/ is gitignored. Deduplicate within a category by place_id. Cross-category overlap is OK.
CAPTCHA / resume
- Completed tiles are recorded in
progress.jsonand skipped on re-run. - Existing
place_ids inplaces.jsonlare skipped (dedupe). - Multiple Maps queries for a category run per tile before the tile is marked complete.
- CAPTCHA detection only treats real Google
/sorry/interstitial pathnames. Consent pages are dismissed, not treated as hard blocks. - If Google shows a CAPTCHA, the scraper pauses, logs the tile, and sets
pausedForCaptchainprogress.json. - Resolve manually with
--headedif headless retries fail.
Disclaimer
This tool is for educational and legitimate research use. Respect Google’s Terms of Service, applicable laws, and website robots/terms when enriching emails. You are responsible for how you use scraped data (including CAN-SPAM / GDPR / marketing consent). Scraping at scale may trigger rate limits or blocks; be a good citizen.
Comments