Google maps list builder skill

Scrape Google Maps for local businesses by category and location, output CSV ready for cold email enrichment.

by growthenginenowoslawski·MIT license·★ 736 Stars on the repo·GitHub ↗

Use now

Files of Google maps list builder

growthenginenowoslawski/main1 file shown
SKILL.md
Show the full text768 lines

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:

  1. Run this skill → CSV of businesses with company_domain
  2. Run /icp-prompt-builder on a sample of 50 — tune a qualification prompt to filter out bad fits before paying for downstream enrichment
  3. Run /blitz-list-builder with the filtered CSV → adds owners/managers to each business
  4. Run /email-waterfall → fills in missing emails
  5. 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:

  1. Evaluate 10 results with an AI qualification prompt
  2. You flag "this one should be NO, they're a chain franchise"
  3. Refine, run next 10
  4. Stop when 2 rounds show no corrections
  5. 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

  1. Node.js 18+ and npm installed
  2. 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;       // "pizza restaurant", "dentist", "gym"
  lat?: number;        // Center latitude (optional if using "query in zipcode" format)
  lng?: number;        // Center longitude
  limit?: number;      // Max results per search (default 20, max 20)
  zoom?: number;       // Map zoom level (default 13 = neighborhood)
  country?: string;    // Country code (default "us")
}

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: string;
  total_results: number;
  unique_results: number;
  places: Place[];
  duration_ms: number;
}
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').slice(1); // skip header

  cache = lines.map(line => {
    // Handle quoted fields (area_codes can contain commas)
    const parts: string[] = [];
    let current = '';
    let inQuotes = false;
    for (const ch of line) {
      if (ch === '"') { inQuotes = !inQuotes; continue; }
      if (ch === ',' && !inQuotes) { parts.push(current); current = ''; continue; }
      current += ch;
    }
    parts.push(current);

    return {
      zip: parts[0]?.padStart(5, '0') || '',
      city: parts[1] || '',
      state: parts[2] || '',
      lat: parseFloat(parts[7]) || 0,
      lng: parseFloat(parts[8]) || 0,
      population: parseInt(parts[9]) || 0,
    };
  }).filter(z => z.zip.length === 5);

  return cache;
}

/** Get zips for a US state (2-letter code, e.g. "CA", "TX") */
export function getZipsByState(stateCode: string): ZipEntry[] {
  return loadAll().filter(z => z.state.toUpperCase() === stateCode.toUpperCase());
}

/** Get zips for a city name (case-insensitive, partial match) */
export function getZipsByCity(city: string, state?: string): ZipEntry[] {
  const cityLower = city.toLowerCase();
  return loadAll().filter(z => {
    const cityMatch = z.city.toLowerCase().includes(cityLower);
    const stateMatch = !state || z.state.toUpperCase() === state.toUpperCase();
    return cityMatch && stateMatch;
  });
}

/** Get zips with population above a threshold */
export function getZipsByMinPopulation(minPop: number, state?: string): ZipEntry[] {
  return loadAll().filter(z => {
    const popMatch = z.population >= minPop;
    const stateMatch = !state || z.state.toUpperCase() === state.toUpperCase();
    return popMatch && stateMatch;
  });
}

/** Get all loaded zip entries */
export function getAllZips(): ZipEntry[] {
  return loadAll();
}
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 GoogleMapsClient {
  private limiter: Bottleneck;
  private apiKey: string;
  private host = 'maps-data.p.rapidapi.com';
  private maxRetries: number;

  constructor(opts: { apiKey: string; requestsPerSecond?: number; maxRetries?: number }) {
    this.apiKey = opts.apiKey;
    this.maxRetries = opts.maxRetries ?? 3;
    this.limiter = new Bottleneck({
      maxConcurrent: 1,
      minTime: Math.floor(1000 / (opts.requestsPerSecond ?? 2)),
    });
  }

  /** Search Google Maps for businesses */
  async search(params: SearchParams): Promise<Place[]> {
    const response = await this.request<RawSearchResponse>('searchmaps.php', {
      query: params.query,
      limit: String(params.limit ?? 20),
      country: params.country ?? 'us',
      ...(params.lat != null && { lat: String(params.lat) }),
      ...(params.lng != null && { lng: String(params.lng) }),
      ...(params.zoom != null && { zoom: String(params.zoom) }),
    });

    if (response.error) throw new Error(`Search failed: ${response.error}`);
    return this.transform(response.data || []);
  }

  /** Geocode a zip code or address to lat/lng */
  async geocode(query: string, country = 'us'): Promise<{ lat: number; lng: number }> {
    const response = await this.request<GeocodingResponse>('geocoding.php', {
      query: `${query}, ${country.toUpperCase()}`,
    });
    if (!response.latitude || !response.longitude) {
      throw new Error(`Could not geocode: ${query}`);
    }
    return { lat: response.latitude, lng: response.longitude };
  }

  private async request<T>(endpoint: string, params: Record<string, string>): Promise<T> {
    return this.limiter.schedule(() => this.requestWithRetry<T>(endpoint, params));
  }

  private async requestWithRetry<T>(
    endpoint: string,
    params: Record<string, string>,
    attempt = 0
  ): Promise<T> {
    const url = new URL(`https://${this.host}/${endpoint}`);
    for (const [k, v] of Object.entries(params)) {
      if (v != null) url.searchParams.set(k, v);
    }

    try {
      const res = await fetch(url.toString(), {
        headers: {
          'X-RapidAPI-Key': this.apiKey,
          'X-RapidAPI-Host': this.host,
        },
      });

      if (!res.ok) {
        const err: any = new Error(`API ${res.status}: ${res.statusText}`);
        err.statusCode = res.status;
        throw err;
      }

      return (await res.json()) as T;
    } catch (err: any) {
      const retryable =
        attempt < this.maxRetries &&
        (err.statusCode === 429 || err.statusCode >= 500 ||
         err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT');

      if (retryable) {
        const delay = 1000 * Math.pow(2, attempt);
        console.log(`  Retry ${attempt + 1}/${this.maxRetries} in ${delay}ms...`);
        await new Promise(r => setTimeout(r, delay));
        return this.requestWithRetry<T>(endpoint, params, attempt + 1);
      }
      throw err;
    }
  }

  private transform(data: NonNullable<RawSearchResponse['data']>): Place[] {
    return data.map(item => ({
      place_id: item.place_id || '',
      name: item.title || item.name || '',
      address: item.address || '',
      lat: item.latitude || 0,
      lng: item.longitude || 0,
      rating: item.rating,
      reviews_count: item.reviews,
      phone: item.phone,
      website: item.website,
      types: item.types || (item.type ? [item.type] : []),
      category: item.category || item.type,
    }));
  }
}
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.split('=').slice(1).join('=') : undefined;
}

