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 to Claude Code
npx -y skills add SamJale/SearchApi-Claude-Plugin --skill local-leads-scraper --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of local-leads-scraper?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/samjale-local-leads-scraper)More formats (shields.io, HTML) on the badges page.
---
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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!