Skip to article
Tutorial

How to scrape Google Maps leads without getting blocked

A practical walkthrough of scraping Google Maps for local business leads using the Stekpad extract verb.

Stekpad Team6 min read
On this page

Local lead generation still runs on one question: who is open near me, and how do I contact them? Google Maps is the biggest public answer. Every dentist, plumber, bakery and gym has a listing with a name, an address, a phone number, a website, a star rating and a review count. That is a working prospect list sitting behind a search box.

This post walks through the full pipeline. You run search to get a list of businesses, you run scrape on each result, you pipe the rows through find_emails and phone_enrich, and you export the dataset as a CSV that drops into any CRM. The whole thing takes under 100 lines of code and costs a few hundred credits on the Builder pack.

One caveat up front. Google's terms of service do not love scrapers, and mass-spamming dentists in Lyon is a good way to get your inbox blocked at the domain level. Treat this as a research and verification workflow, not a cold-outreach cannon. Build one good list, verify it, call the ten best prospects by hand. That is a better use of your Friday than dumping 4,000 emails into a sequencer.

What you get in a Google Maps row

Before writing code, it helps to know what a clean row looks like. Here is the column set we target, with the source for each field.

  • name — the business name, from the listing title
  • category — the Google Maps category, for example "Dentist" or "Bakery"
  • address — full street address, from the listing info panel
  • phone — phone number as displayed, later normalized to E.164
  • website — the business website URL if the listing has one
  • rating — average star rating, 0.0 to 5.0
  • reviews_count — integer count of reviews
  • maps_url — the canonical Google Maps URL of the listing
  • latitude, longitude — coordinates from the listing
  • emails[] — added later by the find_emails enricher on the website
  • phone_e164 — added later by phone_enrich

That is a real lead record. Name, address, phone, website, email, rating. Enough to sort by review count, pick the top ten in a city, and decide which five are worth a real conversation.

Step 1: search for businesses

Stekpad's `/v1/search` verb issues a web search, optionally scrapes the results, and stores them in a dataset. For Google Maps lead work, the query shape that produces clean results is best <category> in <city>. The search backend returns web results first, but Google Maps listings surface reliably in the top ten for local intent queries.

Here is the first call. We search for dentists in Lyon, ask Stekpad to scrape each result, and target a new dataset named "Dentists Lyon".

bash
curl -X POST https://api.stekpad.com/v1/search \
-H "Authorization: Bearer stkpd_live_..." \
-H "Content-Type: application/json" \
-d '{
"query": "best dentists in Lyon",
"num_results": 20,
"scrape_results": true,
"scrape_options": { "formats": ["markdown", "json"] },
"country": "FR",
"language": "fr",
"dataset": { "type": "table", "name": "Dentists Lyon" }
}'

This one call costs 5 credits for the search plus 1 credit per result scraped, so 25 credits total for 20 results. The response gives you a run_id, the list of results with position, url, title, snippet, and a scrape.markdown block for each. The rows are already in the Dentists Lyon dataset, keyed by canonical URL.

Stekpad stores every search result in a dataset you can re-query tomorrow. Firecrawl returns the same data by value and forgets it. If you want to re-run the same query next month to pick up new listings, the dataset is the reason you stay.

Step 2: extract structured fields from each listing

A markdown dump of a Google Maps listing is not a lead. You want typed columns. That is what `/v1/extract` is for. You pass a JSON schema, Stekpad re-scrapes the URL under the hood, and the LLM extractor returns rows that match your schema.

Here is the schema we use for a Google Maps listing, passed to the extract verb in Python via the official SDK.

python
from stekpad import Stekpad
 
sp = Stekpad(api_key="stkpd_live_...")
 
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"category": {"type": "string"},
"address": {"type": "string"},
"phone": {"type": "string"},
"website": {"type": "string", "format": "uri"},
"rating": {"type": "number"},
"reviews_count": {"type": "integer"},
"latitude": {"type": "number"},
"longitude": {"type": "number"},
},
"required": ["name", "address"],
}
 
# Pull the URLs stored by the previous search run.
rows = sp.datasets.rows("Dentists Lyon")
urls = [r["url"] for r in rows if "maps" in r["url"]]
 
run = sp.extract(
urls=urls,
schema=schema,
prompt="Extract the business name, category, address, phone, website, rating, review count and coordinates from this Google Maps listing.",
dataset={"type": "table", "name": "Dentists Lyon — structured"},
)
 
print(run.run_id, run.status)

