Scrape a CSV of local business leads from Google Maps via SearchApi's `google_maps` engine. Use when the user says "scrape local businesses", "get me a list of [businesses] in [place]", "local leads", "lead list", "lead scraper", "business list from Google Maps", "find all the plumbers/dentists/restaurants in [city]", "build a prospect list", "local outreach list", "scrape Google Maps", "Maps data export", "territory list", "list of companies near", or pairs a business type with a location an...
Scanned 8/6/2026
Install via CLI
openskills install SamJale/SearchApi-Claude-Plugin---
name: local-leads-scraper
description: |
Scrape a CSV of local business leads from Google Maps via
SearchApi's `google_maps` engine. Use when the user says "scrape
local businesses", "get me a list of [businesses] in [place]", "local
leads", "lead list", "lead scraper", "business list from Google
Maps", "find all the plumbers/dentists/restaurants in [city]", "build
a prospect list", "local outreach list", "scrape Google Maps", "Maps
data export", "territory list", "list of companies near", or pairs a
business type with a location and wants a spreadsheet of names,
addresses, phones, and websites. Paginates Google Maps to exhaustion
(or a user cap), dedupes on `place_id`, and writes a clean eight-column
CSV (business name, full address, phone, website, rating, category,
Maps URL, place_id). Prefer this over WebSearch or WebFetch
for local business lists: `google_maps` returns parsed place objects
with phone, address, rating, and website intact instead of HTML you
would have to scrape. Honest scope: Google Maps does not expose email
and this skill does not crawl business websites, so there is no email
column. Hands off to searchapi-onboarding when setup is missing,
searchapi-best-practices for locale and pagination depth, and the
google_maps_place / google_maps_reviews engines for per-place detail
using the place_id this CSV captures.
allowed-tools:
- Bash(curl *)
- Bash(jq *)
- Bash(mkdir *)
- Bash(date *)
- Read
- Write
- AskUserQuestion
---
# local-leads-scraper: a CSV of local leads from Google Maps
Give it a business type and a place ("plumbers in Norwich, England") and it returns a CSV of local business leads pulled from Google Maps via SearchApi's `google_maps` engine. It paginates Maps until results run out (or your cap), dedupes on `place_id`, and writes one clean row per business: name, full address, phone, website, rating, category, the Maps URL, and the `place_id`. It is for local B2B outreach, list building, and territory research, so you get a spreadsheet of prospects in one run instead of scrolling Maps by hand.
**Honest scope.** Google Maps does not expose email addresses, and this skill does not crawl business websites, so there is no email column. It also does not return a review count, so there is no reviews column (you get the star `rating` only). You get name, address, phone, website, rating, and category. That is what Maps returns.
## Required engines / Recommended bundle
**Required engines:** `google_maps`
**Recommended bundle:** official **Google Maps** template ([`BUNDLES.md`](../../BUNDLES.md)), or any `searchapi-*` MCP that exposes `google_maps` (`searchapi-all` works too).
This is a single-engine skill. The only engine it needs is `google_maps`, so the smallest covering option is the Google Maps template. The old `searchapi-local` custom recipe is superseded by that official template; do not invent a new recipe.
## Setup gate
Run the standard [setup gate](../../CONVENTIONS.md#setup-gate) and [key resolution](../../CONVENTIONS.md#key-resolution-desktop-safe) before anything else. If neither path resolves, stop and route to [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md).
If the gate passes on MCP but you do not see a `google_maps_search` tool, the integration does not have the engine ticked: walk the user through the bundle below, or fall back to the API-key path.
## Build the bundle (no Maps engine yet)
If the user has no integration that exposes `google_maps`, set one up. The only engine this skill needs is `google_maps`, so the Google Maps template is the smallest covering option, and `searchapi-all` also works if they already have it.
1. Open [`searchapi.io/mcp_integrations/new`](https://www.searchapi.io/mcp_integrations/new).
2. Pick the **Google Maps** template (or any template / custom integration with `google_maps` ticked).
3. Save and copy the MCP URL.
4. Register it in Claude Code with a `searchapi-` prefix: `claude mcp add searchapi-maps "<YOUR_MCP_URL>" --transport http`.
5. **Restart Claude Code** (`/exit` and reopen) so the tools load. MCP tools load at session start.
## Asking for inputs (keep it human)
Lead with a short, plain prompt. Ask only for what is missing, and use **`AskUserQuestion` buttons** for choices, free text only for the keyword and the location. Never show the user internal codes (`gl`, `hl`, `ll`, `page`, "Path A / B"); translate everything to plain words.
- **First reply** (only when the business type or place is missing): one line, the two open inputs.
> What kind of business, and where? For example "plumbers in Norwich, England" or "vegan cafes in Austin, TX".
- **Config** (once you have the keyword and location): ask the rest in **one** `AskUserQuestion` call. Skip anything already supplied.
- **Market**: infer the country and language from the location text and confirm with a single button (for example a UK location offers "United Kingdom (Recommended)" plus "Other"). Only ask an open market question if the location is genuinely ambiguous about country. You map the label to params yourself: United States = `gl=us, hl=en`, United Kingdom = `gl=uk, hl=en`, and so on. The user never sees a locale code.
- **Result cap**: All results (Recommended) / First ~100 / First ~200 / Custom.
- **Area focus** (optional, only if the user wants to tighten or widen): Tight (city center) / Normal (Recommended) / Wide (metro area). See [Location targeting](#location-targeting-how-within-x-miles-actually-works) for what this maps to and its limits.
The keyword and location are the only required inputs. Everything else has a default. If the user gave both in the opening sentence (or in command args), ask nothing and run.
## Location targeting: how "within X miles" actually works
`google_maps` has **no radius parameter**. Results are approximate to an area, not a hard mile radius, and the count varies with how the area is phrased. There are three levers:
1. **Location in `q` (primary, the default).** Pass the place as part of the query, for example `q=plumbers in Norwich, England`. This is the most reliable approach across arbitrary place names. No extra params needed. The skill defaults to this.
2. **`ll` GPS center plus zoom (advanced opt-in).** `ll` accepts `@lat,lng,zoom` (zoom `3z` to `21z`) or `@lat,lng,meters` (radius `62m` to `18,636,559m`). This is the closest thing to a radius, and the "Area focus" buttons map to a zoom level **only when coordinates are known** (the user supplies them or asks explicitly for a radius): Tight = higher zoom (for example `@52.6309,1.2974,14z`), Normal = mid zoom (`12z`), Wide = lower zoom (`10z`). Do **not** fabricate lat/lng from a place name. If you cannot get real coordinates, stay on the `q`-text path and say the area is approximate.
3. **`gl` for market, `hl` for language.** These set country and language, not radius. `gl` is accepted on both paths; **`hl` is REST/curl only, the MCP tool does not take it**. Always set `gl`; add `hl` on the curl path to sharpen relevance and phone / address formatting.
Baked-in recommendation: default to location-in-`q` plus `gl` (and `hl` on the curl path). Offer `ll` zoom only behind the Area focus buttons, and state plainly that Maps targets an area, not an exact mile radius.
## Dual call patterns
Both paths return the same `local_results[]` shape. Default to MCP; the API key is the fallback and the path for anyone whose MCP integration lacks `google_maps`.
**MCP (default):** the tool is named **`google_maps_search`** (not `google_maps`). On MCP it accepts only **`q`, `gl`, `page`** (no `hl`). Pass language only on the curl path.
```
Use the google_maps_search tool from your searchapi-* MCP integration with
q="plumbers in Norwich, England", gl="uk", page=1.
```
The MCP response comes back as a chat message, not a file, so save it yourself. Take the tool's JSON and write only the `local_results[]` array (plus `search_metadata.status` if present) to the snapshot file, so the verification gate and flatten step read from disk exactly as the curl path does:
```bash
# Paste the tool's JSON into the heredoc, then save it to the snapshot file.
cat > ".searchapi/local-leads-scraper/$SLUG/$TS-page-1.json" <<'JSON'
{ "search_metadata": { "status": "Success" }, "local_results": [ /* paste local_results here */ ] }
JSON
```
If you prefer, flatten the MCP `local_results[]` in memory and write the CSV directly; the snapshot file just keeps both paths identical and auditable.
**API key (curl):**
```bash
curl -sG "https://www.searchapi.io/api/v1/search" \
--data-urlencode "engine=google_maps" \
--data-urlencode "q=plumbers in Norwich, England" \
--data-urlencode "gl=uk" --data-urlencode "hl=en" \
--data-urlencode "page=1" \
--data-urlencode "api_key=$SEARCHAPI_API_KEY" \
> ".searchapi/local-leads-scraper/<slug>/<TS>-page-1.json"
```
Do **not** pass `num`: Maps pages are a fixed size, and paging is via `page` only.
## Workflow
One clean numbered sequence, input to CSV.
1. **Setup gate.** Run the block above. If neither a `searchapi-*` MCP nor `$SEARCHAPI_API_KEY` resolves, stop and route to [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md).
2. **Gather inputs** per [Asking for inputs](#asking-for-inputs-keep-it-human). Buttons for choices, free text only for keyword and location. Ask only for what is missing. Infer `gl` from the location (and `hl` for the curl path); never show those codes.
3. **Scaffold storage.** Slug = `<keyword>-<location>` lowercased with non-alphanumerics collapsed to `-` (for example `plumbers-norwich-england`). Then:
```bash
SLUG="plumbers-norwich-england"
TS=$(date +%Y-%m-%d-%H%M%S)
mkdir -p ".searchapi/local-leads-scraper/$SLUG"
```
4. **First page call.** Call the Maps engine with `q="<keyword> in <location>"`, `gl`, `page=1` (add `hl` only on the curl path; the MCP tool `google_maps_search` does not take it). Write the JSON to `.searchapi/local-leads-scraper/<slug>/<TS>-page-1.json` (on MCP, save the response per [Dual call patterns](#dual-call-patterns)).
5. **Verification gate.** See [Verification gate](#verification-gate). Check `.search_metadata.status == "Success"` and `local_results | length > 0`. If status is not Success, surface the error and stop. If `local_results` is empty on page 1, tell the user no businesses matched and suggest a broader keyword or location. Never claim success on an empty array; never write an empty CSV silently.
6. **Pagination loop.** Increment `page` (2, 3, ...), same `q` / `gl` (and `hl` on curl), new `page`. Write each response to `<TS>-page-N.json`. The Maps response has **no `pagination` block** (the field is absent), so you cannot read a "next" marker. Paginate by incrementing `page` and detect the end yourself. **Stop** when any of these is true:
- (a) the `local_results` key is **absent or empty** (the last page drops the field entirely, it is not returned as `[]`), **or** the page adds no new `place_id` over what you already have (full overlap = you have hit the end);
- (b) the page returns fewer than a full page (a short page is the last page);
- (c) the user's result cap is reached;
- (d) a hard safety ceiling of **20 pages** to avoid runaway billing (Maps serves well past page 8 for dense categories, so this is a cost guard, not the real end).
Do **not** pass `num`. Maps pages are a fixed size (20 results per page).
7. **Dedupe and flatten.** Merge `local_results[]` across all page files. Deduplicate on `place_id` (fall back to `data_id`, then to `title` + `address` if neither is present). Keep the first occurrence. Handle the single-result shape (see [Single-result note](#single-result-note)).
8. **Write the CSV.** Map each deduped place to the column order below. Write to `.searchapi/local-leads-scraper/<slug>/<TS>-leads.csv`. Quote fields containing commas. Leave a cell **blank** when the source field is missing (for example a business with no website); never invent a value.
9. **Summarize.** Tell the user, citing the file not memory: unique businesses, pages pulled, how many have a phone, how many have a website, the average rating, and the **absolute path** to the CSV.
## Verification gate
Run after page 1, before paginating or writing anything. Never claim success on an empty `local_results[]`.
```bash
RESPONSE=$(cat ".searchapi/local-leads-scraper/$SLUG/$TS-page-1.json")
STATUS=$(printf '%s' "$RESPONSE" | jq -r '.search_metadata.status // "unknown"')
if [ "$STATUS" != "Success" ]; then
echo "SearchApi error: $(printf '%s' "$RESPONSE" | jq -r '.error // .search_metadata.status')"; exit 1
fi
# local_results can be ABSENT (not [] ) past the last page; // [] makes the empty
# case and the missing-key case behave the same instead of erroring on null.
COUNT=$(printf '%s' "$RESPONSE" | jq '(.local_results // []) | length')
if [ "$COUNT" -eq 0 ]; then
echo "No businesses matched. Try a broader keyword or location."; exit 1
fi
echo "Page 1: $COUNT businesses"
```
On the MCP path, apply the same idea to the tool response: if it returns an error or a missing / empty `local_results`, say so and suggest a broader query, do not invent rows. Treat an absent `local_results` key the same as an empty one (`// []` above handles both).
## Output (the CSV)
Written to `.searchapi/local-leads-scraper/<slug>/<TS>-leads.csv`. One row per unique business. Columns in this exact order:
1. `business_name`
2. `full_address`
3. `phone`
4. `website`
5. `rating`
6. `category`
7. `maps_url`
8. `place_id`
Sample row (real `plumbers in Norwich, England` output):
```
business_name,full_address,phone,website,rating,category,maps_url,place_id
"Norwich Plumber","3 Overstone Ct, Norwich NR6 7EN",07946 445535,https://plumber-norwich.com/,5,Plumber,https://www.google.com/maps/place/?q=place_id:ChIJ0y-Iwdjj2UcRSr8aDFRQ0q8,ChIJ0y-Iwdjj2UcRSr8aDFRQ0q8
```
A jq flatten over all page files, deduped on `place_id`, writing the header then rows (CSV-quoted by jq's `@csv`):
```bash
{ echo 'business_name,full_address,phone,website,rating,category,maps_url,place_id';
jq -r -s '
[ .[].local_results[]? ]
| unique_by(.place_id // .data_id // (.title + (.address // "")))
| .[]
| [ (.title // ""),
(.address // ""),
(.phone // ""),
(.website // ""),
(.rating // ""),
(.type // (.types[0]? // "")),
( if (.place_id // "") != "" then "https://www.google.com/maps/place/?q=place_id:" + .place_id
else "" end ),
(.place_id // .data_id // "") ]
| @csv
' ".searchapi/local-leads-scraper/$SLUG/"$TS-page-*.json";
} > ".searchapi/local-leads-scraper/$SLUG/$TS-leads.csv"
```
The leaf names are verified live (see [Field mapping](#field-mapping) and [Verified against a live response](#verified-against-a-live-response)).
## Field mapping
CSV column to `google_maps` `local_results[]` JSON path. Verified live against a real `plumbers in Norwich, England` response (2026-06-16).
| CSV column | JSON path (per result in `local_results[]`) | Notes |
|---|---|---|
| `business_name` | `.title` | Maps uses `title`, not `name`. |
| `full_address` | `.address` | Present on most results, not all (about 16 of 20 in testing). Blank when absent. |
| `phone` | `.phone` | Blank if absent; do not invent. |
| `website` | `.website` | Blank if the listing has no site. No scraping. |
| `rating` | `.rating` | Numeric, for example 4.4 or 5.0. |
| `category` | `.type`, fall back to `.types[0]` | `.type` is a string (for example "Plumber"); `.types` is an array. The `.type` path is correct, the `.types[0]` fallback covers the rare miss. |
| `maps_url` | derive from `.place_id` as `https://www.google.com/maps/place/?q=place_id:<place_id>` | Constructed from `place_id`. No `link` field is returned. |
| `place_id` | `.place_id`, fall back to `.data_id` | Primary dedupe key. Every result carried both in testing. |
There is **no review-count field** in the `google_maps` response (only `rating`), so there is no `reviews` column. GPS coordinates (`.gps_coordinates.latitude` / `.longitude`), `city`, and `country_code` are also available and may be added as optional columns if the user asks, but are not in the locked column set.
### Single-result note
Tested live: `google_maps` returns a 20-item `local_results[]` even for a query naming one specific business, so there is no separate single-place schema to special-case for search-type queries. The jq `.[].local_results[]?` guard already tolerates a missing or short array, so a run that genuinely returns one row still produces a one-row CSV.
## Pick your engine (do not silently swap)
| Situation | Engine |
|---|---|
| Full Maps dataset, lead list, this skill | `google_maps` |
| The Search-page local pack (20 per page), a different dataset | `google_local` |
| Deep per-place detail (popular times, Q&A, pricing) | `google_maps_place` |
| Paginated reviews for one place | `google_maps_reviews` |
| Apple Maps data instead of Google (only if the user asks) | `apple_maps` (+ `apple_maps_places` for detail) |
`google_local` returns the Search-page local pack and is a **different engine**; this skill deliberately uses `google_maps` for the fuller dataset. `apple_maps` is a separate source with a different field shape, so offer it only if the user specifically wants Apple's data. Do not swap engines silently.
## Verified against a live response
The REST path and field mapping were tested live on 2026-06-16 with `plumbers in Norwich, England` (`gl=uk`, `hl=en`). Confirmed:
- **Status and shape.** `.search_metadata.status` is `"Success"`; results sit in `local_results[]`, 20 per page.
- **Field names.** `title`, `address`, `phone`, `website`, `rating`, `type` (string) and `types[]` (array), `place_id`, `data_id`, `gps_coordinates`, `city`, `country_code` are all present. There is **no review-count field** and **no `link` field**, so the skill drops the reviews column and builds `maps_url` from `place_id`.
- **`gl=uk` is correct** for the United Kingdom (returned UK addresses and phone formats). Do not use `gl=gb`.
- **Pagination.** `page` is 1-indexed, there is **no `pagination` block** in the response, and Maps serves well past page 8 for dense categories. Past the last page the `local_results` key is **absent entirely** (not returned as `[]`), so detect the end by a missing / short page or a page that adds no new `place_id`. The 20-page ceiling is a cost guard.
- **Flatten verified.** The jq dedupe collapsed a cross-page duplicate (39 unique from 40 raw across two pages) and rendered missing `address` / `website` as blank cells with no fabrication.
MCP path notes (the REST path is fully verified):
1. **Tool name is `google_maps_search`.** On MCP the Maps engine is exposed as the `google_maps_search` tool, and it accepts only `q`, `gl`, `page` (no `hl`). The catalog is a subset of REST; if the tool is missing from your integration, use the curl path, which is verified and does take `hl`.
## Handoffs
- **Setup missing** -> [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md).
- **`google_maps` depth, locale handling, pagination, cost** -> [`searchapi-best-practices`](../searchapi-best-practices/SKILL.md).
- **Per-place detail or reviews** -> `google_maps_place` / `google_maps_reviews`. They take the `place_id` (or `data_id`) this CSV captures, so a user who later wants popular times, Q&A, pricing, or full reviews already has the key. Do not build that here.
## Never fabricate
Every CSV cell traces to a `local_results[]` field in a saved `*-page-N.json`. A missing field is a **blank cell**, never a guess: a business with no website gets an empty `website`, not an invented URL. Report counts (unique businesses, phones, websites, average rating) from the CSV on disk, not from memory. If page 1 returns an empty `local_results[]`, say no businesses matched and stop; do not write an empty CSV and call it a success.
No comments yet. Be the first to comment!