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.
| Business | Observed gap | LinkedIn status | Score | Facts to use |
|---|---|---|---|---|
| BusinessAccess Dental | Observed gap3.9 rating; recent low-rated reviews mentioned booking, phones, and wait times | LinkedIn statusCompany website and profile experience matched | Score7 | Facts to useMaps: 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 |
| BusinessTreaty Oak Dental | Observed gap171 reviews vs. a 568 area median; a recent low-rated review mentioned booking | LinkedIn statusNo trusted LinkedIn match | Score5 | Facts to useMaps: 171 reviews; area median: 568 | Recent low-rated review topics: booking | Owner responses: 13 across 20 newest reviews checked |
| BusinessAvenue Dental of South Austin | Observed gap184 reviews vs. a 568 area median | LinkedIn statusCompany website matched; no trusted person match | Score3 | Facts to useMaps: 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
requestslibrary at version 2.32.5. - A business category and the center coordinates of your target location.
python3 -m venv .venvsource .venv/bin/activatepip 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
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
qualified lead 1: score=5qualified lead 2: score=3qualified lead 3: score=7wrote 3 leads from 20 unique businesses to leads.csvrequests: 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.
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.
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] = placeif len(places_by_id) >= max_businesses:return list(places_by_id.values())cursor = page["pagination"].get("next_cursor") or ""if not cursor:breakreturn 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.
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 gapsdef 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 = 0for review in page["reviews"]:if review.get("owner_response"):owner_responses += 1text = 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.
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 [],}
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.
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 += 1if reviews["topics"]:score += 3if company["status"] == "trusted: website match":score += 1if person["status"] == "trusted: experience matched the business":score += 1return score
What it costs
| Stage | Calculation | Credits |
|---|---|---|
| StageGoogle Maps search | Calculation50 calls x 2 | Credits100 |
| StageReviews for 200 businesses | Calculation200 x 2 | Credits400 |
| StageCompany search and lookup | Calculation200 x (5 + 1) | Credits1,200 |
| StagePerson search and lookup | Calculation200 x (5 + 2) | Credits1,400 |
| StageTotal | Calculation | Credits3,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.