A Lead-Gen Agent That Actually Verifies the Business Exists
Google Maps results carry addresses, ratings, categories, and websites. Wire them into an agent and you get qualified leads instead of a scraped phone list.


Most “lead generation” automation produces a spreadsheet of businesses that closed two years ago. The data is scraped from a directory that was itself scraped from a directory, and nobody checked.
Maps data is different in a useful way: it’s what Google is currently showing to people searching for that business. If a place has a live listing with an address, a rating, and a website, it exists.
What comes back
Serply’s Maps endpoint takes a Google-style query string in the path and returns structured place records:
import os
import requests
from urllib.parse import quote_plus
API_KEY = os.environ["SERPLY_API_KEY"]
def find_places(query: str, num: int = 20) -> list[dict]:
resp = requests.get(
f"https://api.serply.io/v1/maps/search/q={quote_plus(query)}&num={num}",
headers={"X-Api-Key": API_KEY},
timeout=30,
)
resp.raise_for_status()
return resp.json().get("places", [])
places = find_places("commercial HVAC contractors in Austin, TX")
Each record in places carries name, address, address_lines, website, domain, latitude, longitude, rating, review_count, categories, phone, timezone, opening_hours, and Google’s own place_id and data_id. The response also includes result_count and a metadata object describing the parse.
Two fields deserve care. review_count and phone come back as null on plenty of listings — Google doesn’t surface them consistently — so treat their absence as “unknown,” not “zero” or “no phone.” Filtering on review_count > 10 silently drops every listing where the count wasn’t rendered.
Qualifying
The point of an agent here isn’t finding places — that’s one API call. It’s deciding which ones are worth a human’s time.
def qualify(place: dict) -> tuple[bool, str]:
"""Cheap deterministic filters before spending any model tokens."""
if not place.get("website"):
return False, "no website"
rating = place.get("rating")
if rating is not None and rating < 3.5:
return False, f"low rating ({rating})"
cats = [c.lower() for c in place.get("categories", [])]
if any("residential" in c for c in cats):
return False, "residential focus"
return True, "passes filters"
candidates = [p for p in places if qualify(p)[0]]
Run the deterministic filters first, always. Every place you eliminate with a comparison is one you don’t pay a model to evaluate, and rating thresholds don’t need an LLM.
Enrichment
For the survivors, read their site. A homepage tells you more about fit than any directory category:
def read_site(url: str) -> str | None:
try:
resp = requests.post(
"https://api.serply.io/v1/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=60,
)
resp.raise_for_status()
return resp.text[:8000]
except requests.RequestException:
return None
Markdown mode returns the converted text as the response body, so read .text. That matters more here than usual: a contractor’s homepage is mostly navigation chrome and image tags, and markdown strips the noise that would otherwise eat your context window.
The scoring agent
from anthropic import Anthropic
client = Anthropic()
RUBRIC = """You score B2B sales leads for a commercial HVAC parts supplier.
Score 1-5 on fit. A 5 is a commercial contractor with multiple crews and an
explicit commercial services page. A 1 is a residential-only or single-operator shop.
Return JSON: {"score": int, "reason": str, "signals": [str]}
Base your answer only on the provided material. If the site says little, score low
and say so — do not infer from the company name."""
def score_lead(place: dict, site_text: str | None) -> dict:
material = f"""Business: {place.get('name')}
Address: {place.get('address')}
Categories: {', '.join(place.get('categories', []))}
Rating: {place.get('rating')} ({place.get('review_count')} reviews)
Website: {place.get('website')}
Site content:
{site_text or '(could not fetch)'}"""
msg = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=500,
system=RUBRIC,
messages=[{"role": "user", "content": material}],
)
return json.loads(msg.content[0].text)
The last line of the rubric is the one that keeps this honest. Without it, a model handed a business called “Austin Commercial Air Systems” and an empty site will confidently score it a 5 based entirely on the name.
Putting it together
import json
from concurrent.futures import ThreadPoolExecutor
places = find_places("commercial HVAC contractors in Austin, TX", num=20)
candidates = [p for p in places if qualify(p)[0]]
with ThreadPoolExecutor(max_workers=5) as pool:
sites = list(pool.map(lambda p: read_site(p["website"]), candidates))
leads = []
for place, site in zip(candidates, sites):
result = score_lead(place, site)
if result["score"] >= 4:
leads.append({**place, **result})
leads.sort(key=lambda l: l["score"], reverse=True)
for lead in leads:
print(f"{lead['score']} {lead['name']:<40} {lead.get('phone') or '—'}")
print(f" {lead['reason']}")
Coverage
One Maps query returns one neighborhood’s worth of results. Real territory coverage means gridding: run the same query against several sub-areas and dedupe on place_id, which is stable across queries in a way that names and addresses are not.
areas = ["North Austin", "South Austin", "Round Rock, TX", "Cedar Park, TX"]
seen, all_places = set(), []
for area in areas:
for p in find_places(f"commercial HVAC contractors in {area}"):
if p.get("place_id") and p["place_id"] not in seen:
seen.add(p["place_id"])
all_places.append(p)
Expect meaningful overlap between adjacent areas — that’s the dedupe earning its keep, not a bug.