关于 google-maps-list-builder
google-maps-list-builder 是一个 Node.js 工具,可按查询词和地点收集 Google Maps 商家信息并导出为 CSV,适用于需要公司线索的开发流程,支持邮编筛选、去重和限速请求。
主要功能
- 01按查询词和地点搜索商家
- 02将商家记录导出为 CSV
- 03筛选内置的美国邮编数据
- 04按 place_id 去重
- 05限速请求与自动重试
适用场景
- 01建立本地商家潜在客户名单
- 02按指定邮编查找商家
- 03准备待筛选的公司记录
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ growthenginenowoslawski/ coldoutboundskills --skill google-maps-list-builder安装后在 Claude Code 中输入/google-maps-list-builder确认已加载。
详细介绍
Google Maps 列表构建器
一款独立工具,用于抓取 Google Maps 上的商家列表。提供搜索查询(例如“披萨餐厅”)和地点(邮政编码、城市或坐标),即可获取所有匹配商家的结构化数据,并写入 CSV。
在冷邮件流程中的作用
Google Maps 可提供公司信息(名称、域名、电话、地址、评分),但无法提供个人信息。要开展冷邮件营销:
- 运行此技能 → 获取包含
company_domain的商家 CSV - 对 50 条样本运行
/icp-prompt-builder——调整资格筛选提示词,在付费进行后续数据补充之前排除不合适的对象 - 使用筛选后的 CSV 运行
/blitz-list-builder→ 为每家商户添加所有者/经理信息 - 运行
/list-builder(第 5 阶段,邮箱)→ 补全缺失的邮箱 - 运行
/cold-email-starter-kit的smartlead-add-leads.ts→ 上传到 Smartlead
此技能只是第一步。
必需步骤:使用 /icp-prompt-builder 进行资格筛选
此步骤为必需步骤。请勿跳过。
Google Maps 可以轻松返回 10,000 家“伊利诺伊州的披萨餐厅”,但其中大多数可能不符合你的实际 ICP(例如,你可能只需要有 50-200 个座位的商家,或只需要没有在线订餐的商家)。在付费进行数据补充之前,先抽取约 50 条结果样本并运行 /icp-prompt-builder:
- 使用 AI 资格筛选提示词评估 10 条结果
- 标记“这个应该判为否,他们是连锁加盟店”
- 优化提示词,再运行下一批 10 条
- 连续 2 轮没有需要修正的地方时停止
- 使用调整后的提示词筛选其余抓取结果
**为何这是必需步骤:**后续的所有者信息查找(通过 /blitz-list-builder)和邮箱瀑布式查找,每个联系人需花费 $0.10-$0.30。抓取 10,000 家商户就要花费 $1K-$3K。提前进行资格筛选,平均可节省其中 50-80% 的支出。
开始前的准备
- 安装 Node.js 18+ 和 npm
- 注册 RapidAPI 帐户(提供免费套餐),并订阅 Maps Data API:
- 在 https://rapidapi.com 注册
- 订阅 API:https://rapidapi.com/alexanderxbx/api/maps-data
- 从控制面板复制 RapidAPI 密钥(任何端点页面的
X-RapidAPI-Key请求头中都能找到)
就是这些。无需 Google Cloud 帐户、OAuth,也无需进行 RapidAPI 以外的账单设置。
项目设置
创建新的项目目录并初始化:
mkdir google-maps-scraper && cd google-maps-scraper
npm init -y
npm install typescript bottleneck
npm install -D @types/node tsx
在 package.json 脚本中添加:
{
"scripts": {
"scrape": "tsx src/index.ts"
}
}
创建 tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}
将 API 密钥设为环境变量:
export RAPIDAPI_KEY=your_key_here
或者创建 .env 文件(将 .env 添加到 .gitignore):
RAPIDAPI_KEY=your_key_here
文件结构
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)
内置邮政编码数据库
仓库包含 data/us-zip-codes.csv——完整的美国邮政编码参考数据库,共有 42,734 条记录。列如下:
zip,primary_city,state,timezone,area_codes,world_region,country,latitude,longitude,irs_estimated_population
这样一来,无需手动列出邮政编码,即可抓取整个州或大都市地区的数据。src/zips.ts 加载器支持按州、城市和最低人口数筛选。
核心文件
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
加载并筛选内置的邮政编码 CSV。支持按州、城市名称或最低人口数进行定位。
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
这是核心 API 客户端。它负责速率限制(2 req/sec)和使用指数退避进行重试。
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);
});
运行
本地 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
编程方式使用
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,
});
部署为 Web 应用(可选)
如果你想使用浏览器界面来代替 CLI,或在 CLI 之外同时使用浏览器界面,请添加 Express:
npm install express
npm install -D @types/express
创建 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}`));
在 package.json 中添加脚本:
{
"scripts": {
"scrape": "tsx src/index.ts",
"serve": "tsx src/server.ts"
}
}
在本地运行:npm run serve,然后打开 http://localhost:3000
部署到 Railway
- 将项目推送到 GitHub 仓库
- 前往 https://railway.com,创建新项目并连接仓库
- 在 Railway 控制面板中设置环境变量
RAPIDAPI_KEY - 将启动命令设置为
npx tsx src/server.ts - Railway 会自动从
process.env.PORT检测端口,并为你提供一个公开 URL
你可以在 POST 处理程序中检查 PASSWORD 环境变量来添加密码保护,也可以使用 Railway 内置的身份验证功能。
部署到其他平台
这是一个标准的 Node.js 应用,可在任何地方运行:
- Render:连接 GitHub 仓库,设置环境变量,即可完成
- Fly.io:运行
fly launch,使用fly secrets set RAPIDAPI_KEY=xxx设置密钥 - Vercel:作为无服务器函数部署(修改 server.ts 以导出处理程序)
- Docker:
FROM node:20-slim+npm install+npx tsx src/server.ts
API 参考
底层 API 是 RapidAPI 上的 Maps Data API: https://rapidapi.com/alexanderxbx/api/maps-data
使用的关键端点
| 端点 | 用途 | 示例 |
|---|---|---|
searchmaps.php | 按查询词和位置搜索商家 | ?query=pizza+in+10014&limit=20 |
geocoding.php | 将地址/邮编转换为纬度/经度 | ?query=10014,+US |
nearby.php | 搜索指定纬度/经度附近的地点 | ?query=pizza&lat=40.73&lng=-74.00 |
place.php | 获取一家商家的完整详情 | ?place_id=ChIJ... |
速率限制
RapidAPI 免费套餐有请求次数限制(请查看你的套餐)。客户端已硬编码为每秒 2 个请求,并会在收到 429 响应时自动重试。如果你的套餐允许更多请求,可调整 requestsPerSecond。
响应字段
每条结果包含:
place_id— 唯一的 Google Maps 标识符name— 商家名称address— 完整街道地址phone— 电话号码(如有列出)website— 网站 URL(如有列出)rating— 星级评分(1-5)reviews_count— Google 评论数量lat/lng— 坐标types/category— 商家类别(例如,“pizza_restaurant”)
提示
- “query in zipcode” 格式最适合在美国搜索。无需坐标。
- 每次搜索最多返回 20 条结果。如需扩大覆盖范围,可搜索多个有重叠的邮政编码。
- 按
place_id去重 — 同一家商家常会出现在相邻邮政编码的搜索结果中。 - 菜系/类别筛选:
types字段会告诉你商家的类型。可用它过滤无关结果(例如,搜索“restaurant”时排除“bar”)。 - 费用:请查看你的 RapidAPI 套餐。免费套餐通常足以用于测试。付费套餐适合批量抓取,价格也不高。
接下来要做什么
在 50 家商家的样本上运行 /icp-prompt-builder(上文所述的必需步骤)。然后使用筛选后的域名运行 /blitz-list-builder,查找业主联系人 — Google Maps 返回的是商家,而不是个人。
找到业主后:运行 /list-builder(第 5 阶段,电子邮件)补齐缺失的电子邮件,然后运行 /list-quality-scorecard 评分。
**或者先等等:**如果抓取结果少于 200 家商家,说明你的查询词和位置范围太窄。继续之前先扩大范围。
相关技能
/icp-prompt-builder— 必需的资格筛选步骤/blitz-list-builder— 查找各家商家的业主联系人/list-builder(第 5 阶段,电子邮件)— 补齐缺失的电子邮件/list-quality-scorecard— 为最终名单评分