How to Scrape Google Reviews (Fields, API, and Code)
Scrape Google reviews the durable way: pull rating, text, date, reviewer, and owner response past the anti-bot wall, and know when the Places API is enough.

To scrape Google reviews at any real depth, you hit a wall the official tools put up on purpose: the Google Places API returns at most five reviews per business, ordered by "most relevant," with no way to page through the rest. Five reviews is fine for a widget on your homepage. It's useless for monitoring a competitor's reputation, tracking sentiment over a quarter, or building a training set. This guide covers the honest version of the job: which API actually returns everything (and when you're allowed to use it), what a single review contains field by field, how to open the reviews panel and sort by newest, how to page through the full history without tripping the anti-bot layer, and how to clean the output so it's ready for sentiment analysis. Every code sample uses SparkProxy's Scraping API, so rendering, IP rotation, and the CAPTCHA problem are request parameters instead of infrastructure you run yourself.
Is it legal to scrape Google reviews?
Reviews on a Google Business Profile are public, but "public" answers only one of the questions you need to answer, so get the framing right before you write code.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that scraping data that's publicly accessible, meaning no login and no authentication wall, generally does not violate the Computer Fraud and Abuse Act. That decision is about unauthorized access, not a license to do whatever you want with what you collect. Google's Terms of Service separately prohibit automated access to its services, so scraping can breach a contract even where it isn't a CFAA problem. Two different legal questions, two different answers.
Reviews carry an extra weight that plain business listings don't: a review ties a named person to an opinion, sometimes with a profile photo and a link to their account. That's personal data under the GDPR and the CCPA. The moment you store the reviewer's identity, you've taken on data-protection obligations that have nothing to do with scraping law. We come back to this in the GDPR section because it changes what you should collect, not just how.
Guardrails that keep a review-collection project defensible:
- Collect public reviews only. Nothing behind a login, nothing from a private profile.
- Prefer the sanctioned API when it fits (next section). Scraping is the gray-area fallback for coverage the API doesn't sell.
- Treat reviewer names, photos, and profile links as personal data. If you don't need them, don't store them.
- Rate-limit and back off on errors so you aren't degrading the service for real users.
- If the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice.
Aggregating public reviews to measure sentiment or track reputation is a common, legitimate use, and the sanctioned-versus-scraping choice below is the first thing to settle before any of it.
Places API vs scraping: the 5-review wall
This is the section most tutorials skip, and it's the whole reason a google reviews scraper exists. Google sells three different doors to review data, and they are not interchangeable.
Google Places API (Place Details). The public, pay-per-call API. Ask for the reviews field and you get back an array of at most five review objects, chosen by Google as "most relevant." There is no pagination and no reliable way to fetch the sixth review. The Places API docs state the five-review limit plainly, and it has been that way for years across a decade of feature requests. Each object is clean and structured (rating, text, timestamp, author), which is why it's the right tool when five reviews is genuinely enough.
Google Business Profile API. The former "Google My Business" API. Its reviews.list endpoint returns every review with real pagination (page size up to 50). The catch: it only works for locations you own or manage. You cannot point it at a competitor. If you're collecting reviews for your own business, stop reading and use this API. It's fully sanctioned and complete.
Scraping the public Maps UI. The only path that returns the full review history for a business you don't own. It's a gray area (Google's ToS prohibit it), it's harder (rendering, scrolling, anti-bot), and it's what the rest of this guide covers, because if you want to scrape Google business reviews for competitors or a market at scale, this is the only door that opens.
| Approach | Reviews returned | Works for competitors? | Sanctioned? |
|---|---|---|---|
| Places API (Place Details) | Up to 5, "most relevant" | Yes | Yes, paid |
| Business Profile API | All, paginated (50/page) | No, own locations only | Yes, paid |
| Scrape the public Maps UI | All that are public | Yes | No, breaches ToS |
The practical decision tree is short. Own the business? Business Profile API. Need five reviews for a widget? Places API. Need the full public history of a business you don't control? You're scraping, so do it carefully. If you're weighing a managed scraping API against building your own headless-browser proxy pool for that last case, Web Scraping API vs Self-Managed Proxies lays out the trade-off without the sales pitch.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
What a Google review contains (fields reference)
A single Google review is richer than "stars and text." Here's the full record, where each field lives when you scrape the Maps UI, and whether the public google reviews api exposes the same thing. The API-versus-scrape column is worth reading closely, because it's not a straight superset either way.
| Field | Where it lives (scraping the UI) | Places API field | Notes |
|---|---|---|---|
| Rating | `span[role="img"]` `aria-label` | `rating` | 1 to 5 stars, integer |
| Review text | review body span (verify class) | `text` | Truncated in the UI until "See more" is clicked |
| Relative date | "2 weeks ago" text | `relativePublishTimeDescription` | Human-relative, not exact |
| Absolute date | not shown in the UI | `publishTime` (ISO 8601) | The API gives an exact timestamp, the scrape does not |
| Reviewer name | review header (verify class) | `authorAttribution.displayName` | Personal data |
| Reviewer profile URL | author link `href` | `authorAttribution.uri` | Personal data |
| Reviewer photo | author `img` `src` | `authorAttribution.photoUri` | Personal data |
| Local Guide / review count | "Local Guide · 42 reviews" text | not exposed | Credibility signal |
| Owner response | "Response from the owner" block | not exposed | The business's public reply |
| Review photos | `img` inside the review block | not exposed | Count and URLs |
| Review ID | `div[data-review-id]` attribute | not exposed | Your stable dedup key |
Two rows drive the whole strategy. The owner response and the review ID exist only when you scrape: the Places API drops both. The review ID (data-review-id) is the single most useful field you'll capture, because it's a stable per-review key that makes deduping and incremental collection trivial. And note the date asymmetry: the API gives you an exact publishTime, while the scraped UI only shows "3 months ago," so if you need precise timestamps and five reviews is enough, the API wins on that one field.
For the business-listing side of a place (name, address, phone, hours, coordinates), that's a different job covered in How to Scrape Google Maps Data, and for the organic results side there's How to Scrape Google Search Results. This guide stays on the reviews.
Set up the SparkProxy Scraping API
The SparkProxy Scraping API takes a target URL, runs it through a headless browser on a rotating proxy, and returns the rendered HTML. For Google reviews, four parameters carry the load:
render_js=true: the reviews panel is painted by JavaScript, so you need a real browser or you get an empty shell.premium_proxy=true: routes through residential IPs, which survive Google's defenses where datacenter IPs draw a CAPTCHA on the/sorry/indexpage fast.country_code: the ISO alpha-2 exit country. Set a non-EU country likeUSand you skip theconsent.google.cominterstitial that blocks EU exits before the page renders.js_scenario: a list of browser actions (click, wait, scroll, evaluate) that opens the Reviews tab, sorts by newest, expands truncated text, and pages the list.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. A minimal request in cURL, pointed at a place page:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.google.com/maps/place/?q=place_id:ChIJ...&hl=en" \
--data-urlencode "render_js=true" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "country_code=US"
The Python client used throughout:
import json
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
You need a place URL to target. You'll usually have it from a Maps search (the companion guide above builds those), from a place_id, or from the business's own "share" link. The full parameter list and response fields are in the Scraping API docs.
Open the reviews and sort by newest
Loading a place page shows the overview, not the review list. You have to click the Reviews tab, and for monitoring you want to sort by newest so reviews come back in the order they were posted. Sorting newest-first is what makes incremental collection possible later: you can stop as soon as you reach a review you've already stored.
Drive all of it from one js_scenario. The tab and sort controls are buttons with descriptive aria-label attributes, which Google keeps stable for screen readers, so anchor on those rather than on class names:
def open_and_sort_scenario():
return {"instructions": [
{"wait_for": "button[aria-label*='Reviews']"},
{"click": "button[aria-label*='Reviews']"},
{"wait": 2},
{"click": "button[aria-label*='Sort']"},
{"wait": 1},
# The sort menu lists Most relevant / Newest / Highest / Lowest.
# "Newest" is the second radio item. Verify the index before a long run.
{"click": "div[role='menuitemradio']:nth-of-type(2)"},
{"wait": 2},
]}
One selector here is index-based (:nth-of-type(2) for "Newest") because the menu items don't carry a stable label. Treat that as a value to verify, not a constant. Everything else keys off aria-label, which is durable.
Page through the full review history
Reviews load with their own infinite scroll inside the panel. There's no ?page=2. The next batch appears only when you scroll the last loaded review into view, and a busy place can have thousands of them. Two things trip people up here, and both are silent.
First, long reviews are truncated with a "See more" button, and the full text isn't in the DOM until that button is clicked. Scrape without expanding and you'll capture half of every long review with no error to warn you. So each scroll round should also click every visible "See more."
Second, pace the scroll. Fire scroll events with no pause and Google either fails to load the next batch or flags the burst as automation. A short wait between rounds looks human and actually lets the batch render.
Extend the scenario with a scroll-and-expand loop appended to the open-and-sort steps:
def reviews_scenario(rounds=20):
steps = open_and_sort_scenario()["instructions"]
scroll = {"evaluate":
"let r=document.querySelectorAll('div[data-review-id]');"
"if(r.length) r[r.length-1].scrollIntoView();"}
expand = {"evaluate":
"document.querySelectorAll('button[aria-label=\"See more\"]')"
".forEach(b=>b.click());"}
for _ in range(rounds):
steps.append(scroll)
steps.append({"wait": 2}) # let the next batch render
steps.append(expand) # open any newly loaded "See more"
return {"instructions": steps}
def fetch_reviews(place_url, lang="en", rounds=20):
sep = "&" if "?" in place_url else "?"
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": f"{place_url}{sep}hl={lang}",
"render_js": "true",
"premium_proxy": "true",
"country_code": "US",
"js_scenario": json.dumps(reviews_scenario(rounds)),
},
timeout=180,
)
resp.raise_for_status()
return resp.text
Set rounds to the job. A daily monitoring run only needs a handful of rounds because new reviews are few and they're at the top once sorted by newest. A first full backfill of a place with thousands of reviews needs many more, and it's worth splitting into several requests. The rendering, IP rotation, and CAPTCHA handling all sit inside the API call, so the piece you tune is scroll depth. For the proxy-side theory on staying unblocked at volume, How to Avoid Getting Your Proxy Blocked goes deep.
Extract each review durably
Every review is wrapped in a div[data-review-id], and that attribute is stable, so it's your anchor. Inside, anchor on aria-label and role wherever you can, and treat the obfuscated class names as values to verify. A clean trick for the review body: Google renders the review text and the owner's reply in the same kind of span, so within one review block the first body span is the customer's text and the second, when present, is the owner response.
import re
from selectolax.parser import HTMLParser
RATING_RE = re.compile(r"([0-5](?:[.,]\d)?)\s*star", re.I)
DATE_RE = re.compile(
r"\b(a|an|\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago\b", re.I)
COUNT_RE = re.compile(r"([\d,]+)\s+reviews?", re.I)
def parse_reviews(html):
tree, out = HTMLParser(html), []
for block in tree.css("div[data-review-id]"):
rid = block.attributes.get("data-review-id")
if not rid:
continue
star = block.css_first('span[role="img"][aria-label*="star"]')
label = star.attributes.get("aria-label", "") if star else ""
rm = RATING_RE.search(label)
dm = DATE_RE.search(block.text())
cm = COUNT_RE.search(block.text())
bodies = block.css("span.wiI7pd") # obfuscated class; verify it
out.append({
"review_id": rid,
"rating": float(rm.group(1).replace(",", ".")) if rm else None,
"relative_date": dm.group(0) if dm else None,
"text": bodies[0].text(strip=True) if bodies else None,
"owner_response": bodies[1].text(strip=True) if len(bodies) > 1 else None,
"reviewer_reviews": int(cm.group(1).replace(",", "")) if cm else None,
"is_local_guide": "Local Guide" in block.text(),
})
return out
What makes this hold up: the container is matched by data-review-id, the rating comes out of the star aria-label (which Google keeps for accessibility), and the relative date and reviewer's review count are pulled from the block text with regexes rather than from fragile class hooks. The one class-based selector (span.wiI7pd for the body) is flagged inline because Google does rotate it. When it changes, you update one string, not the whole parser.
Notice what's deliberately missing: the reviewer's name, photo, and profile link. You can read them from the header, but the durable selectors for those are the weak ones, and, more importantly, you usually shouldn't store them at all. That's the GDPR section.
Capture the owner response
The owner response is the field the Places API doesn't give you and most scrapers ignore, and it's often the most useful one. How a business replies to complaints is a signal in itself: response rate, response speed, and tone are all measurable from this field, and they say as much about a competitor as the star average does.
The parser above already captures it as the second body span. The thing to get right is downstream: an owner response is not customer sentiment. If you feed it into a sentiment model alongside the reviews, you'll pollute the score with the business's own marketing voice. Keep it in a separate column and analyze it separately, or drop it from the sentiment corpus entirely.
def split_corpus(reviews):
"""Separate customer text from owner replies before any scoring."""
customer = [r for r in reviews if r["text"]]
replies = [{"review_id": r["review_id"], "text": r["owner_response"]}
for r in reviews if r["owner_response"]]
return customer, replies
A quick reputation-ops metric that falls out for free: the share of one-star and two-star reviews that got an owner reply. High means an engaged operator, low means a business that lets criticism sit unanswered.
Prep the reviews for sentiment analysis
Raw scraped reviews aren't ready to score. Four steps get them there, and doing them in this order avoids double-counting and language-mixing bugs.
1. Dedup by review ID. The same review can appear twice across scroll batches, and the stable data-review-id makes this a one-liner. Never dedup on text; two people can write "Great service!" and they're different reviews.
2. Collect incrementally. Because you sorted newest-first, a monitoring run can stop the moment it reaches a review you already have. Keep a checkpoint of stored IDs and break early:
def collect_new(reviews, known_ids):
"""reviews are newest-first; stop at the first one we've already stored."""
fresh = []
for r in reviews:
if r["review_id"] in known_ids:
break
fresh.append(r)
return fresh
3. Detect the language. Google reviews are multilingual, and running an English sentiment model over a French review returns garbage. Tag each review so you route it to the right model or translate first:
from langdetect import detect
def to_sentiment_record(r):
text = (r["text"] or "").strip()
return {
"id": r["review_id"],
"stars": r["rating"],
"text": text,
"lang": detect(text) if text else None,
"date": r["relative_date"],
# note: reviewer name is deliberately NOT carried into the corpus
}
4. Keep the star rating as a prior, not the label. The rating and the text disagree more often than you'd think (a four-star review with a paragraph of complaints). Use the stars as a feature, not as ground truth, and let the model read the text.
From here the analysis itself (scoring, trend lines, alerting on a drop) is its own discipline, and the collection patterns that keep a monitoring pipeline running are covered in Using Proxies for Review Monitoring and Sentiment Analysis. For the high-volume mechanics of running many places on a schedule, Using Datacenter Proxies for Web Scraping covers the throughput side.
GDPR and reviewer data
This is where a review scraper differs from a price scraper, so treat it as a real constraint, not a footnote. A review links a named individual to an opinion, and often to a photo and a public profile. Under the GDPR that's personal data, and scraping it triggers obligations regardless of the fact that the reviewer posted it publicly.
Practical rules that keep a review project on the right side of it:
- Minimize. Ask whether your use case actually needs the reviewer's identity. Sentiment analysis, reputation tracking, and trend detection all work on
{stars, text, date}with no name attached. If you don't need the name, don't scrape it. The parser above already leaves it out by default. - Pick a lawful basis. For public-interest research or legitimate-interest analysis you generally need a documented legitimate-interest assessment (LIA) that weighs your purpose against the individual's rights. Do it before collection, not after.
- Pseudonymize if you must keep identity. Hash the reviewer name or profile URL into an opaque key if you need to distinguish authors (for example, to spot a serial reviewer) without storing who they are.
- Respect storage limitation and erasure. Set a retention window, and be able to delete an individual's data on request. A stable
review_idmakes both auditable. - Don't republish. Scraping reviews for internal analysis is one thing. Re-hosting them as your own content is a copyright and ToS problem on top of the privacy one.
The safe default is blunt: store the rating, the text, the date, and your own review ID. Leave the human out of your database unless you have a specific, documented reason and a lawful basis to keep them in it. When you genuinely need identity-linked review data at scale, that's the moment to revisit whether the sanctioned Business Profile API (for your own locations) or a licensed data source fits better than scraping.
Frequently asked questions
FAQ
Scraping publicly visible reviews (no login) generally does not violate the US Computer Fraud and Abuse Act under hiQ v. LinkedIn (9th Cir. 2022), but it can still breach Google's Terms of Service, which prohibit automated access. Reviews also contain personal data (the reviewer's name and profile), so the GDPR and CCPA apply the moment you store identities. Prefer the official APIs where they fit, collect only public data, minimize what you keep, and get legal advice before any commercial use.
No. The public Google Places API (Place Details) returns at most five reviews per business, ordered by "most relevant," with no pagination. The only official way to get every review is the Google Business Profile API, which paginates the full history but works only for locations you own or manage. To get the complete public review history of a business you don't control, scraping the Maps UI is the only path, and it falls in Google's ToS gray area.
If it's your own business, use the Google Business Profile API, which returns all reviews with pagination (page size up to 50). If you need the full public reviews of a business you don't own, the public API caps at five, so you scrape the Maps reviews panel: open the Reviews tab, sort by newest, and page through the infinite scroll while expanding each truncated review. Anchor on the stable data-review-id so you can dedup.
Sort the reviews by newest so they load in reverse-chronological order, then keep a checkpoint of the review IDs you've already stored. On each run, walk the freshly scraped reviews from the top and stop at the first ID you recognize, since everything below it is already in your database. Because new reviews are few, a monitoring run only needs a shallow scroll, which keeps each request cheap and fast.
Google truncates long reviews in the UI and hides the full text behind a "See more" button, so the complete text isn't in the DOM until that button is clicked. Your scraper must click every visible "See more" after each scroll round before reading the review bodies. In the SparkProxy Scraping API, add an evaluate step to the js_scenario that calls .click() on every button[aria-label="See more"].
Only with care. A reviewer's name, photo, and profile link are personal data under the GDPR and CCPA, so storing them creates data-protection obligations even though the review is public. Most use cases (sentiment, reputation tracking, trend analysis) work fine on just the rating, text, and date, so the safe default is to leave identity out. If you genuinely need it, document a lawful basis, pseudonymize where possible, set a retention limit, and honor erasure requests.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon — claim it before it's gone
Related articles
How to Scrape Redfin Data: Listings, Prices, Market
Learn how to scrape Redfin data: pull listings, prices, and property details from Redfin's Stingray JSON API and CSV export, and handle its rate limits.

How to Scrape IMDb Data: Ratings, Cast, Reviews
Learn how to scrape IMDb data: titles, ratings, cast, and reviews. Pull IMDb's JSON-LD and hidden JSON, then use the official datasets for bulk facts.

How to Set Up and Use a Proxy in Postman
Set up a proxy in Postman the right way: custom proxy host and port, proxy auth, SSL cert fixes, verify the exit IP in the Console, plus Newman env vars.
