Open Google Maps on any busy street and tap a few local businesses: a clinic, a laundry, a barbershop, a guesthouse. You’ll see the same pattern. Dozens or hundreds of reviews, and either no replies at all, or a burst of “Terima kasih kak!” from two years ago and nothing since. The 1-star review complaining about a rude cashier has been sitting there unanswered, in public, for months.
Owners know they should reply. They don’t, because it’s a small, boring, slightly stressful job that never feels urgent. That makes it a good thing to sell: a monthly service where every new Google review gets a sensible, on-brand reply within a few days, and the difficult ones go to the owner before anything is posted.
AI makes this cheap to run. It reads screenshots of the reviews, drafts replies in the reviewer’s language, and follows the owner’s rules. You do the part clients pay for: judgement. You check every reply, and you never let a machine answer a food-poisoning complaint by itself.
In this guide you’ll build one Python script, reviews.py, that:
- reads screenshots of the Reviews panel with Gemini and turns them into a CSV, skipping reviews you’ve already handled
- drafts a reply to each new review using a short business profile the owner approves once
- flags risky reviews and risky replies with plain Python rules (low stars, health or legal words, phone numbers, discounts), so you know where to slow down
- builds a simple HTML review sheet with copy buttons, so posting 20 replies takes minutes
AI cost per client: about $0.05 per month for 30–40 reviews. Build time: about 45 minutes. Running time per client: about 20–30 minutes a week.
What you need
| Item | Cost | Notes |
|---|---|---|
| Python 3.10+ | $0 | Windows, macOS or Linux |
| Gemini API key | $0 to start | aistudio.google.com → “Get API key” |
google-genai, pydantic, pillow | $0 | pip install |
| Manager access to the client’s Business Profile | $0 | The owner adds your Google account; you never need their password |
| A phone or laptop to take screenshots | What you already have |
Pricing as of October 2026 (check ai.google.dev/pricing before you rely on it; Google changes models and prices often):
- Gemini 3.5 Flash-Lite (
gemini-3.5-flash-lite, paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens. Thinking tokens are billed as output. This is the script’s default. - Free tier: rate-limited, fine for testing on public reviews of your own favourite warung. Use a paid-tier key for client work (see Reality check).
Getting access the right way
Google Business Profile has two user roles that matter here: owners and managers. Managers can reply to reviews, edit business info and add photos, but can’t add or remove users or delete the profile. Ask the owner to add you as a manager: at business.google.com (or from their Business Profile on Google Search) they open More (⋮) → Business Profile settings → People and access → Add, enter your Gmail address, choose the Manager role and select Invite. You accept the invite from your email.
Never ask for the owner’s Google password. Apart from being a security problem, a password you hold is a liability you don’t want when the relationship ends.
How the cost works out
Reading reviews. On Gemini 3.x an image costs a fixed number of input tokens set by the media resolution, 1,120 tokens at the default, regardless of pixel size. A phone screenshot of the Reviews panel shows roughly 3–5 reviews, so 35 reviews is about 10 screenshots. With ~300 prompt tokens each, that’s about 14,000 input tokens and 4,000 output tokens.
Drafting replies. Each reply call sends the profile, rules and one review: roughly 900 input tokens and 250 output tokens with minimal thinking. For 35 reviews: about 32,000 input and 9,000 output tokens.
Total for a 35-review month:
- Input: 46,000 × $0.30/1M = $0.014
- Output: 13,000 × $2.50/1M = $0.033
- About $0.05 per client per month, or under Rp 1,000
The script prints real token counts and cost at the end of every run, so you don’t have to trust these estimates. The AI bill will never be your problem in this business. Your time is.
Step 1: Set up the project
mkdir reviewdesk && cd reviewdesk
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install google-genai pydantic pillow
mkdir -p clients/warung-bu-sri/shots
export GEMINI_API_KEY="paste-your-key-here" # Windows PowerShell: $env:GEMINI_API_KEY="..."
One folder per client. Inside each, a profile.txt the owner reads and approves once. This file is what makes replies sound like the business instead of like a chatbot:
Business: Warung Bu Sri, Jl. Kaliurang Km 5, Yogyakarta
Sign off as: Bu Sri & tim
Tone: warm, short, informal Indonesian ("kak"); plain English for English reviews
Facts you may mention: open 07:00-21:00 daily; gudeg is cooked fresh every morning;
we have parking for motorbikes only
For complaints, invite them to message us on WhatsApp via the number on our profile.
Never: offer discounts or free food, argue, blame staff by name, mention other customers
Keep it short. Every line is sent with every review, and every line is a fact the AI is allowed to repeat in public, so only put true things in it.
Step 2: The script
Save this as reviews.py:
import argparse, csv, html, os, re, sys, time
from io import BytesIO
from pathlib import Path
from google import genai
from google.genai import errors, types
from PIL import Image, ImageOps
from pydantic import BaseModel
MODEL = os.environ.get("REVIEW_MODEL", "gemini-3.5-flash-lite")
PRICE_IN, PRICE_OUT = 0.30, 2.50 # USD per 1M tokens, Gemini 3.5 Flash-Lite paid tier
FIELDS = ["key", "reviewer", "stars", "when", "text", "reply", "flags", "status"]
RISKY_REVIEW = re.compile(
r"sakit|keracunan|diare|muntah|poison|sick|allerg|alergi|polisi|police|lawyer|"
r"pengacara|lapor|penipu|scam|fraud|refund|uang kembali|kecoa|cockroach|tikus|\brats?\b|"
r"pelecehan|harass|racist|rasis", re.I)
RISKY_REPLY = re.compile(
r"https?://|www\.|\+?\d[\d\s-]{8,}|diskon|discount|gratis|free|voucher|promo|"
r"refund|ganti rugi|compensat|our fault", re.I)
class Review(BaseModel):
reviewer: str
stars: int
when: str
text: str
already_replied: bool
class Draft(BaseModel):
reply: str
needs_owner: bool
reason: str
EXTRACT_PROMPT = """This is a screenshot of Google reviews for one business.
List every review that is fully visible, top to bottom.
- reviewer: the name exactly as shown.
- stars: 1-5.
- when: the date text as shown, e.g. "2 minggu lalu" or "3 weeks ago".
- text: the review text exactly as shown, without translating or fixing it.
Empty string if the review has stars only. If the text is cut off with "More"/"Lainnya",
copy what is visible and end it with "[...]".
- already_replied: true if a "Response from the owner"/"Tanggapan dari pemilik" is shown.
Skip reviews that are only partly visible at the top or bottom edge."""
DRAFT_PROMPT = """You write public replies to Google reviews for a small business.
Business profile (the ONLY facts you may state about the business):
{profile}
Rules:
- Reply in the same language as the review. Stars-only review: one short thank-you line.
- 1-3 sentences, under 60 words. No hashtags, no emojis unless the review uses them.
- Thank them for something specific they mentioned. Do not repeat praise back word for word.
- Never invent facts, opening hours, menu items, prices, policies or staff names.
- Never offer discounts, gifts, refunds or compensation. Never admit legal fault.
- Never mention health conditions, the reviewer's personal details, or other customers.
- For complaints: apologise for the experience, don't argue, and invite them to contact
the business privately as described in the profile.
- Set needs_owner=true if the review mentions illness, injury, safety, hygiene, legal
action, discrimination, a specific staff member, or anything you cannot answer
from the profile. Give the reason in one short sentence.
Review ({stars} stars) by {reviewer}:
{text}"""
client = genai.Client() # reads GEMINI_API_KEY
usage = {"in": 0, "out": 0}
def call(contents, schema):
for attempt in range(3):
try:
resp = client.models.generate_content(
model=MODEL,
contents=contents,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=schema,
thinking_config=types.ThinkingConfig(thinking_level="minimal"),
),
)
except errors.APIError as e:
print(f" API error {e.code} (attempt {attempt + 1}), retrying", file=sys.stderr)
time.sleep(5 * (attempt + 1))
continue
u = resp.usage_metadata
usage["in"] += u.prompt_token_count or 0
usage["out"] += (u.candidates_token_count or 0) + (u.thoughts_token_count or 0)
if resp.parsed is not None:
return resp.parsed
sys.exit("Request failed 3 times.")
def image_part(path):
img = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
img.thumbnail((2048, 2048))
buf = BytesIO()
img.save(buf, "JPEG", quality=90)
return types.Part.from_bytes(data=buf.getvalue(), mime_type="image/jpeg")
def review_key(name, text):
# Same person + same opening words = same review, even across overlapping screenshots
return re.sub(r"\W+", "", (name + text[:60]).lower())
def read_rows(path):
if not path.exists():
return []
with open(path, newline="", encoding="utf-8-sig") as f: # -sig: Excel adds a BOM
return list(csv.DictReader(f))
def write_rows(path, rows):
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(rows)
def extract(folder, rows):
known = {r["key"] for r in rows}
exts = {".jpg", ".jpeg", ".png", ".webp"}
for shot in sorted(p for p in (folder / "shots").iterdir() if p.suffix.lower() in exts):
print(f"Reading {shot.name}")
for r in call([image_part(shot), EXTRACT_PROMPT], list[Review]):
key = review_key(r.reviewer, r.text)
if key in known:
continue
known.add(key)
rows.append({"key": key, "reviewer": r.reviewer, "stars": r.stars, "when": r.when,
"text": r.text, "reply": "", "flags": "",
"status": "skip (already replied)" if r.already_replied else "new"})
shot.rename(shot.with_suffix(shot.suffix + ".done")) # don't re-read next week
def draft(folder, rows):
profile = (folder / "profile.txt").read_text(encoding="utf-8")
for r in rows:
if r["status"] != "new":
continue
d = call(DRAFT_PROMPT.format(profile=profile, stars=r["stars"],
reviewer=r["reviewer"], text=r["text"] or "(no text)"),
Draft)
flags = []
if int(r["stars"]) <= 2:
flags.append(f"{r['stars']}-star")
if RISKY_REVIEW.search(r["text"]):
flags.append("sensitive words in review")
if RISKY_REPLY.search(d.reply):
flags.append("reply has link/number/offer")
if "[...]" in r["text"]:
flags.append("review cut off: open full text")
if d.needs_owner:
flags.append("AI: " + d.reason)
r["reply"], r["flags"] = d.reply.strip(), "; ".join(flags)
r["status"] = "owner" if flags else "ready"
print(f" {r['status']:5} {r['stars']}★ {r['reviewer'][:30]}")
def sheet(folder, rows):
cards = []
for i, r in enumerate(x for x in rows if x["status"] in ("ready", "owner")):
color = "#fde2e1" if r["status"] == "owner" else "#e6f4ea"
cards.append(f"""<div style="background:{color};padding:12px;margin:12px 0;border-radius:8px">
<b>{html.escape(r['reviewer'])}</b> {'★' * int(r['stars'])} <small>{html.escape(r['when'])}</small>
<p>{html.escape(r['text']) or '<i>(stars only)</i>'}</p>
<p style="color:#b00">{html.escape(r['flags'])}</p>
<textarea id="t{i}" rows="3" style="width:100%">{html.escape(r['reply'])}</textarea>
<button onclick="navigator.clipboard.writeText(document.getElementById('t{i}').value)">Copy</button>
</div>""")
page = f"""<!doctype html><meta charset="utf-8"><title>{folder.name}</title>
<body style="font-family:sans-serif;max-width:720px;margin:auto">
<h2>{folder.name}: {len(cards)} replies</h2>
<p>Green = check and post. Red = send to owner first.</p>{''.join(cards)}</body>"""
(folder / "review.html").write_text(page, encoding="utf-8")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("client", help="client folder, e.g. clients/warung-bu-sri")
folder = Path(ap.parse_args().client)
path = folder / "reviews.csv"
rows = read_rows(path)
extract(folder, rows)
draft(folder, rows)
write_rows(path, rows)
sheet(folder, rows)
cost = usage["in"] * PRICE_IN / 1e6 + usage["out"] * PRICE_OUT / 1e6
print(f"\nTokens: {usage['in']:,} in, {usage['out']:,} out. Cost: ${cost:.4f}")
print(f"Open {folder / 'review.html'} to review and post.")
if __name__ == "__main__":
main()
A few design choices worth knowing:
- Two layers of flags. The AI’s own
needs_ownerjudgement is useful but not reliable, so plain regular expressions check the review and the reply too. If either layer is worried, the reply goes red. Add words your clients’ customers actually use, such asbasi(stale) for food businesses orluka(injured) for gyms. reviews.csvis your memory. Screenshots overlap week to week. Thekeystops the same review from being drafted twice, and already-answered reviews are markedskip. Processed screenshots get a.donesuffix.- After posting, mark rows done. Open
reviews.csvand changestatustopostedfor everything you’ve published, so it drops off next week’s sheet. - Nothing gets posted automatically. That’s deliberate. See Reality check.
Step 3: Run a real week
- Open the business on Google Maps → Reviews and sort by Newest.
- Screenshot down to the last review you handled. Tap “More” on long reviews before screenshotting so the full text is visible.
- Move the screenshots into
clients/warung-bu-sri/shots/and run:
python reviews.py clients/warung-bu-sri
Reading Screenshot_0412.png
Reading Screenshot_0413.png
ready 5★ Dimas Pratama
ready 4★ Sarah K.
owner 1★ Andi W.
ready 5★ Rina Sulistyo
...
Tokens: 11,906 in, 3,342 out. Cost: $0.0119
Open clients/warung-bu-sri/review.html to review and post.
- Open
review.html. Read every green reply, edit in the textarea if needed, press Copy, and paste it into the reply box for that review. Logged in with your manager account, go to business.google.com → Read reviews → Reply icon next to the review (you can also reply from the Business Profile panel when you search the business name on Google). That’s 15–30 seconds per review once you have the rhythm. - Send every red card to the owner on WhatsApp: the review, your suggested reply, and the flag. Post only after they approve or rewrite it.
Cost breakdown
For one client with ~35 reviews a month:
| Item | Monthly cost |
|---|---|
| Gemini API (paid tier) | ~$0.05 |
| Your time: 4 sessions × 20–30 min | 1.5–2 hours |
| Onboarding (one-off): access, profile.txt, backlog | 1–2 hours |
The first month is the heavy one. A business with 150 unanswered reviews needs a backlog clean-up. Charge for it separately.
Turning it into income
What to charge. Search Fiverr for “google review response” and you’ll find plenty of gigs starting at $5 per batch. Fiverr keeps 20% of each order (plus a service fee charged to the buyer), and buyers there want volume, so the margin is thin. The better market is local businesses you can walk into. A reasonable starting offer in Indonesia:
- Backlog clean-up: Rp 250.000–500.000 one-off, depending on how many unanswered reviews there are
- Monthly: Rp 300.000–500.000 for up to 40 new reviews, replies within 3 working days, red-flag reviews sent to the owner the same day
At Rp 400.000 a month (about $22 at October 2026 exchange rates of ~Rp 18.000 per dollar), 10 clients is Rp 4 juta a month for about 15–20 hours of work. Those numbers are a starting point, not a market rate you can look up. Test them on your first three clients and adjust.
Who to approach first. Businesses with lots of reviews and few replies: you can see both on Google Maps before you ever talk to them. Clinics, dentists, guesthouses, laundries, cafés, car workshops and barbershops are good fits because reviews directly affect whether a stranger walks in.
The pitch that works. Don’t say “AI”. Show them. Pick three of their real unanswered reviews, including a bad one, write the replies with the script, and show the owner on your phone: “Ini contoh balasan untuk 3 review terakhir. Saya bisa urus semua review tiap minggu, yang negatif saya kirim ke Ibu dulu sebelum dibalas.” It takes five minutes and proves you understand their business.
Upsells that use the same access. Once you’re a manager, you can offer adjacent work: keeping opening hours and holiday hours correct, adding photos, and writing Business Profile updates. Price each one separately; don’t let them creep into the monthly fee.
Reality check: what does NOT work
Auto-posting replies. It’s tempting to wire this to the Google Business Profile API and skip the copy-paste. Don’t, at least at first. API access requires applying to Google and being approved, and more importantly, one bad automated reply to a complaint about illness or discrimination can do more damage than a year of good ones. The human review step is the product.
Generic replies. “Thank you for your review! We hope to see you again!” on every review is worse than nothing. Readers notice copy-paste replies and conclude nobody is listening. That’s why the prompt demands something specific from each review. If the drafts start sounding the same, tighten profile.txt and edit harder.
Selling fake or “boosted” reviews. Some clients will ask whether you can also “get more 5-star reviews”. Writing reviews, paying for them, or offering discounts in exchange for them breaks Google’s user-contributed content policy. Google can remove the reviews and restrict the profile. Your service is replying, not manufacturing. Say no, and put it in your agreement.
Arguing with reviewers. Owners sometimes want to “set the record straight” in public. A defensive reply to a 1-star review is read by every future customer. Move the conversation to WhatsApp, keep the public reply calm, and let the owner decide on compensation privately.
Health, legal and personal details. Never confirm in public that someone was a patient, what they ordered, or what happened to them. For clinics this is also a confidentiality issue. The regex flags help, but they are not a substitute for reading every review yourself.
Screenshots that cut text off. Gemini can only read what’s on screen. Long reviews hidden behind “More” get marked [...] and flagged, but it’s faster to expand them before you screenshot.
Free tier and client data. On Google’s free tier, inputs may be used to improve Google’s products. Reviews are public, but the owner’s profile notes may not be. Use a paid-tier key for client work so the terms are clear and you don’t hit rate limits.
Model names change. If gemini-3.5-flash-lite stops working, set REVIEW_MODEL to the current Flash-Lite model and update PRICE_IN/PRICE_OUT so the cost line stays accurate.
Final checklist
- Added as manager, never given the owner’s password
-
profile.txtread and approved by the owner, containing only true facts - Screenshots sorted by Newest, long reviews expanded
- Every green reply read before posting
- Every red reply approved by the owner before posting
- Posted rows marked
postedinreviews.csv - Written agreement: response time, review limit per month, no fake reviews
- Backlog clean-up priced separately from the monthly fee
Start with a business you already go to that has an obvious pile of unanswered reviews. Reply to their last month of reviews for free as a demo, show the owner the before and after, and you have your first case study.