async function main() {
  const args = process.argv.slice(2);

  const query = getArg(args, '--query=');
  const zips = getArg(args, '--zips=');
  const cities = getArg(args, '--cities=');
  const state = getArg(args, '--state=');
  const minPop = getArg(args, '--min-pop=');
  const limit = parseInt(getArg(args, '--limit=') || '20', 10);
  const output = getArg(args, '--output=') || './output/results.csv';

  if (!query || (!zips && !cities && !state)) {
    console.log(`
Google Maps Scraper

USAGE:
  npm run scrape -- --query="pizza restaurant" --zips=10014,10013,10012
  npm run scrape -- --query="dentist" --state=TX
  npm run scrape -- --query="dentist" --state=TX --min-pop=10000
  npm run scrape -- --query="gym" --cities="Austin TX,Dallas TX"

OPTIONS:
  --query=QUERY      Business type to search for (required)
  --zips=ZIP,ZIP     Comma-separated zip codes to search
  --cities=CITY,CITY Comma-separated cities to search
  --state=XX         Search all zip codes in a US state (2-letter code)
  --min-pop=N        Filter zips to those with population >= N (use with --state)
  --limit=N          Max results per location (default 20, max 20)
  --output=PATH      Output CSV path (default ./output/results.csv)

ENVIRONMENT:
  RAPIDAPI_KEY       Your RapidAPI key (required)
                     Get one at: https://rapidapi.com/alexanderxbx/api/maps-data

EXAMPLES:
  # All pizza places in California (zips with pop >= 5000)
  npm run scrape -- --query="pizza restaurant" --state=CA --min-pop=5000

  # Dentists in specific NYC zip codes
  npm run scrape -- --query="dentist" --zips=10014,10013,10012

  # Gyms across Texas cities
  npm run scrape -- --query="gym" --cities="Austin TX,Dallas TX,Houston TX"
`);
    process.exit(1);
  }

  const apiKey = process.env.RAPIDAPI_KEY;
  if (!apiKey) {
    console.error('Error: RAPIDAPI_KEY environment variable is required');
    console.error('Get your key at: https://rapidapi.com/alexanderxbx/api/maps-data');
    process.exit(1);
  }

  // Build location list from all sources
  const locations: string[] = [];

  if (zips) {
    locations.push(...zips.split(',').map(s => s.trim()));
  }
  if (cities) {
    locations.push(...cities.split(',').map(s => s.trim()));
  }
  if (state) {
    const minPopNum = minPop ? parseInt(minPop, 10) : 0;
    const stateZips = minPopNum > 0
      ? getZipsByMinPopulation(minPopNum, state)
      : getZipsByState(state);
    locations.push(...stateZips.map(z => z.zip));
    console.log(`Loaded ${stateZips.length} zip codes for ${state.toUpperCase()}${minPopNum > 0 ? ` (pop >= ${minPopNum.toLocaleString()})` : ''}`);
  }

  if (locations.length === 0) {
    console.error('No locations to search. Provide --zips, --cities, or --state.');
    process.exit(1);
  }

  console.log(`Scraping "${query}" across ${locations.length} location(s)...\n`);

  const client = new GoogleMapsClient({ apiKey, requestsPerSecond: 2 });
  const allPlaces: Place[] = [];
  const start = Date.now();

  for (let i = 0; i < locations.length; i++) {
    const loc = locations[i];
    console.log(`  [${i + 1}/${locations.length}] Searching: ${query} in ${loc}`);

    try {
      const results = await client.search({
        query: `${query} in ${loc}`,
        limit,
        country: 'us',
      });
      allPlaces.push(...results);
      console.log(`    Found ${results.length} results`);
    } catch (err: any) {
      console.error(`    Error: ${err.message}`);
    }
  }

  // Deduplicate
  const unique = dedup(allPlaces);
  console.log(`\nTotal: ${allPlaces.length} results, ${unique.length} unique after dedup`);

  // Export
  await exportCSV(unique, output);
  console.log(`Saved to: ${output}`);

  const duration = ((Date.now() - start) / 1000).toFixed(1);
  console.log(`Done in ${duration}s`);

  // Print sample
  if (unique.length > 0) {
    console.log('\nSample result:');
    const sample = unique[0];
    console.log(`  ${sample.name}`);
    console.log(`  ${sample.address}`);
    if (sample.phone) console.log(`  ${sample.phone}`);
    if (sample.website) console.log(`  ${sample.website}`);
    if (sample.rating) console.log(`  ${sample.rating} stars (${sample.reviews_count} reviews)`);
  }
}

