<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Jim Miller]]></title><description><![CDATA[Jim Miller]]></description><link>https://jimmiller-gmaps.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 26 Oct 2026 23:17:05 GMT</lastBuildDate><atom:link href="https://jimmiller-gmaps.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Build a Local-Business Lead List with the Google Maps Scraper API in Python]]></title><description><![CDATA[Search by keyword and area, keep distinct businesses, and save a CSV with optional recent reviews.]]></description><link>https://jimmiller-gmaps.hashnode.dev/build-a-local-business-lead-list-with-the-google-maps-scraper-api-in-python</link><guid isPermaLink="true">https://jimmiller-gmaps.hashnode.dev/build-a-local-business-lead-list-with-the-google-maps-scraper-api-in-python</guid><category><![CDATA[Python]]></category><category><![CDATA[api]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[lead generation]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Jim Miller]]></dc:creator><pubDate>Thu, 22 Oct 2026 13:47:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ada12afbd84b29790dec5c3/40bae68c-1ab9-4dfe-b39b-43fb3eb3a27a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A plumber shows up in two searches, and now my sheet has two rows for the same shop. One row has the phone number I need. The other tells me which area I searched. I want one row with both search labels, ready to check before I add it to my CRM.</p>
<p>Here's how I'd build that list with Python and the <a href="https://gmapsscraper.ai/google-maps-scraper-api">GMapsScraper.AI REST API</a>. We'll search by keyword and area, combine repeat hits, and write a CSV. Then you can add recent reviews for a few businesses you choose.</p>
<h2>Start with a small search</h2>
<p>Get a key from <strong>Dashboard &gt; Settings &gt; Create API Key</strong> on <a href="https://gmapsscraper.ai">the GMapsScraper.AI website</a>. Install <code>requests</code> and put your key in an environment variable:</p>
<pre><code class="language-bash">python3 -m pip install requests
export GMAPS_API_KEY="replace_with_your_key"
</code></pre>
<p>Keep your real key out of the Python file and version control. The script sends it in an <code>Authorization: Bearer</code> header.</p>
<p>I use two keywords below: <code>plumber</code> and <code>dental clinic</code>. Swap them for the businesses you need. Add an entry to <code>LOCATIONS</code> for each area. The script searches every keyword in every area.</p>
<p>Set <code>ll</code> yourself. It takes <code>@latitude,longitude,zoom</code> and defaults to a US location when left out. The coordinates below are an example. Change the label and coordinates together, then check the returned addresses against your sales area. Set <code>hl</code> for language and <code>gl</code> for country; their defaults are <code>en</code> and <code>us</code>.</p>
<p>All four Python blocks below go in <strong>one file, <code>build_leads.py</code>, in the order shown</strong>. The shell commands stay in your terminal.</p>
<h2>Give requests a little room to recover</h2>
<p>First, add the settings and request helper:</p>
<pre><code class="language-python">import csv
import json
import os
import random
import time

import requests

API = "https://gmapsscraper.ai/api/v1"
KEYWORDS = ["plumber", "dental clinic"]
LOCATIONS = {
    "Example area": "@40.6970194,-74.3093048,11z",
}
MAX_PAGES = 2
ENRICH_REVIEWS = False

COLUMNS = [
    "Name", "Fulladdress", "Categories", "Phone", "Website",
    "Domain", "Average Rating", "Review Count", "Google Maps URL",
    "Place Id", "Cid", "Fid", "emails", "facebook_links",
    "instagram_links", "linkedin_links", "_keywords", "_locations",
]


def post(session, endpoint, body, attempts=5):
    if attempts &lt; 1:
        raise ValueError("attempts must be positive")
    for attempt in range(attempts):
        try:
            response = session.post(
                f"{API}/{endpoint}", json=body, timeout=(10, 60)
            )
        except (requests.ConnectionError, requests.Timeout):
            if attempt == attempts - 1:
                raise
        else:
            retryable = (
                response.status_code == 429
                or 500 &lt;= response.status_code &lt; 600
            )
            if not retryable or attempt == attempts - 1:
                response.raise_for_status()
                return response.json()
        time.sleep(2 ** attempt + random.uniform(0, 1))
