If you sell reputation-management services, a generic list of local businesses gives you no reason to contact one company before another. This tutorial shows you how to enter a business category and city and get back a prospect list of businesses with a recent low-star Google review that had no owner response when captured. Use the linked review to find businesses that may need help managing their online reputation and make your outreach specific.
Would you rather have an agent handle this? Copy the prompt and paste into your favourite LLM to find you leads effortlessly.
Short version
- Input a business category and city and get back a list of prospects to reach out to.
- Return businesses where a recent low-star Google review had no owner response when captured.
- Use the linked review to find prospects who may need help improving and managing their online reputation.
Two accounts from a dentists search in Austin
This example uses dentists in Austin, TX scraped on 2026-11-03. Swap in yours when you run the script.
| Business and public contact | Place snapshot | Exact review evidence | Owner response |
|---|---|---|---|
| Swish Dental SouthWebsite+1 512-296-2841 | Dentist 4.6 rating 964 reviews | 1 star · October 30, 2026“I was ready to move on and let it be water under the bridge but then I started receiving a bill for 241.80.” | Absent in captured response |
| 38th Street DentalWebsite+1 737-260-5523 | Dentist 5.0 rating 734 reviews | 1 star · September 15, 2026“Very disappointed with my experience. I felt the office was deceptive about what my insurance would cover, resulting in unexpected costs.” | Absent in captured response |
What you need to run this
- Python 3.
- A Tapline account and API key.
- The tested client version, installed with
pip install "tapline==0.2.19". - The environment variable
TAPLINE_API_KEYset to your key.
Lets start with a simple example
This request runs a Google Maps search and prints the name, feature id, rating, and review count for three businesses.
python3 -m venv .venvsource .venv/bin/activatepip install "tapline==0.2.19"export TAPLINE_API_KEY="your-api-key"
import osfrom tapline import SyncTaplineClientwith SyncTaplineClient(api_key=os.environ["TAPLINE_API_KEY"]) as tapline:page = tapline.google_maps.search(query="dentists in Austin, TX", limit=3)for place in page.results:print(place.name, place.feature_id, place.rating, place.review_count)
ATX Family Dental 0x8644b4eb0b01986f:0x6b241d4769ed491e 4.9 915South Austin Dentist 0x865b4b5e859f967f:0x38deff12e0bdefcb 5.0 181Austin Lifetime Dental 0x8644cc20724ee6f5:0x2ae9fa2c2bf15149 4.9 846
Build the list
The script first searches your category and city, then extracts the first five businesses that have a source URL, a feature id, and a public phone or website. It then fetches the latest 20 reviews and filters for the ones with the lowest ratings.
"""Build a dated manual account-review queue from Google Maps review evidence."""from __future__ import annotationsimport argparseimport csvimport osfrom dataclasses import asdict, dataclassfrom datetime import datetime, timedelta, timezonefrom pathlib import Pathfrom tapline import SyncTaplineClientSEARCH_LIMIT = 10MAX_REVIEWED_BUSINESSES = 5REVIEWS_PER_BUSINESS = 20MAX_REVIEW_RATING = 3RECENT_DAYS = 180SEARCH_CREDITS = 2REVIEWS_CREDITS = 2OUTPUT_PREFIX = "google-maps-account-review-queue"@dataclass(frozen=True)class AccountReviewEvidence:business_name: strcategory: straddress: strphone: strwebsite: strgoogle_maps_url: strplace_rating: float | Noneplace_review_count: int | Nonereview_id: strreview_rating: intreview_created_at: strreview_text: strreview_url: strowner_response: strexample_filter: strcaptured_at_utc: strdef parse_created_at(value: str) -> datetime:return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)def public_phone(place: object) -> str:phone = getattr(place, "phone", None)if not phone:return ""return phone.international or phone.number or ""def public_website(place: object) -> str:website = getattr(place, "website", None)return website.url if website and website.url else ""def maps_url(place: object) -> str:if getattr(place, "url", None):return place.urlif getattr(place, "cid", None):return f"https://www.google.com/maps?cid={place.cid}"return ""def write_csv(rows: list[AccountReviewEvidence], output_dir: Path, run_at: datetime) -> Path:output_dir.mkdir(parents=True, exist_ok=True)timestamp = run_at.strftime("%Y%m%dT%H%M%SZ")output_path = output_dir / f"{OUTPUT_PREFIX}-{timestamp}.csv"with output_path.open("w", newline="", encoding="utf-8") as output:writer = csv.DictWriter(output, fieldnames=list(AccountReviewEvidence.__dataclass_fields__))writer.writeheader()writer.writerows(asdict(row) for row in rows)return output_pathdef parse_args() -> argparse.Namespace:parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("category", help='Business category, such as "dentists"')parser.add_argument("location", help='City and region, such as "Austin, TX"')parser.add_argument("--output-dir",type=Path,default=Path("."),help="Directory for the timestamped CSV (default: current directory)",)return parser.parse_args()def main() -> int:args = parse_args()run_at = datetime.now(timezone.utc)cutoff = run_at - timedelta(days=RECENT_DAYS)query = f"{args.category} in {args.location}"rows: list[AccountReviewEvidence] = []reviewed_businesses = 0with SyncTaplineClient(api_key=os.environ["TAPLINE_API_KEY"]) as tapline:places = tapline.google_maps.search(query=query, gl="us", limit=SEARCH_LIMIT).resultsfor place in places:if reviewed_businesses >= MAX_REVIEWED_BUSINESSES:breakphone = public_phone(place)website = public_website(place)source_url = maps_url(place)if (not place.nameor place.is_operating_business is Falseor not place.feature_idor not source_urlor not (phone or website)):continuereviewed_businesses += 1reviews = tapline.google_maps.list_reviews(place.feature_id,sort="newest",limit=REVIEWS_PER_BUSINESS,gl="us",).reviewsmatched_for_business = 0for review in reviews:if (review.rating is Noneor review.rating > MAX_REVIEW_RATINGor not review.created_ator not review.textor not review.urlor review.owner_response is not None):continueif parse_created_at(review.created_at) < cutoff:continuematched_for_business += 1rows.append(AccountReviewEvidence(business_name=place.name,category=place.categories[0] if place.categories else args.category,address=place.address or "",phone=phone,website=website,google_maps_url=source_url,place_rating=place.rating,place_review_count=place.review_count,review_id=review.review_id or "",review_rating=review.rating,review_created_at=review.created_at,review_text=review.text,review_url=review.url,owner_response="absent in captured response",example_filter=(f"rating <= {MAX_REVIEW_RATING}; created within {RECENT_DAYS} days; ""owner_response absent"),captured_at_utc=run_at.isoformat(),))print(f"REVIEWED | {place.name} | {len(reviews)} newest reviews | "f"{matched_for_business} matched the example filter")output_path = write_csv(rows, args.output_dir, run_at)flagged_accounts = len({row.business_name for row in rows})documented_credits = SEARCH_CREDITS + reviewed_businesses * REVIEWS_CREDITSprint(f"Google Maps returned {len(places)} businesses for {query!r}")print(f"Reviewed {reviewed_businesses} operating businesses with public contact and source fields")print(f"Flagged {flagged_accounts} accounts with {len(rows)} exact review evidence rows")print(f"API calls: 1 search + {reviewed_businesses} review pages; "f"documented cost: {documented_credits} credits")print(f"Wrote the dated manual review queue to {output_path}")return 0if __name__ == "__main__":raise SystemExit(main())
Download find_unanswered_reviews.py
Expand the search
Increase the value of MAX_REVIEWED_BUSINESSES to expand the number of prospects to analyse, and increase SEARCH_LIMIT to raise the number of results we look through. MAX_REVIEW_RATING controls the highest review rating to be returned.
Run it on your category and city
Pass the category and location as arguments, and send output to a folder so each run's CSV stays separate.
python find_unanswered_reviews.py dentists "Austin, TX" --output-dir final-run
REVIEWED | ATX Family Dental | 20 newest reviews | 0 matched the example filterREVIEWED | Austin Lifetime Dental | 20 newest reviews | 0 matched the example filterREVIEWED | South Austin Dentist | 20 newest reviews | 0 matched the example filterREVIEWED | Swish Dental South | 20 newest reviews | 1 matched the example filterREVIEWED | 38th Street Dental | 20 newest reviews | 1 matched the example filterGoogle Maps returned 10 businesses for 'dentists in Austin, TX'Reviewed 5 operating businesses with public contact and source fieldsFlagged 2 accounts with 2 exact review evidence rowsAPI calls: 1 search + 5 review pages; documented cost: 12 creditsWrote the dated manual review queue to final-run/google-maps-account-review-queue-20261103T225511Z.csv
Wrapping up
With one script you can now find qualified leads that merit a closer look. Don't forget to update the parameters so that they work for your business.