main().catch(err => {
  console.error('Fatal:', err.message);
  process.exit(1);
});

Running It

Local CLI
# Search pizza places in 3 NYC zip codes
npm run scrape -- --query="pizza restaurant" --zips=10014,10013,10012

# Search ALL dentists in Texas (zips with population >= 5000)
npm run scrape -- --query="dentist" --state=TX --min-pop=5000

# Every gym in California (all 2,657 zip codes — takes a while)
npm run scrape -- --query="gym" --state=CA

# Search dentists across specific cities
npm run scrape -- --query="dentist" --cities="Austin TX,Dallas TX,San Antonio TX"

# Custom output file
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,
});

// Simple search
const places = await client.search({
  query: 'coffee shop in 94105',
  limit: 20,
});

// Search with coordinates
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,
});

// Simple HTML form
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.post('/scrape', async (req, res) => {
  const { query, locations: locStr } = req.body;
  const locations = locStr.split(',').map((s: string) => s.trim()).filter(Boolean);

  const allPlaces: any[] = [];
  for (const loc of locations) {
    try {
      const results = await client.search({ query: `${query} in ${loc}`, limit: 20 });
      allPlaces.push(...results);
    } catch {}
  }

  // Dedup
  const seen = new Set<string>();
  const unique = allPlaces.filter(p => { if (seen.has(p.place_id)) return false; seen.add(p.place_id); return true; });

  // Export CSV and send as download
  const tmpPath = join(tmpdir(), `scrape-${Date.now()}.csv`);
  await exportCSV(unique, tmpPath);
  const csv = await readFile(tmpPath, 'utf-8');
  await unlink(tmpPath);

  res.setHeader('Content-Type', 'text/csv');
  res.setHeader('Content-Disposition', `attachment; filename="maps-scrape-${Date.now()}.csv"`);
  res.send(csv);
});

