Skip to main content
Python tutorial

How to scrape a list of qualified local businesses

Create a script that takes a city and business category, then returns a shortlist with available public contact details and gaps you could help each business fill.

By the Tapline teamLast updated 10 min read

A generic lead database gives you names and contact details. This workflow adds a reason to look at each business. If you sell marketing or automation services, the script looks for gaps you may be able to fill: no website on Maps, an unclaimed profile, a low review count, or complaints about booking and callbacks. By the end, you'll have a CSV with available public contact details, the gaps found for each business, and best-effort LinkedIn matches.

Short version

  • Searches a Google Maps grid for businesses.
  • Finds businesses with no website, an unclaimed profile, a rating below 4.0, or fewer reviews than average.
  • Fetches the latest reviews for each flagged business.
  • Finds matching LinkedIn company and person profiles.
  • Scores each business based on the gaps found.

Our Target

For this tutorial, we're looking at dentists in Austin. Change the inputs to match the market you want to target. The table shows three real businesses returned by the script on November 4, 2026.

BusinessObserved gapLinkedIn statusScoreFacts to use
Access Dental3.9 rating; recent low-rated reviews mentioned booking, phones, and wait timesCompany website and profile experience matched7Maps: 3.9 rating | Recent low-rated review topics: booking, phones, wait times | Owner responses: 19 across 20 newest reviews checked | LinkedIn company: website matched Maps | LinkedIn person: experience matched the business
Treaty Oak Dental171 reviews vs. a 568 area median; a recent low-rated review mentioned bookingNo trusted LinkedIn match5Maps: 171 reviews; area median: 568 | Recent low-rated review topics: booking | Owner responses: 13 across 20 newest reviews checked
Avenue Dental of South Austin184 reviews vs. a 568 area medianCompany website matched; no trusted person match3Maps: 184 reviews; area median: 568 | Owner responses: 20 across 20 newest reviews checked | LinkedIn company: website matched Maps

What you need before you start

  • Python 3.11.0.
  • A Tapline account and an API key in TAPLINE_API_KEY.
  • The requests library at version 2.32.5.
  • A business category and the center coordinates of your target location.
terminal
python3 -m venv .venv
source .venv/bin/activate
pip install "requests==2.32.5"
export TAPLINE_API_KEY="your-api-key"

Run it in your target location

Download the script and run the small Austin example first. Increase --grid-size and --max-shortlist when you want more businesses.

Download find_local_business_leads.py

bounded test
python3 find_local_business_leads.py dentist Austin 30.2672 -97.7431 \
--grid-size 1 \
--max-pages-per-tile 1 \
--max-businesses 20 \
--max-shortlist 10 \
--output leads.csv
captured output
qualified lead 1: score=5
qualified lead 2: score=3
qualified lead 3: score=7
wrote 3 leads from 20 unique businesses to leads.csv
requests: 15 {'maps_search': 1, 'maps_reviews': 3, 'brave_search': 6, 'linkedin_company': 3, 'linkedin_profile': 2}
documented full-page credits: 45 {'maps_search': 2, 'maps_reviews': 6, 'brave_search': 30, 'linkedin_company': 3, 'linkedin_profile': 4}

The default run widens the grid and allows up to 1,000 businesses and 200 shortlisted rows.

target location
python3 find_local_business_leads.py dentist Austin 30.2672 -97.7431

Step By Step

1. Find businesses

Search through a grid on Maps, paginate through and deduplicate the results.

step 1
def fetch_places(
client: TaplineClient,
query: str,
latitude: float,
longitude: float,
grid_size: int,
max_pages_per_tile: int,
max_businesses: int,
) -> list[dict]:
places_by_id = {}
for point_latitude, point_longitude in grid_points(latitude, longitude, grid_size):
cursor = ""
for _ in range(max_pages_per_tile):
page = client.get(
"/v1/google-maps/search",
"maps_search",
query=query,
latitude=point_latitude,
longitude=point_longitude,
zoom=MAPS_ZOOM,
limit=MAPS_PAGE_SIZE,
cursor=cursor,
gl="us",
hl="en",
)
for place in page["results"]:
feature_id = place.get("feature_id")
if feature_id:
places_by_id[feature_id] = place
if len(places_by_id) >= max_businesses:
return list(places_by_id.values())
cursor = page["pagination"].get("next_cursor") or ""
if not cursor:
break
return list(places_by_id.values())

2. Find gaps and fetch reviews

Flag listing gaps, then check the 20 newest reviews for booking and follow-up complaints.

step 2
def initial_gaps(place: dict, review_median: float) -> list[str]:
gaps = []
review_count = place.get("review_count")
rating = place.get("rating")
if not website_url(place):
gaps.append("no website")
if place.get("is_claimed") is False:
gaps.append("unclaimed profile")
if review_count is not None and review_median and review_count < review_median * LOW_REVIEW_RATIO:
gaps.append("review count under half the area median")
if rating is not None and rating < LOW_RATING:
gaps.append("rating under 4.0")
return gaps
def review_evidence(client: TaplineClient, place: dict) -> dict:
try:
page = client.get(
f"/v1/google-maps/places/{place['feature_id']}/reviews",
"maps_reviews",
sort="newest",
limit=REVIEWS_PER_BUSINESS,
gl="us",
hl="en",
)
except TaplineApiError as error:
return {
"checked": 0,
"owner_responses": 0,
"topics": [],
"excerpt": "",
"status": f"reviews error: HTTP {error.status_code}",
}
topics = []
excerpt = ""
owner_responses = 0
for review in page["reviews"]:
if review.get("owner_response"):
owner_responses += 1
text = review.get("text") or review.get("preview_text") or ""
matched_topics = []
if review.get("rating") is not None and review["rating"] <= 3:
matched_topics = [
topic for topic, pattern in COMPLAINT_PATTERNS.items() if pattern.search(text)
]
if matched_topics:
topics.extend(matched_topics)
if not excerpt:
excerpt = re.sub(r"\s+", " ", text).strip()[:180]
return {
"checked": len(page["reviews"]),
"owner_responses": owner_responses,
"topics": sorted(set(topics)),
"excerpt": excerpt,
"status": "checked",
}