</code></pre>
<p>Each API key has a rate limit. If a call gets HTTP 429 or a 5xx error, this helper waits and tries again. The wait grows after each failure, with a small random delay added. It also retries connection errors and timeouts.</p>
<p>Five attempts is my script setting. After that, the error stops the run. Other HTTP errors stop it right away, so you can fix a bad key or request. The timeout keeps a call from waiting forever.</p>
<p>If the terminal shows an error, read it before running the whole job again. For example, a rejected key needs a key check. Adding more retries won't fix it. Keeping that rule in one helper makes the search and review loops easier to read.</p>
<h2>Fetch pages and combine repeat hits</h2>
<p>Search results arrive one page per call. <code>MAX_PAGES</code> puts a ceiling on each keyword-and-area search. An empty <code>data</code> list ends that search early.</p>
<p>The response also has <code>total</code> and <code>params</code>. For this loop, we only need <code>data</code>, which holds the business rows.</p>
<pre><code class="language-python">def search_pages(session, keyword, area, ll, max_pages):
    if max_pages &lt; 1:
        raise ValueError("max_pages must be positive")
    rows = []
    for page in range(1, max_pages + 1):
        payload = post(session, "search", {
            "q": keyword, "page": page, "ll": ll,
            "hl": "en", "gl": "us", "extra": False,
        })
        batch = payload["data"]
        if not batch:
            break
        for business in batch:
            rows.append({
                **business,
                "_keywords": [keyword],
                "_locations": [area],
            })
    return rows


def business_key(row):
    for field in ("Place Id", "Cid", "Fid"):
        value = row.get(field)
        if value is not None and str(value).strip():
            return (field, str(value).strip())
    name = str(row.get("Name") or "").strip().casefold()
    address = str(row.get("Fulladdress") or "").strip().casefold()
    return ("name_address", name, address)


def dedupe(rows):
    businesses = {}
    for row in rows:
        key = business_key(row)
        if key not in businesses:
            businesses[key] = dict(row)
            continue
        lead = businesses[key]
        for field, value in row.items():
            if field in ("_keywords", "_locations"):
                lead[field] = sorted(set(lead.get(field, []) + value))
            elif lead.get(field) in (None, "", []):
                lead[field] = value
    return list(businesses.values())
</code></pre>
<p>The dictionary uses <code>Place Id</code> first, then <code>Cid</code>, then <code>Fid</code>. If all three are missing, it uses the name and full address together. Trimming spaces and ignoring letter case helps that last check.</p>
<p>When two rows share a key, I keep the first filled value for each field and merge their keyword and location labels. That lets you see how a shop entered the list without keeping each search hit as a separate lead.</p>
<p>This simple check can miss a match if one row has a Place Id and another has only a Cid. Also, two rows with no IDs, name, or address would share a key. Check those sparse rows and repeated names before importing the CSV. The labels help you trace them back to a search.</p>
<p>For example, a shop found under both keywords gets one row when its key matches. Its <code>_keywords</code> cell keeps both terms. If it also appears in a second area, <code>_locations</code> keeps both area labels. You can filter by either column later without collecting the same business again just to recover its source.</p>
<h2>Save the CSV and add reviews if needed</h2>
<p>I leave <code>extra</code> set to <code>False</code> for the first run. Change it to <code>True</code> to request emails and social links. The free API doesn't return those extra fields, and emails are available only when found. Leave missing emails blank.</p>
<pre><code class="language-python">def cell(value):
    if isinstance(value, list):
        return "; ".join(str(item) for item in value)
    return "" if value is None else value


