On this page
- What you get in a Google Maps row
- Step 1: search for businesses
- Step 2: extract structured fields from each listing
- Why the schema beats scraping HTML directly
- Step 3: enrich with emails and normalized phones
- Step 4: export to CSV for your CRM
- Step 5: the honest part about Google's terms
- Step 6: automation via MCP
- Next steps
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 titlecategory— the Google Maps category, for example "Dentist" or "Bakery"address— full street address, from the listing info panelphone— phone number as displayed, later normalized to E.164website— the business website URL if the listing has onerating— average star rating, 0.0 to 5.0reviews_count— integer count of reviewsmaps_url— the canonical Google Maps URL of the listinglatitude,longitude— coordinates from the listingemails[]— added later by thefind_emailsenricher on the websitephone_e164— added later byphone_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".
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.
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.
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.
curl -X GET "https://api.stekpad.com/v1/datasets/dentists-lyon-structured/export?format=csv" \ -H "Authorization: Bearer stkpd_live_..." \ -o dentists-lyon.csvThe 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:
- Scrape a tight list — one city, one category, 20 to 50 listings
- Sort by review count and rating
- Pick the top 10 prospects by hand
- Write 10 personal emails, not 10 sequences
- 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.
> 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
- Read the `search` API reference for the full parameter list including
country,languageandnum_results. - Read the `extract` API reference to learn about schema validation and retries.
- Browse the enrichers catalog for the other 17 enrichers you can chain onto the same dataset.
- Install the Stekpad MCP server in Claude Desktop and run the pipeline from a chat window.