const port = parseInt(process.env.PORT || '3000', 10);
app.listen(port, () => console.log(`Scraper running at http://localhost:${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
  1. Push your project to a GitHub repo
  2. Go to https://railway.com, create a new project, connect the repo
  3. Set the environment variable RAPIDAPI_KEY in Railway's dashboard
  4. Set the start command to npx tsx src/server.ts
  5. 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.

  • /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
1---
2name: google-maps-list-builder
3description: 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).
4---
5 
6# Google Maps List Builder
7 
8A 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.
9 
10## How this fits in the cold email flow
11 
12Google Maps gives you COMPANIES (name, domain, phone, address, ratings). It does NOT give you PEOPLE. To run cold email:
13 
141. Run this skill → CSV of businesses with `company_domain`
152. **Run `/icp-prompt-builder` on a sample of 50** — tune a qualification prompt to filter out bad fits before paying for downstream enrichment
163. Run `/blitz-list-builder` with the filtered CSV → adds owners/managers to each business
174. Run `/email-waterfall` → fills in missing emails
185. Run `/cold-email-starter-kit`'s `smartlead-add-leads.ts` → upload to Smartlead
19 
20This skill is only the first step.
21 
22## Required step: Qualify with /icp-prompt-builder
23 
24**This is a required step. Do not skip it.**
25 
26Google 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`:
27 
281. Evaluate 10 results with an AI qualification prompt
292. You flag "this one should be NO, they're a chain franchise"
303. Refine, run next 10
314. Stop when 2 rounds show no corrections
325. Apply tuned prompt to filter the rest of the scrape
33 
34**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.
35 
36## What You Need Before Starting
37 
381. **Node.js 18+** and **npm** installed
392. **A RapidAPI account** (free tier available) with a subscription to the **Maps Data API**:
40 - Sign up at https://rapidapi.com
41 - Subscribe to the API: https://rapidapi.com/alexanderxbx/api/maps-data
42 - Copy your RapidAPI key from the dashboard (it's in the `X-RapidAPI-Key` header on any endpoint page)
43 
44That's it. No Google Cloud account, no OAuth, no billing setup beyond RapidAPI.
45 
46## Project Setup
47 
48Create a new project directory and initialize it:
49 
50```bash
51mkdir google-maps-scraper && cd google-maps-scraper
52npm init -y
53npm install typescript bottleneck
54npm install -D @types/node tsx
55```
56 
57Add to `package.json` scripts:
58```json
59{
60 "scripts": {
61 "scrape": "tsx src/index.ts"
62 }
63}
64```
65 
66Create `tsconfig.json`:
67```json
68{
69 "compilerOptions": {
70 "target": "ES2022",
71 "module": "ESNext",
72 "moduleResolution": "bundler",
73 "esModuleInterop": true,
74 "strict": true,
75 "outDir": "dist",
76 "rootDir": "src",
77 "skipLibCheck": true
78 },
79 "include": ["src"]
80}
81```
82 
83Set your API key as an environment variable:
84```bash
85export RAPIDAPI_KEY=your_key_here
86```
87 
88Or create a `.env` file (add `.env` to `.gitignore`):
89```
90RAPIDAPI_KEY=your_key_here
91```
92 
93## File Structure
94 
95```
96google-maps-scraper/
97 data/
98 us-zip-codes.csv # 42,734 US zip codes with city, state, lat/lng, population
99 src/
100 index.ts # CLI entry point
101 client.ts # RapidAPI Maps Data client with rate limiting
102 types.ts # TypeScript interfaces
103 csv.ts # CSV export
104 zips.ts # Zip code loader (filter by state, city, population)
105```
106 
107## Bundled Zip Code Database
108 
109The repo includes `data/us-zip-codes.csv` — a complete US zip code reference with 42,734 entries. Columns:
110 
111```
112zip,primary_city,state,timezone,area_codes,world_region,country,latitude,longitude,irs_estimated_population
113```
114 
115This 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.
116 
117## Core Files
118 
119### src/types.ts
120 
121```typescript
122export interface SearchParams {
123 query: string; // "pizza restaurant", "dentist", "gym"
124 lat?: number; // Center latitude (optional if using "query in zipcode" format)
125 lng?: number; // Center longitude
126 limit?: number; // Max results per search (default 20, max 20)
127 zoom?: number; // Map zoom level (default 13 = neighborhood)
128 country?: string; // Country code (default "us")
129}
130 
131export interface Place {
132 place_id: string;
133 name: string;
134 address: string;
135 lat: number;
136 lng: number;
137 rating?: number;
138 reviews_count?: number;
139 phone?: string;
140 website?: string;
141 types?: string[];
142 category?: string;
143}
144 
145export interface ScrapeResult {
146 query: string;
147 location: string;
148 total_results: number;
149 unique_results: number;
150 places: Place[];
151 duration_ms: number;
152}
153```
154 
155### src/zips.ts
156 
157Loads and filters the bundled zip code CSV. Lets you target by state, city name, or minimum population.
158 
159```typescript
160import { readFileSync } from 'fs';
161import { join, dirname } from 'path';
162import { fileURLToPath } from 'url';
163 
164export interface ZipEntry {
165 zip: string;
166 city: string;
167 state: string;
168 lat: number;
169 lng: number;
170 population: number;
171}
172 
173let cache: ZipEntry[] | null = null;
174 
175function loadAll(): ZipEntry[] {
176 if (cache) return cache;
177 const __dirname = dirname(fileURLToPath(import.meta.url));
178 const csvPath = join(__dirname, '..', 'data', 'us-zip-codes.csv');
179 const raw = readFileSync(csvPath, 'utf-8');
180 const lines = raw.trim().split('\n').slice(1); // skip header
181 
182 cache = lines.map(line => {
183 // Handle quoted fields (area_codes can contain commas)
184 const parts: string[] = [];
185 let current = '';
186 let inQuotes = false;
187 for (const ch of line) {
188 if (ch === '"') { inQuotes = !inQuotes; continue; }
189 if (ch === ',' && !inQuotes) { parts.push(current); current = ''; continue; }
190 current += ch;
191 }
192 parts.push(current);
193 
194 return {
195 zip: parts[0]?.padStart(5, '0') || '',
196 city: parts[1] || '',
197 state: parts[2] || '',
198 lat: parseFloat(parts[7]) || 0,
199 lng: parseFloat(parts[8]) || 0,
200 population: parseInt(parts[9]) || 0,
201 };
202 }).filter(z => z.zip.length === 5);
203 
204 return cache;
205}
206 
207/** Get zips for a US state (2-letter code, e.g. "CA", "TX") */
208export function getZipsByState(stateCode: string): ZipEntry[] {
209 return loadAll().filter(z => z.state.toUpperCase() === stateCode.toUpperCase());
210}
211 
212/** Get zips for a city name (case-insensitive, partial match) */
213export function getZipsByCity(city: string, state?: string): ZipEntry[] {
214 const cityLower = city.toLowerCase();
215 return loadAll().filter(z => {
216 const cityMatch = z.city.toLowerCase().includes(cityLower);
217 const stateMatch = !state || z.state.toUpperCase() === state.toUpperCase();
218 return cityMatch && stateMatch;
219 });
220}
221 
222/** Get zips with population above a threshold */
223export function getZipsByMinPopulation(minPop: number, state?: string): ZipEntry[] {
224 return loadAll().filter(z => {
225 const popMatch = z.population >= minPop;
226 const stateMatch = !state || z.state.toUpperCase() === state.toUpperCase();
227 return popMatch && stateMatch;
228 });
229}
230 
231/** Get all loaded zip entries */
232export function getAllZips(): ZipEntry[] {
233 return loadAll();
234}
235```
236 
237### src/client.ts
238 
239This is the core API client. It handles rate limiting (2 req/sec) and retries with exponential backoff.
240 
241```typescript
242import Bottleneck from 'bottleneck';
243import type { SearchParams, Place } from './types.js';
244 
245interface RawSearchResponse {
246 data?: Array<{
247 place_id?: string;
248 title?: string;
249 name?: string;
250 address?: string;
251 latitude?: number;
252 longitude?: number;
253 rating?: number;
254 reviews?: number;
255 phone?: string;
256 website?: string;
257 types?: string[];
258 type?: string;
259 category?: string;
260 }>;
261 error?: string;
262}
263 
264interface GeocodingResponse {
265 latitude?: number;
266 longitude?: number;
267 formatted_address?: string;
268 error?: string;
269}
270 
271export class GoogleMapsClient {
272 private limiter: Bottleneck;
273 private apiKey: string;
274 private host = 'maps-data.p.rapidapi.com';
275 private maxRetries: number;
276 
277 constructor(opts: { apiKey: string; requestsPerSecond?: number; maxRetries?: number }) {
278 this.apiKey = opts.apiKey;
279 this.maxRetries = opts.maxRetries ?? 3;
280 this.limiter = new Bottleneck({
281 maxConcurrent: 1,
282 minTime: Math.floor(1000 / (opts.requestsPerSecond ?? 2)),
283 });
284 }
285 
286 /** Search Google Maps for businesses */
287 async search(params: SearchParams): Promise<Place[]> {
288 const response = await this.request<RawSearchResponse>('searchmaps.php', {
289 query: params.query,
290 limit: String(params.limit ?? 20),
291 country: params.country ?? 'us',
292 ...(params.lat != null && { lat: String(params.lat) }),
293 ...(params.lng != null && { lng: String(params.lng) }),
294 ...(params.zoom != null && { zoom: String(params.zoom) }),
295 });
296 
297 if (response.error) throw new Error(`Search failed: ${response.error}`);
298 return this.transform(response.data || []);
299 }
300 
301 /** Geocode a zip code or address to lat/lng */
302 async geocode(query: string, country = 'us'): Promise<{ lat: number; lng: number }> {
303 const response = await this.request<GeocodingResponse>('geocoding.php', {
304 query: `${query}, ${country.toUpperCase()}`,
305 });
306 if (!response.latitude || !response.longitude) {
307 throw new Error(`Could not geocode: ${query}`);
308 }
309 return { lat: response.latitude, lng: response.longitude };
310 }
311 
312 private async request<T>(endpoint: string, params: Record<string, string>): Promise<T> {
313 return this.limiter.schedule(() => this.requestWithRetry<T>(endpoint, params));
314 }
315 
316 private async requestWithRetry<T>(
317 endpoint: string,
318 params: Record<string, string>,
319 attempt = 0
320 ): Promise<T> {
321 const url = new URL(`https://${this.host}/${endpoint}`);
322 for (const [k, v] of Object.entries(params)) {
323 if (v != null) url.searchParams.set(k, v);
324 }
325 
326 try {
327 const res = await fetch(url.toString(), {
328 headers: {
329 'X-RapidAPI-Key': this.apiKey,
330 'X-RapidAPI-Host': this.host,
331 },
332 });
333 
334 if (!res.ok) {
335 const err: any = new Error(`API ${res.status}: ${res.statusText}`);
336 err.statusCode = res.status;
337 throw err;
338 }
339 
340 return (await res.json()) as T;
341 } catch (err: any) {
342 const retryable =
343 attempt < this.maxRetries &&
344 (err.statusCode === 429 || err.statusCode >= 500 ||
345 err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT');
346 
347 if (retryable) {
348 const delay = 1000 * Math.pow(2, attempt);
349 console.log(` Retry ${attempt + 1}/${this.maxRetries} in ${delay}ms...`);
350 await new Promise(r => setTimeout(r, delay));
351 return this.requestWithRetry<T>(endpoint, params, attempt + 1);
352 }
353 throw err;
354 }
355 }
356 
357 private transform(data: NonNullable<RawSearchResponse['data']>): Place[] {
358 return data.map(item => ({
359 place_id: item.place_id || '',
360 name: item.title || item.name || '',
361 address: item.address || '',
362 lat: item.latitude || 0,
363 lng: item.longitude || 0,
364 rating: item.rating,
365 reviews_count: item.reviews,
366 phone: item.phone,
367 website: item.website,
368 types: item.types || (item.type ? [item.type] : []),
369 category: item.category || item.type,
370 }));
371 }
372}
373```
374 
375### src/csv.ts
376 
377```typescript
378import { writeFile, mkdir } from 'fs/promises';
379import { dirname } from 'path';
380import type { Place } from './types.js';
381 
382const HEADERS = [
383 'place_id', 'name', 'address', 'phone', 'website',
384 'rating', 'reviews_count', 'lat', 'lng', 'category',
385];
386 
387function escape(val: string | number | undefined | null): string {
388 if (val == null) return '';
389 const s = String(val);
390 return s.includes(',') || s.includes('"') || s.includes('\n')
391 ? `"${s.replace(/"/g, '""')}"`
392 : s;
393}
394 
395export async function exportCSV(places: Place[], outputPath: string): Promise<void> {
396 await mkdir(dirname(outputPath), { recursive: true });
397 const lines = [
398 HEADERS.join(','),
399 ...places.map(p =>
400 HEADERS.map(h => escape(p[h as keyof Place])).join(',')
401 ),
402 ];
403 await writeFile(outputPath, lines.join('\n') + '\n', 'utf-8');
404}
405```
406 
407### src/index.ts
408 
409```typescript
410import { GoogleMapsClient } from './client.js';
411import { exportCSV } from './csv.js';
412import { getZipsByState, getZipsByCity, getZipsByMinPopulation } from './zips.js';
413import type { Place } from './types.js';
414 
415function dedup(places: Place[]): Place[] {
416 const seen = new Set<string>();
417 return places.filter(p => {
418 if (seen.has(p.place_id)) return false;
419 seen.add(p.place_id);
420 return true;
421 });
422}
423 
424function getArg(args: string[], prefix: string): string | undefined {
425 const match = args.find(a => a.startsWith(prefix));
426 return match ? match.split('=').slice(1).join('=') : undefined;
427}
428 
429async function main() {
430 const args = process.argv.slice(2);
431 
432 const query = getArg(args, '--query=');
433 const zips = getArg(args, '--zips=');
434 const cities = getArg(args, '--cities=');
435 const state = getArg(args, '--state=');
436 const minPop = getArg(args, '--min-pop=');
437 const limit = parseInt(getArg(args, '--limit=') || '20', 10);
438 const output = getArg(args, '--output=') || './output/results.csv';
439 
440 if (!query || (!zips && !cities && !state)) {
441 console.log(`
442Google Maps Scraper
443 
444USAGE:
445 npm run scrape -- --query="pizza restaurant" --zips=10014,10013,10012
446 npm run scrape -- --query="dentist" --state=TX
447 npm run scrape -- --query="dentist" --state=TX --min-pop=10000
448 npm run scrape -- --query="gym" --cities="Austin TX,Dallas TX"
449 
450OPTIONS:
451 --query=QUERY Business type to search for (required)
452 --zips=ZIP,ZIP Comma-separated zip codes to search
453 --cities=CITY,CITY Comma-separated cities to search
454 --state=XX Search all zip codes in a US state (2-letter code)
455 --min-pop=N Filter zips to those with population >= N (use with --state)
456 --limit=N Max results per location (default 20, max 20)
457 --output=PATH Output CSV path (default ./output/results.csv)
458 
459ENVIRONMENT:
460 RAPIDAPI_KEY Your RapidAPI key (required)
461 Get one at: https://rapidapi.com/alexanderxbx/api/maps-data
462 
463EXAMPLES:
464 # All pizza places in California (zips with pop >= 5000)
465 npm run scrape -- --query="pizza restaurant" --state=CA --min-pop=5000
466 
467 # Dentists in specific NYC zip codes
468 npm run scrape -- --query="dentist" --zips=10014,10013,10012
469 
470 # Gyms across Texas cities
471 npm run scrape -- --query="gym" --cities="Austin TX,Dallas TX,Houston TX"
472`);
473 process.exit(1);
474 }
475 
476 const apiKey = process.env.RAPIDAPI_KEY;
477 if (!apiKey) {
478 console.error('Error: RAPIDAPI_KEY environment variable is required');
479 console.error('Get your key at: https://rapidapi.com/alexanderxbx/api/maps-data');
480 process.exit(1);
481 }
482 
483 // Build location list from all sources
484 const locations: string[] = [];
485 
486 if (zips) {
487 locations.push(...zips.split(',').map(s => s.trim()));
488 }
489 if (cities) {
490 locations.push(...cities.split(',').map(s => s.trim()));
491 }
492 if (state) {
493 const minPopNum = minPop ? parseInt(minPop, 10) : 0;
494 const stateZips = minPopNum > 0
495 ? getZipsByMinPopulation(minPopNum, state)
496 : getZipsByState(state);
497 locations.push(...stateZips.map(z => z.zip));
498 console.log(`Loaded ${stateZips.length} zip codes for ${state.toUpperCase()}${minPopNum > 0 ? ` (pop >= ${minPopNum.toLocaleString()})` : ''}`);
499 }
500 
501 if (locations.length === 0) {
502 console.error('No locations to search. Provide --zips, --cities, or --state.');
503 process.exit(1);
504 }
505 
506 console.log(`Scraping "${query}" across ${locations.length} location(s)...\n`);
507 
508 const client = new GoogleMapsClient({ apiKey, requestsPerSecond: 2 });
509 const allPlaces: Place[] = [];
510 const start = Date.now();
511 
512 for (let i = 0; i < locations.length; i++) {
513 const loc = locations[i];
514 console.log(` [${i + 1}/${locations.length}] Searching: ${query} in ${loc}`);
515 
516 try {
517 const results = await client.search({
518 query: `${query} in ${loc}`,
519 limit,
520 country: 'us',
521 });
522 allPlaces.push(...results);
523 console.log(` Found ${results.length} results`);
524 } catch (err: any) {
525 console.error(` Error: ${err.message}`);
526 }
527 }
528 
529 // Deduplicate
530 const unique = dedup(allPlaces);
531 console.log(`\nTotal: ${allPlaces.length} results, ${unique.length} unique after dedup`);
532 
533 // Export
534 await exportCSV(unique, output);
535 console.log(`Saved to: ${output}`);
536 
537 const duration = ((Date.now() - start) / 1000).toFixed(1);
538 console.log(`Done in ${duration}s`);
539 
540 // Print sample
541 if (unique.length > 0) {
542 console.log('\nSample result:');
543 const sample = unique[0];
544 console.log(` ${sample.name}`);
545 console.log(` ${sample.address}`);
546 if (sample.phone) console.log(` ${sample.phone}`);
547 if (sample.website) console.log(` ${sample.website}`);
548 if (sample.rating) console.log(` ${sample.rating} stars (${sample.reviews_count} reviews)`);
549 }
550}
551 
552main().catch(err => {
553 console.error('Fatal:', err.message);
554 process.exit(1);
555});
556```
557 
558## Running It
559 
560### Local CLI
561 
562```bash
563# Search pizza places in 3 NYC zip codes
564npm run scrape -- --query="pizza restaurant" --zips=10014,10013,10012
565 
566# Search ALL dentists in Texas (zips with population >= 5000)
567npm run scrape -- --query="dentist" --state=TX --min-pop=5000
568 
569# Every gym in California (all 2,657 zip codes — takes a while)
570npm run scrape -- --query="gym" --state=CA
571 
572# Search dentists across specific cities
573npm run scrape -- --query="dentist" --cities="Austin TX,Dallas TX,San Antonio TX"
574 
575# Custom output file
576npm run scrape -- --query="gym" --zips=90210 --output=./data/gyms.csv
577```
578 
579### Programmatic Usage
580 
581```typescript
582import { GoogleMapsClient } from './client.js';
583 
584const client = new GoogleMapsClient({
585 apiKey: process.env.RAPIDAPI_KEY!,
586 requestsPerSecond: 2,
587});
588 
589// Simple search
590const places = await client.search({
591 query: 'coffee shop in 94105',
592 limit: 20,
593});
594 
595// Search with coordinates
596const { lat, lng } = await client.geocode('94105');
597const nearby = await client.search({
598 query: 'coffee shop',
599 lat,
600 lng,
601 zoom: 14,
602 limit: 20,
603});
604```
605 
606## Deploying as a Web App (Optional)
607 
608If you want a browser UI instead of (or in addition to) the CLI, add Express:
609 
610```bash
611npm install express
612npm install -D @types/express
613```
614 
615Create `src/server.ts`:
616 
617```typescript
618import express from 'express';
619import { GoogleMapsClient } from './client.js';
620import { exportCSV } from './csv.js';
621import { tmpdir } from 'os';
622import { join } from 'path';
623import { readFile, unlink } from 'fs/promises';
624 
625const app = express();
626app.use(express.json());
627app.use(express.urlencoded({ extended: true }));
628 
629const client = new GoogleMapsClient({
630 apiKey: process.env.RAPIDAPI_KEY!,
631 requestsPerSecond: 2,
632});
633 
634// Simple HTML form
635app.get('/', (_req, res) => {
636 res.send(`<!DOCTYPE html>
637<html><head><title>Google Maps Scraper</title></head>
638<body style="font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px">
639 <h1>Google Maps Scraper</h1>
640 <form method="POST" action="/scrape">
641 <label>Search query:<br>
642 <input name="query" placeholder="pizza restaurant" style="width:100%;padding:8px;margin:4px 0 12px" required>
643 </label>
644 <label>Locations (comma-separated zips or cities):<br>
645 <input name="locations" placeholder="10014, 10013, 10012" style="width:100%;padding:8px;margin:4px 0 12px" required>
646 </label>
647 <button type="submit" style="padding:10px 24px;cursor:pointer">Scrape</button>
648 </form>
649</body></html>`);
650});
651 
652app.post('/scrape', async (req, res) => {
653 const { query, locations: locStr } = req.body;
654 const locations = locStr.split(',').map((s: string) => s.trim()).filter(Boolean);
655 
656 const allPlaces: any[] = [];
657 for (const loc of locations) {
658 try {
659 const results = await client.search({ query: `${query} in ${loc}`, limit: 20 });
660 allPlaces.push(...results);
661 } catch {}
662 }
663 
664 // Dedup
665 const seen = new Set<string>();
666 const unique = allPlaces.filter(p => { if (seen.has(p.place_id)) return false; seen.add(p.place_id); return true; });
667 
668 // Export CSV and send as download
669 const tmpPath = join(tmpdir(), `scrape-${Date.now()}.csv`);
670 await exportCSV(unique, tmpPath);
671 const csv = await readFile(tmpPath, 'utf-8');
672 await unlink(tmpPath);
673 
674 res.setHeader('Content-Type', 'text/csv');
675 res.setHeader('Content-Disposition', `attachment; filename="maps-scrape-${Date.now()}.csv"`);
676 res.send(csv);
677});
678 
679const port = parseInt(process.env.PORT || '3000', 10);
680app.listen(port, () => console.log(`Scraper running at http://localhost:${port}`));
681```
682 
683Add a script to `package.json`:
684```json
685{
686 "scripts": {
687 "scrape": "tsx src/index.ts",
688 "serve": "tsx src/server.ts"
689 }
690}
691```
692 
693Run locally: `npm run serve` then open http://localhost:3000
694 
695### Deploying to Railway
696 
6971. Push your project to a GitHub repo
6982. Go to https://railway.com, create a new project, connect the repo
6993. Set the environment variable `RAPIDAPI_KEY` in Railway's dashboard
7004. Set the start command to `npx tsx src/server.ts`
7015. Railway auto-detects the port from `process.env.PORT` and gives you a public URL
702 
703You can add password protection by checking a `PASSWORD` env var in the POST handler, or use Railway's built-in auth features.
704 
705### Deploying to Other Platforms
706 
707This is a standard Node.js app. It runs anywhere:
708- **Render**: Connect GitHub repo, set env vars, done
709- **Fly.io**: `fly launch`, set secrets with `fly secrets set RAPIDAPI_KEY=xxx`
710- **Vercel**: Deploy as a serverless function (modify server.ts to export handlers)
711- **Docker**: `FROM node:20-slim` + `npm install` + `npx tsx src/server.ts`
712 
713## API Reference
714 
715The underlying API is the **Maps Data API** on RapidAPI:
716https://rapidapi.com/alexanderxbx/api/maps-data
717 
718### Key Endpoints Used
719 
720| Endpoint | Purpose | Example |
721|---|---|---|
722| `searchmaps.php` | Search businesses by query + location | `?query=pizza+in+10014&limit=20` |
723| `geocoding.php` | Convert address/zip to lat/lng | `?query=10014,+US` |
724| `nearby.php` | Search near a lat/lng point | `?query=pizza&lat=40.73&lng=-74.00` |
725| `place.php` | Get full details for one business | `?place_id=ChIJ...` |
726 
727### Rate Limits
728 
729The 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.
730 
731### Response Fields
732 
733Each result includes:
734- `place_id` — unique Google Maps identifier
735- `name` — business name
736- `address` — full street address
737- `phone` — phone number (if listed)
738- `website` — website URL (if listed)
739- `rating` — star rating (1-5)
740- `reviews_count` — number of Google reviews
741- `lat` / `lng` — coordinates
742- `types` / `category` — business categories (e.g., "pizza_restaurant")
743 
744## Tips
745 
746- **"query in zipcode"** format works best for US searches. No coordinates needed.
747- **20 results per search** is the max. To get more coverage, search multiple overlapping zip codes.
748- **Dedup by `place_id`** — the same business often shows up in adjacent zip code searches.
749- **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").
750- **Cost**: Check your RapidAPI plan. The free tier usually gives you enough for testing. Paid plans are cheap for bulk scraping.
751 
752---
753 
754## What to do next
755 
756**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.
757 
758After owner discovery: `/email-waterfall` to fill missing emails, then `/list-quality-scorecard` to grade.
759 
760**Or wait:** if your scrape returned <200 businesses, your query + location is too narrow. Widen before proceeding.
761 
762## Related skills
763 
764- `/icp-prompt-builder` — required qualification pass
765- `/blitz-list-builder` — find owner contacts at each business
766- `/email-waterfall` — fill missing emails
767- `/list-quality-scorecard` — grade the final list
768 

Discussion

Alternatives

API and interface designGuides stable API and interface design. Use when designing APIs, module boundaries, or any public interface. Use when creating REST or GraphQL endpoints, defining type contracts between modules, or establishing boundaries between frontend and backend.Coding · MITContext7Pulls up-to-date, version-specific library docs and code examples into the prompt so the AI stops inventing old APIs.Coding · MITContext7 Documentation LookupFetch up-to-date documentation and code examples for any library, framework, SDK, CLI tool, or cloud service. Use whenever the user asks about a specific library — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot — because training data may not reflect recent API changes or version updates. Always use for: API syntax questions, configuration options, version migration issues, "how do I" questions mentioning a library name, debugging that involves library-specific behavior, setup instructions, and CLI tool usage. Use even when you think you know the answer. Do not rely on training data for API details, signatures, or configuration options — they are frequently out of date. Prefer this over web search for library documentation.Coding · MITAdaptyv Bio Foundry APIHow to use the Adaptyv Bio Foundry API and Python SDK for protein experiment design, submission, and results retrieval. Use this skill whenever the user mentions Adaptyv, Foundry API, protein binding assays, protein screening experiments, BLI/SPR assays, thermostability assays, or wants to submit protein sequences for experimental characterization. Also trigger when code imports `adaptyv`, `adaptyv_sdk`, or `FoundryClient`, or references `foundry-api-public.adaptyvbio.com`.Science · MIT