def write_csv(rows, path="leads.csv"):
    with open(path, "w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=COLUMNS)
        writer.writeheader()
        for row in rows:
            writer.writerow({field: cell(row.get(field))
                             for field in COLUMNS})


def newest_reviews(session, business, max_pages=1):
    if max_pages &lt; 1:
        raise ValueError("max_pages must be positive")
    if business.get("Fid"):
        target = {"fid": business["Fid"]}
    elif business.get("Google Maps URL"):
        target = {"url": business["Google Maps URL"]}
    else:
        return []
    reviews = []
    for page in range(1, max_pages + 1):
        payload = post(session, "reviews", {
            **target, "page": page, "sort_by": 2,
        })
        batch = payload["reviews"]
        reviews.extend(batch)
        if not batch or not payload["next"]:
            break
    return reviews
</code></pre>
<p><code>csv.DictWriter</code> handles commas and quotes in business names and addresses. <code>cell()</code> joins lists with semicolons, so categories, social links, and search labels fit in single cells. Missing fields become blank cells. The column names match the API keys, including spaces and capitals.</p>
<p>For reviews, the helper uses <code>Fid</code> or a Google Maps place URL. <code>sort_by: 2</code> asks for newest reviews. It stops when <code>next</code> is false, when reviews are empty, or when it reaches its page cap. The default is one page per business. To fetch up to two pages, pass <code>max_pages=2</code> in the call to <code>newest_reviews()</code>. A false <code>next</code> still stops it early. I save the review response in JSON so you can inspect its contents without squeezing it into CSV columns.</p>
<h2>Put the steps together</h2>
<p>The last block connects the functions. It writes the base list before making any review requests.</p>
<pre><code class="language-python">def main():
    key = os.environ["GMAPS_API_KEY"].strip()
    if not key:
        raise ValueError("GMAPS_API_KEY is empty")
    with requests.Session() as session:
        session.headers.update({"Authorization": f"Bearer {key}"})
        rows = []
        for area, ll in LOCATIONS.items():
            for keyword in KEYWORDS:
                rows.extend(search_pages(
                    session, keyword, area, ll, MAX_PAGES
                ))
        leads = dedupe(rows)
        write_csv(leads)
        print(f"Saved {len(leads)} businesses to leads.csv")

        if ENRICH_REVIEWS:
            # Replace this slice with businesses you want to check.
            shortlist = leads[:3]
            enriched = []
            for business in shortlist:
                enriched.append({
                    "Name": business.get("Name"),
                    "Fid": business.get("Fid"),
                    "Google Maps URL": business.get("Google Maps URL"),
                    "reviews": newest_reviews(session, business),
                })
            with open("shortlist_reviews.json", "w",
                      encoding="utf-8") as handle:
                json.dump(enriched, handle, ensure_ascii=False, indent=2)


if __name__ == "__main__":
    main()
</code></pre>
<p>Run <code>python3 build_leads.py</code> from your terminal. If the key is missing or empty, it stops before sending requests. You'll find <code>leads.csv</code> in your current directory. Another run overwrites it, so rename any export you want to keep.</p>
<p>Leave <code>ENRICH_REVIEWS = False</code> while you check the base list. When you're ready, choose your businesses in <code>shortlist</code> and turn the flag on. The slice shown picks the first three rows; it doesn't rank them. If a review request fails, the CSV is already saved.</p>
<p>You can also fetch photos through <code>POST /api/v1/photos</code>, using <code>fid</code> or <code>url</code> and <code>page</code>. The response includes <code>photos</code>, <code>next</code>, <code>total</code>, <code>url</code>, <code>params</code>, and <code>place</code>. Give that loop its own page cap if you add it.</p>
<h2>Keep the run small enough to inspect</h2>
<p>API usage draws from a request quota, separate from web app credits. Check the <a href="https://gmapsscraper.ai/pricing">GMapsScraper.AI pricing details</a> when planning your run. The free web app plan has 1,000 monthly credits, allows two keywords per task and one task at a time, and exports CSV or JSON.</p>
<p>For the script, multiply keywords by locations by <code>MAX_PAGES</code> to see the search call ceiling before retries. Reviews and photos add calls. Start small, open the CSV, and check the fields and addresses before adding more areas. A page cap keeps the run bounded but may leave results uncollected.</p>
<p>I like to add a status column before a CRM import: keep, skip, or check. Read the website and confirm the business fits your offer. That step often matters more than adding another search keyword.</p>
<p>Before a larger run, try opening the file in a spreadsheet. Check a row with several categories, a row with a missing website, and any repeated names. You should see readable lists and blank cells where data is missing. Keep the IDs beside your notes so you can match a later export to the businesses you already checked. If you change the search settings, save that version of the script too. It gives you a clear record of the keywords, areas, and page cap behind each export.</p>
<h2>Use the list with care</h2>
<p>GMapsScraper.AI collects publicly available business data. Respect Google's terms and privacy and anti-spam laws, including GDPR, CCPA, and CAN-SPAM. Collect only what you need, honor opt-outs, and don't target individual reviewers.</p>
<p><em>Jim Miller, Growth at GMapsScraper.AI</em></p>
]]></content:encoded></item></channel></rss>