| name | google-maps-list-builder |
| description | Scrape Google Maps for local businesses by category and location, output CSV ready for cold email enrichment. Best for SMB campaigns targeting restaurants, clinics, gyms, salons, contractors, etc. Uses RapidAPI Maps Data API. Output feeds directly into /blitz-list-builder (to find owner contacts) or /email-waterfall (if you have names already). |
Google Maps List Builder
A self-contained tool for scraping business listings from Google Maps. Give it a search query (e.g., "pizza restaurant") and a location (zip code, city, or coordinates), and it returns structured data for every matching business — written to CSV.
How this fits in the cold email flow
Google Maps gives you COMPANIES (name, domain, phone, address, ratings). It does NOT give you PEOPLE. To run cold email:
- Run this skill → CSV of businesses with
company_domain
- Run
/icp-prompt-builder on a sample of 50 — tune a qualification prompt to filter out bad fits before paying for downstream enrichment
- Run
/blitz-list-builder with the filtered CSV → adds owners/managers to each business
- Run
/email-waterfall → fills in missing emails
- Run
/cold-email-starter-kit's smartlead-add-leads.ts → upload to Smartlead
This skill is only the first step.
Required step: Qualify with /icp-prompt-builder
This is a required step. Do not skip it.
Google Maps will happily return 10,000 "pizza restaurants in Illinois," but most of those won't match your actual ICP (maybe you only want 50-200 seat operators, or only ones without online ordering). Before spending on enrichment, sample ~50 results and run /icp-prompt-builder:
- Evaluate 10 results with an AI qualification prompt
- You flag "this one should be NO, they're a chain franchise"
- Refine, run next 10
- Stop when 2 rounds show no corrections
- Apply tuned prompt to filter the rest of the scrape
Why required: downstream owner-finding (via /blitz-list-builder) and email waterfall cost $0.10-$0.30 per contact. On a 10,000-business scrape, that's $1K-$3K. Qualifying upfront saves 50-80% of that spend on average.
What You Need Before Starting
- Node.js 18+ and npm installed
- A RapidAPI account (free tier available) with a subscription to the Maps Data API:
That's it. No Google Cloud account, no OAuth, no billing setup beyond RapidAPI.
Project Setup
Create a new project directory and initialize it:
mkdir google-maps-scraper && cd google-maps-scraper
npm init -y
npm install typescript bottleneck
npm install -D @types/node tsx
Add to package.json scripts:
{
"scripts": {
"scrape": "tsx src/index.ts"
}
}
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}
Set your API key as an environment variable:
export RAPIDAPI_KEY=your_key_here
Or create a .env file (add .env to .gitignore):
RAPIDAPI_KEY=your_key_here
File Structure
google-maps-scraper/
data/
us-zip-codes.csv # 42,734 US zip codes with city, state, lat/lng, population
src/
index.ts # CLI entry point
client.ts # RapidAPI Maps Data client with rate limiting
types.ts # TypeScript interfaces
csv.ts # CSV export
zips.ts # Zip code loader (filter by state, city, population)
Bundled Zip Code Database
The repo includes data/us-zip-codes.csv — a complete US zip code reference with 42,734 entries. Columns:
zip,primary_city,state,timezone,area_codes,world_region,country,latitude,longitude,irs_estimated_population
This lets you scrape an entire state or metro area without manually listing zip codes. The src/zips.ts loader provides filtering by state, city, and minimum population.
Core Files
src/types.ts
export interface SearchParams {
query: string;
lat?: number;
lng?: number;
limit?: number;
zoom?: number;
country?: string;
}
export interface Place {
place_id: string;
name: string;
address: string;
lat: number;
lng: number;
rating?: number;
reviews_count?: number;
phone?: string;
website?: string;
types?: string[];
category?: string;
}
export interface ScrapeResult {
query: string;
location: ;
: ;
: ;
: [];
: ;
}
src/zips.ts
Loads and filters the bundled zip code CSV. Lets you target by state, city name, or minimum population.
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
export interface ZipEntry {
zip: string;
city: string;
state: string;
lat: number;
lng: number;
population: number;
}
let cache: ZipEntry[] | null = null;
function loadAll(): ZipEntry[] {
if (cache) return cache;
const __dirname = dirname(fileURLToPath(import.meta.url));
const csvPath = join(__dirname, '..', 'data', 'us-zip-codes.csv');
const raw = readFileSync(csvPath, 'utf-8');
const lines = raw.trim().split('\n').();
cache = lines.( {
: [] = [];
current = ;
inQuotes = ;
( ch line) {
(ch === ) { inQuotes = !inQuotes; ; }
(ch === && !inQuotes) { parts.(current); current = ; ; }
current += ch;
}
parts.(current);
{
: parts[]?.(, ) || ,
: parts[] || ,
: parts[] || ,
: (parts[]) || ,
: (parts[]) || ,
: (parts[]) || ,
};
}).( z.. === );
cache;
}
(): [] {
().( z..() === stateCode.());
}
(): [] {
cityLower = city.();
().( {
cityMatch = z..().(cityLower);
stateMatch = !state || z..() === state.();
cityMatch && stateMatch;
});
}
(): [] {
().( {
popMatch = z. >= minPop;
stateMatch = !state || z..() === state.();
popMatch && stateMatch;
});
}
(): [] {
();
}
src/client.ts
This is the core API client. It handles rate limiting (2 req/sec) and retries with exponential backoff.
import Bottleneck from 'bottleneck';
import type { SearchParams, Place } from './types.js';
interface RawSearchResponse {
data?: Array<{
place_id?: string;
title?: string;
name?: string;
address?: string;
latitude?: number;
longitude?: number;
rating?: number;
reviews?: number;
phone?: string;
website?: string;
types?: string[];
type?: string;
category?: string;
}>;
error?: string;
}
interface GeocodingResponse {
latitude?: number;
longitude?: number;
formatted_address?: string;
error?: string;
}
export class {
: ;
: ;
host = ;
: ;
() {
. = opts.;
. = opts. ?? ;
. = ({
: ,
: .( / (opts. ?? )),
});
}
(: ): <[]> {
response = .<>(, {
: params.,
: (params. ?? ),
: params. ?? ,
...(params. != && { : (params.) }),
...(params. != && { : (params.) }),
...(params. != && { : (params.) }),
});
(response.) ();
.(response. || []);
}
(: , country = ): <{ : ; : }> {
response = .<>(, {
: ,
});
(!response. || !response.) {
();
}
{ : response., : response. };
}
request<T>(: , : <, >): <T> {
..( .<T>(endpoint, params));
}
requestWithRetry<T>(
: ,
: <, >,
attempt =
): <T> {
url = ();
( [k, v] .(params)) {
(v != ) url..(k, v);
}
{
res = (url.(), {
: {
: .,
: .,
},
});
(!res.) {
: = ();
err. = res.;
err;
}
( res.()) T;
} (: ) {
retryable =
attempt < . &&
(err. === || err. >= ||
err. === || err. === );
(retryable) {
delay = * .(, attempt);
.();
( (r, delay));
.<T>(endpoint, params, attempt + );
}
err;
}
}
(: <[]>): [] {
data.( ({
: item. || ,
: item. || item. || ,
: item. || ,
: item. || ,
: item. || ,
: item.,
: item.,
: item.,
: item.,
: item. || (item. ? [item.] : []),
: item. || item.,
}));
}
}
src/csv.ts
import { writeFile, mkdir } from 'fs/promises';
import { dirname } from 'path';
import type { Place } from './types.js';
const HEADERS = [
'place_id', 'name', 'address', 'phone', 'website',
'rating', 'reviews_count', 'lat', 'lng', 'category',
];
function escape(val: string | number | undefined | null): string {
if (val == null) return '';
const s = String(val);
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"`
: s;
}
export async function exportCSV(places: Place[], outputPath: string): Promise<void> {
await mkdir(dirname(outputPath), { recursive: true });
const lines = [
HEADERS.join(','),
...places.map(p =>
HEADERS.map(h => escape(p[h as keyof Place])).join(',')
),
];
await writeFile(outputPath, lines.join('\n') + '\n', 'utf-8');
}
src/index.ts
import { GoogleMapsClient } from './client.js';
import { exportCSV } from './csv.js';
import { getZipsByState, getZipsByCity, getZipsByMinPopulation } from './zips.js';
import type { Place } from './types.js';
function dedup(places: Place[]): Place[] {
const seen = new Set<string>();
return places.filter(p => {
if (seen.has(p.place_id)) return false;
seen.add(p.place_id);
return true;
});
}
function getArg(args: string[], prefix: string): string | undefined {
const match = args.find(a => a.startsWith(prefix));
return match ? match.().().() : ;
}
() {
args = process..();
query = (args, );
zips = (args, );
cities = (args, );
state = (args, );
minPop = (args, );
limit = ((args, ) || , );
output = (args, ) || ;
(!query || (!zips && !cities && !state)) {
.();
process.();
}
apiKey = process..;
(!apiKey) {
.();
.();
process.();
}
: [] = [];
(zips) {
locations.(...zips.().( s.()));
}
(cities) {
locations.(...cities.().( s.()));
}
(state) {
minPopNum = minPop ? (minPop, ) : ;
stateZips = minPopNum >
? (minPopNum, state)
: (state);
locations.(...stateZips.( z.));
.();
}
(locations. === ) {
.();
process.();
}
.();
client = ({ apiKey, : });
: [] = [];
start = .();
( i = ; i < locations.; i++) {
loc = locations[i];
.();
{
results = client.({
: ,
limit,
: ,
});
allPlaces.(...results);
.();
} (: ) {
.();
}
}
unique = (allPlaces);
.();
(unique, output);
.();
duration = ((.() - start) / ).();
.();
(unique. > ) {
.();
sample = unique[];
.();
.();
(sample.) .();
(sample.) .();
(sample.) .();
}
}
().( {
.(, err.);
process.();
});
Running It
Local CLI
npm run scrape -- --query="pizza restaurant" --zips=10014,10013,10012
npm run scrape -- --query="dentist" --state=TX --min-pop=5000
npm run scrape -- --query="gym" --state=CA
npm run scrape -- --query="dentist" --cities="Austin TX,Dallas TX,San Antonio TX"
npm run scrape -- --query="gym" --zips=90210 --output=./data/gyms.csv
Programmatic Usage
import { GoogleMapsClient } from './client.js';
const client = new GoogleMapsClient({
apiKey: process.env.RAPIDAPI_KEY!,
requestsPerSecond: 2,
});
const places = await client.search({
query: 'coffee shop in 94105',
limit: 20,
});
const { lat, lng } = await client.geocode('94105');
const nearby = await client.search({
query: 'coffee shop',
lat,
lng,
zoom: 14,
limit: 20,
});
Deploying as a Web App (Optional)
If you want a browser UI instead of (or in addition to) the CLI, add Express:
npm install express
npm install -D @types/express
Create src/server.ts:
import express from 'express';
import { GoogleMapsClient } from './client.js';
import { exportCSV } from './csv.js';
import { tmpdir } from 'os';
import { join } from 'path';
import { readFile, unlink } from 'fs/promises';
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const client = new GoogleMapsClient({
apiKey: process.env.RAPIDAPI_KEY!,
requestsPerSecond: 2,
});
app.get('/', (_req, res) => {
res.send(`<!DOCTYPE html>
<html><head><title>Google Maps Scraper</title></head>
<body style="font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px">
<h1>Google Maps Scraper</h1>
<form method="POST" action="/scrape">
<label>Search query:<br>
<input name="query" placeholder="pizza restaurant" style="width:100%;padding:8px;margin:4px 0 12px" required>
</label>
<label>Locations (comma-separated zips or cities):<br>
<input name="locations" placeholder="10014, 10013, 10012" style="width:100%;padding:8px;margin:4px 0 12px" required>
</label>
<button type="submit" style="padding:10px 24px;cursor:pointer">Scrape</button>
</form>
</body></html>`);
});
app.(, (req, res) => {
{ query, : locStr } = req.;
locations = locStr.().( s.()).();
: [] = [];
( loc locations) {
{
results = client.({ : , : });
allPlaces.(...results);
} {}
}
seen = <>();
unique = allPlaces.( { (seen.(p.)) ; seen.(p.); ; });
tmpPath = ((), );
(unique, tmpPath);
csv = (tmpPath, );
(tmpPath);
res.(, );
res.(, );
res.(csv);
});
port = (process.. || , );
app.(port, .());
Add a script to package.json:
{
"scripts": {
"scrape": "tsx src/index.ts",
"serve": "tsx src/server.ts"
}
}
Run locally: npm run serve then open http://localhost:3000
Deploying to Railway
- Push your project to a GitHub repo
- Go to https://railway.com, create a new project, connect the repo
- Set the environment variable
RAPIDAPI_KEY in Railway's dashboard
- Set the start command to
npx tsx src/server.ts
- Railway auto-detects the port from
process.env.PORT and gives you a public URL
You can add password protection by checking a PASSWORD env var in the POST handler, or use Railway's built-in auth features.
Deploying to Other Platforms
This is a standard Node.js app. It runs anywhere:
- Render: Connect GitHub repo, set env vars, done
- Fly.io:
fly launch, set secrets with fly secrets set RAPIDAPI_KEY=xxx
- Vercel: Deploy as a serverless function (modify server.ts to export handlers)
- Docker:
FROM node:20-slim + npm install + npx tsx src/server.ts
API Reference
The underlying API is the Maps Data API on RapidAPI:
https://rapidapi.com/alexanderxbx/api/maps-data
Key Endpoints Used
| Endpoint | Purpose | Example |
|---|
searchmaps.php | Search businesses by query + location | ?query=pizza+in+10014&limit=20 |
geocoding.php | Convert address/zip to lat/lng | ?query=10014,+US |
nearby.php | Search near a lat/lng point | ?query=pizza&lat=40.73&lng=-74.00 |
place.php | Get full details for one business | ?place_id=ChIJ... |
Rate Limits
The free tier on RapidAPI has request limits (check your plan). The client is hard-coded to 2 requests/second with automatic retries on 429s. Adjust requestsPerSecond if your plan allows more.
Response Fields
Each result includes:
place_id — unique Google Maps identifier
name — business name
address — full street address
phone — phone number (if listed)
website — website URL (if listed)
rating — star rating (1-5)
reviews_count — number of Google reviews
lat / lng — coordinates
types / category — business categories (e.g., "pizza_restaurant")
Tips
- "query in zipcode" format works best for US searches. No coordinates needed.
- 20 results per search is the max. To get more coverage, search multiple overlapping zip codes.
- Dedup by
place_id — the same business often shows up in adjacent zip code searches.
- Cuisine/category filtering: The
types field tells you what kind of business it is. Use it to filter out irrelevant results (e.g., filter out "bar" when searching for "restaurant").
- Cost: Check your RapidAPI plan. The free tier usually gives you enough for testing. Paid plans are cheap for bulk scraping.
What to do next
Run /icp-prompt-builder on a 50-business sample (required step above). Then /blitz-list-builder with the filtered domains to find owner contacts — Google Maps returns businesses, not people.
After owner discovery: /email-waterfall to fill missing emails, then /list-quality-scorecard to grade.
Or wait: if your scrape returned <200 businesses, your query + location is too narrow. Widen before proceeding.
Related skills
/icp-prompt-builder — required qualification pass
/blitz-list-builder — find owner contacts at each business
/email-waterfall — fill missing emails
/list-quality-scorecard — grade the final list