google-maps-list-builder
Googleマップから地域ビジネスをカテゴリと地域で抽出し、CSVフォーマットで出力します。メールリスト充実化に活用できます。飲食店、クリニック、ジム、サロン、工事業者など、中小企業向けキャンペーンに最適です。RapidAPI Maps Data APIを使用しています。出力結果は /blitz-list-builder(オーナー連絡先の特定用)または /email-waterfall(既に名前がある場合)に直接連携できます。
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).
SKILL.md 本文
Google Maps リストビルダー
Google Maps からビジネスリストをスクレイピングするための自己完結型ツール。検索クエリ(例:「ピザレストラン」)と場所(郵便番号、市区町村、または座標)を指定すると、マッチするすべてのビジネスの構造化データが CSV に出力されます。
コールドメールフロー内でのこのツールの位置づけ
Google Maps は企業情報(名前、ドメイン、電話、住所、評価)を提供しますが、人物情報は提供しません。コールドメールを実行するには:
- このスキルを実行 →
company_domainを含むビジネス CSV を取得 - 50件のサンプルで
/icp-prompt-builderを実行 — ダウンサーム富化に費用を払う前に不適切な見込み客をフィルタリングするための認定プロンプトを調整 - フィルタリングされた CSV で
/blitz-list-builderを実行 → 各ビジネスにオーナー/マネージャーを追加 /email-waterfallを実行 → 不足しているメールアドレスを補充/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 ファイルを作成(.gitignore に .env を追加):
RAPIDAPI_KEY=your_key_here
ファイル構造
google-maps-scraper/
data/
us-zip-codes.csv # 市区町村、州、緯度経度、人口を含む 42,734 件の米国郵便番号
src/
index.ts # CLI エントリーポイント
client.ts # レート制限付き RapidAPI Maps Data クライアント
types.ts # TypeScript インターフェース
csv.ts # CSV エクスポート
zips.ts # 郵便番号ローダー(州、市区町村、人口でフィルタリング)
バンドルされた郵便番号データベース
リポジトリには 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; // 中心緯度(「郵便番号のquery」形式を使用する場合はオプション)
lng?: number; // 中心経度
limit?: number; // 検索ごとの最大結果数(デフォルト 20、最大 20)
zoom?: number; // マップズームレベル(デフォルト 13 = 近隣)
country?: string; // 国コード(デフォルト "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); // ヘッダーをスキップ
cache = lines.map(line => {
// クォートされたフィールドを処理(area_codes はカンマを含む可能性)
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;
}
/** US 州の郵便番号を取得(2 文字コード、例:"CA", "TX") */
export function getZipsByState(stateCode: string): ZipEntry[] {
return loadAll().filter(z => z.state.toUpperCase() === stateCode.toUpperCase());
}
/** 市区町村名の郵便番号を取得(大文字小文字を区別しない、部分一致) */
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;
});
}
/** 人口がしきい値を超える郵便番号を取得 */
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;
});
}
/** ロードされたすべての郵便番号エントリを取得 */
export function getAllZips(): ZipEntry[] {
return loadAll();
}
src/client.ts
これはコア API クライアントです。レート制限(2 req/秒)と指数バックオフでの再試行を処理します。
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)),
});
}
/** ビジネスを Google Maps で検索 */
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 || []);
}
/** 郵便番号またはアドレスを緯度経度にジオコード */
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);
}
// すべてのソースからロケーションリストを構築
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}`);
}
}
// 重複排除
const unique = dedup(allPlaces);
console.log(`\nTotal: ${allPlaces.length} results, ${unique.length} unique after dedup`);
// エクスポート
await exportCSV(unique, output);
console.log(`Saved to: ${output}`);
const duration = ((Date.now() - start) / 1000).toFixed(1);
console.log(`Done in ${duration}s`);
// サンプルを出力
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
# 3 つの NYC 郵便番号のピザ場所を検索
npm run scrape -- --query="pizza restaurant" --zips=10014,10013,10012
# テキサス州のすべての歯科医を検索(人口 >= 5000 の郵便番号)
npm run scrape -- --query="dentist" --state=TX --min-pop=5000
# カリフォルニア州のすべてのジム(2,657 個の郵便番号すべて — 時間がかかります)
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
プログラマティック使用
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,
});
Web アプリとしてデプロイ(オプション)
CLI の代わりに(または加えて)ブラウザ UI が必要な場合は、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,
});
// シンプルな HTML フォーム
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 {}
}
// 重複排除
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; });
// CSV をエクスポートしダウンロードとして送信
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 リ
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- growthenginenowoslawski
- ライセンス
- MIT
- 最終更新
- 2026/5/4
Source: https://github.com/growthenginenowoslawski/coldoutboundskills / ライセンス: MIT