3. Find matches on LinkedIn

Use Brave to find LinkedIn pages, then verify the website.

step 3 company
def linkedin_company_evidence(client: TaplineClient, place: dict) -> dict:
business_name = place["name"]
city = (place.get("location") or {}).get("city") or ""
results, search_status = brave_results(
client,
f'site:linkedin.com/company "{business_name}" {city}'.strip(),
)
candidate = best_company_result(results, business_name)
if not candidate:
return {
"status": f"{search_status}; no company result",
"url": "",
"employee_count": None,
"website": "",
"employees": [],
}
try:
company = client.get(
"/v1/linkedin/company",
"linkedin_company",
url=candidate["url"],
)
except TaplineApiError as error:
return {
"status": f"company error: HTTP {error.status_code}",
"url": candidate["url"],
"employee_count": None,
"website": "",
"employees": [],
}
maps_website = website_url(place)
company_website = company.get("website") or ""
if not maps_website:
status = "not trusted: Maps has no website to compare"
elif not same_domain(maps_website, company_website):
status = "not trusted: website mismatch"
else:
status = "trusted: website match"
return {
"status": status,
"url": company.get("url") or candidate["url"],
"employee_count": company.get("employeeCount"),
"website": company_website,
"employees": company.get("employees") or [],
}
step 3 person
def linkedin_person_evidence(
client: TaplineClient,
place: dict,
company_evidence: dict,
) -> dict:
business_name = place["name"]
results, search_status = brave_results(
client,
f'site:www.linkedin.com/in "{business_name}" owner OR "practice manager"',
)
candidate = best_profile_result(results, business_name, company_evidence["employees"])
if not candidate:
return {"status": f"{search_status}; no reliable person result", "name": "", "headline": "", "url": ""}
if company_evidence["status"] != "trusted: website match":
return {
"status": "not fetched: company website was not verified",
"name": "",
"headline": "",
"url": candidate["url"],
}
try:
profile = client.get(
"/v1/linkedin/profile",
"linkedin_profile",
url=candidate["url"],
)
except TaplineApiError as error:
return {
"status": f"profile error: HTTP {error.status_code}",
"name": "",
"headline": "",
"url": candidate["url"],
}
if not profile_has_business(profile, business_name):
status = "not trusted: experience did not match the business"
else:
status = "trusted: experience matched the business"
return {
"status": status,
"name": profile.get("name") or "",
"headline": profile.get("headline") or "",
"url": profile.get("url") or candidate["url"],
}

4. Rank and Export

Score by the gaps found and then save the data to CSV.

step 4
def build_evidence_summary(
place: dict,
gaps: list[str],
review_median: int,
reviews: dict,
company: dict,
person: dict,
) -> str:
facts = []
if "no website" in gaps:
facts.append("Maps: no website listed")
if "unclaimed profile" in gaps:
facts.append("Maps: profile unclaimed")
if "review count under half the area median" in gaps:
facts.append(
f"Maps: {place['review_count']} reviews; area median: {review_median}"
)
if "rating under 4.0" in gaps:
facts.append(f"Maps: {place['rating']} rating")
if reviews["topics"]:
facts.append(f"Recent low-rated review topics: {', '.join(reviews['topics'])}")
if reviews["checked"]:
facts.append(
f"Owner responses: {reviews['owner_responses']} across "
f"{reviews['checked']} newest reviews checked"
)
if company["status"] == "trusted: website match":
facts.append("LinkedIn company: website matched Maps")
if person["status"] == "trusted: experience matched the business":
facts.append("LinkedIn person: experience matched the business")
return " | ".join(facts)
def score_lead(
gaps: list[str],
reviews: dict,
company: dict,
person: dict,
) -> int:
score = 2 * len(gaps)
if reviews["checked"] and reviews["owner_responses"] == 0:
score += 1
if reviews["topics"]:
score += 3
if company["status"] == "trusted: website match":
score += 1
if person["status"] == "trusted: experience matched the business":
score += 1
return score

What it costs

StageCalculationCredits
Google Maps search50 calls x 2100
Reviews for 200 businesses200 x 2400
Company search and lookup200 x (5 + 1)1,200
Person search and lookup200 x (5 + 2)1,400
Total3,100

The full script uses 3,100 credits. A $9 pack includes 5,000 credits, so it covers the run. The free account includes 100 credits, enough to test the Maps search. The script makes about 850 requests and takes about 15 minutes at the free rate limit.

FAQ

Scraping public data is generally not considered illegal, but source terms and outreach laws still apply. This is not legal advice.

Tapline returns 20 places by default and accepts up to 100 per page.

Your list is ready to work

You now have a ranked list of businesses that you can contact and offer services to. Hand this tutorial to an agent to customise it further and come up with your own criteria.

Google Maps live demo.

Run a live request. No account or API key needed.

Place Search
Places·Reviews

Live Google Maps data · 5 free runs per operation each day

Place Search

Use Run to run this request against live Google Maps data. No API key needed.

Run a request to fetch a live response.
Get started

Start With
Google Maps Data

Sign up for 100 free credits with no card, run the bounded Austin example, then swap in your category and target location.

Try Google Maps place search on a real category.
Run live