The extract verb is async for batches. You poll /v1/runs/:id or wire a webhook. At 5 credits per URL, 20 listings costs 100 credits. Add the 25 from Step 1 and you are at 125 credits for a full structured dataset. On the Starter pack that is six cents.

Why the schema beats scraping HTML directly

We used to recommend parsing Google Maps HTML with selectors. The HTML is fragile and Google rewrites it every few months. The LLM extractor reads the rendered markdown, returns JSON that matches your schema, and validates the output. When Google moves a div, nothing breaks. When Google adds a new field, you add a key to the schema and re-run. See the enrichment pipeline guide for the same pattern applied to company profiles.

Step 3: enrich with emails and normalized phones

You now have 20 rows with a website and a phone number each. Neither is directly usable yet. The website field points at the business homepage, not the email address. The phone field is formatted the way Google displayed it, which is different for every listing. Two enrichers fix this.

`find_emails` walks the website URL, fetches /contact, /about, /legal and the footer, extracts email addresses, filters disposable domains, and returns a deduplicated array. One credit per row. The enricher is in-house — no Hunter, no Clearbit, no data leaves our stack.

`phone_enrich` takes the raw phone column, normalizes it to E.164 using libphonenumber, detects country, line type (mobile, landline, VoIP) and carrier hint, and adds four typed columns. One credit per row.

typescript
import { Stekpad } from "@stekpad/sdk";
 
const sp = new Stekpad({ apiKey: "stkpd_live_..." });
 
await sp.enrich({
dataset: "Dentists Lyon — structured",
pipeline: [
{ enricher: "find_emails", input_column: "website" },
{ enricher: "phone_enrich", input_column: "phone" },
],
});

After this call, every row gains emails[], phone_e164, phone_country, phone_type and phone_carrier_hint. The pipeline is persisted to the dataset, so any new row added later by a re-run of the search verb is enriched automatically. That is the difference between a dataset and a one-shot response.

Step 4: export to CSV for your CRM

Every table dataset exports as CSV with one call. Here is the curl.

bash
curl -X GET "https://api.stekpad.com/v1/datasets/dentists-lyon-structured/export?format=csv" \
-H "Authorization: Bearer stkpd_live_..." \
-o dentists-lyon.csv

The resulting CSV has every column we built: name, category, address, phone, phone_e164, phone_country, phone_type, website, emails, rating, reviews_count, latitude, longitude. Drop it into HubSpot, Pipedrive, Attio, Close, or a Google Sheet. The emails column is a comma-joined list — if your CRM wants one email per row, the scrape-to-google-sheets workflow shows the unpivot pattern.

Step 5: the honest part about Google's terms

Google Maps data is public. Anyone can open a browser, run the search, and read the listings. Scraping is the programmatic version of that. It is not a legal gray zone in most jurisdictions when the data is public and the use is research or enrichment.

Spam is a different story. If you take a list of 4,000 dentists and fire a cold email sequence at them, you are going to hit spam filters, burn your sending domain, and annoy 4,000 people. Google's terms do not care about that directly, but your email provider will, and the Loi informatique et libertés (in France) or the GDPR (across the EU) absolutely do when the contact is a named person at a small business.

The pattern that works:

  1. Scrape a tight list — one city, one category, 20 to 50 listings
  2. Sort by review count and rating
  3. Pick the top 10 prospects by hand
  4. Write 10 personal emails, not 10 sequences
  5. Re-run next month for new listings

That is what the dataset is for. It is not a blast list. It is a prospect list you update.

Scrape the web. Store it. Enrich it. Call the ten best prospects by name, not the 400 worst by template.

Step 6: automation via MCP

If you live in Claude Desktop or Cursor, the whole flow above is available as MCP tools. You install the Stekpad MCP server once, then you talk to Claude in plain English.

text
> Search Google for "best plumbers in Bordeaux", scrape the top 20 results,
extract name address phone website rating, then enrich the websites with
find_emails and the phones with phone_enrich. Store everything in a dataset
called "Plumbers Bordeaux".

Claude calls search, then extract, then enrich, then reports the dataset id. You run the same pipeline by dictating it. See the MCP explainer for growth teams for the install steps and the config JSON.

Next steps

Stekpad Team
We build Stekpad. We scrape the web, store it, and enrich it — from an API, from an app, or from Claude.

Try the API. Free to start.

3 free runs a day on the playground. No credit card. Install MCP for Claude in 60 seconds.

How to scrape Google Maps leads without getting blocked — Stekpad — Stekpad