How to Scrape Trustpilot Reviews with Proxies
Learn to scrape Trustpilot reviews with proxies: extract rating, text, date, reviewer, and company replies as JSON, paginate at scale, and stay GDPR-compliant.

To scrape Trustpilot reviews reliably, you have to solve two problems most tutorials skip. Trustpilot rebuilds its HTML class names on nearly every deploy, and it also hides a clean, structured copy of every review in a JSON blob that sits right there in the page source. Chase the CSS selectors and your parser breaks within weeks. Read the JSON instead and you get the rating, review text, dates, reviewer, and the company's reply in one shot. This guide walks the full pipeline for public review data: the fields worth pulling, how to get past Cloudflare and the login wall, how to paginate to the page cap, how to prep the text for sentiment analysis, and how to stay on the right side of GDPR when reviewer names are involved. Every request runs through the SparkProxy Scraping API, so the anti-bot layer is one parameter instead of an infrastructure project you babysit.
Is it legal to scrape Trustpilot reviews?
Get the framing right before you write a line of code, because "the reviews are public" only answers part of the question.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that scraping data that is 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 blanket license. Trustpilot's Terms of Use separately prohibit automated collection, so scraping can still be a breach of contract even where it is not a CFAA violation. Those are two different legal questions.
There is a second layer here that Amazon or price scraping does not have: reviews contain a person's name, country, and history. That makes review data personal data under the EU's GDPR and similar laws, regardless of the fact that it is published. We cover the practical handling in the GDPR section below, but keep it in mind from the first request.
Practical guardrails that keep a project defensible:
- Collect public review content only: rating, title, text, dates, and the company reply. Treat reviewer identity as sensitive.
- Rate-limit yourself and back off on errors so you are not degrading Trustpilot's service.
- Do not republish full review text or reviewer identities beyond what fair use and privacy law allow.
- Prefer the official Trustpilot Business API when you need reviews for your own company profile. It is the compliant route and it hands you clean JSON with no anti-bot fight.
- If the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice.
Brand monitoring, competitor sentiment tracking, and market research on public reviews are common, legitimate uses. The infrastructure and business context for that sit in Using Proxies for Review Monitoring and Sentiment Analysis.
What review data you can extract (fields reference)
A public Trustpilot review page (reachable at /review/, for example /review/sparkproxy.io) exposes a consistent set of fields per review. Because Trustpilot is a Next.js app, every one of these fields lives inside the embedded __NEXT_DATA__ JSON, which is far more stable than the visible DOM. Here is the reference set worth pulling, with the JSON key and a CSS fallback for each.
| Field | JSON key (in `__NEXT_DATA__`) | CSS fallback (attribute selector) | Notes |
|---|---|---|---|
| Rating | `rating` | `[data-service-review-rating]` | Integer 1 to 5 |
| Review title | `title` | `[data-service-review-title-typography]` | Short headline the reviewer wrote |
| Review text | `text` | `[data-service-review-text-typography]` | Body; can be empty on rating-only reviews |
| Date published | `dates.publishedDate` | `time[datetime]` | ISO 8601, UTC |
| Date of experience | `dates.experiencedDate` | "Date of experience" label | When the customer used the service |
| Reviewer name | `consumer.displayName` | `[data-consumer-name-typography]` | Personal data (see GDPR) |
| Reviewer country | `consumer.countryCode` | `[data-consumer-country-typography]` | ISO 3166-1 alpha-2 |
| Reviewer review count | `consumer.numberOfReviews` | `[data-consumer-reviews-count-typography]` | Signal of a genuine vs throwaway account |
| Verification label | `labels.verification` | Green "Verified" badge | Flags invited vs organic reviews |
| Company reply | `reply.message` | Reply block under the review | `null` when the business has not answered |
| Reply date | `reply.publishedDate` | Timestamp in the reply block | ISO 8601, UTC |
| Review ID | `id` | `data-review-id` | Stable primary key for deduping |
The review ID is the anchor. Store it as your primary key so re-running a scrape updates existing rows instead of duplicating them. The exact JSON keys are current as of mid-2026; Trustpilot can rename them, so treat the table as a starting point and inspect the blob (the next sections show a resilient way to find the array even if the path moves).
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Why Trustpilot is hard to scrape
Trustpilot is not the hardest target on the public web, but it has four traits that break naive scrapers.
Cloudflare in front. Trustpilot sits behind Cloudflare, which fingerprints the TLS handshake and can serve a "Checking your browser" challenge or a 403 before you ever see a review. A plain requests.get from a datacenter IP gets flagged fast.
CSS class names that churn. Trustpilot uses Next.js CSS Modules, which generate hashed class names like styles_reviewCardInner__EwDq2. That hash changes on nearly every frontend deploy, roughly every few weeks. Any scraper keyed on those classes silently returns empty fields the morning after a deploy.
A login wall on deep pages. Trustpilot serves review pages openly for roughly the first 10 pages, then starts prompting you to log in for older reviews. You cannot page past that anonymously.
Rate limiting. Request pages too fast, past roughly 20 page loads per minute, and you draw 429s and temporary blocks.
| Signal | What you will see | How to handle it |
|---|---|---|
| Cloudflare challenge | 403 or a "Checking your browser" interstitial | Route through residential IPs (`premium_proxy=true`); add `render_js` + `stealth` if still challenged |
| CSS module churn | Class selectors return nothing after a deploy | Parse the `__NEXT_DATA__` JSON, not the class names |
| Login wall | Reviews stop after about 10 pages, a "Log in" prompt appears | Stay within the cap; narrow with star, date, and language filters |
| Rate limiting | 429 or slowdowns past ~20 page requests/minute | Pace 3 to 5 seconds between pages; back off on 429 |
A managed scraping API absorbs the first and last of these for you. The class-churn problem is solved by reading the JSON, and the login wall is a hard limit you plan around with filters. If you want the proxy-side theory behind clearing Cloudflare, How to Avoid Getting Your Proxy Blocked goes deep on TLS fingerprinting and header consistency.
Set up the SparkProxy Scraping API
The SparkProxy Scraping API takes a target URL and handles the proxy, rotation, and anti-bot layer for you. You send one request, you get the HTML back. For Trustpilot, the parameter choice is a little different from a JavaScript-heavy site, and it saves you credits.
Here is the key point most guides miss: the reviews are server-side rendered into __NEXT_DATA__, so you do not need a headless browser to read them. The full first page of reviews is already in the initial HTML. That means you can often run with render_js=false, which is cheaper (1 credit on a rotating proxy, or 10 credits on a premium residential proxy, versus 25 for a rendered premium request). Two parameters carry the weight:
premium_proxy=true: routes through residential IPs, which clear Trustpilot's Cloudflare where datacenter IPs get challenged.render_js=false: skip the browser, because the data is in the SSR JSON. Only turn this on (true) plusstealth=trueif you hit a persistent Cloudflare challenge.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. A minimal request in cURL:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.trustpilot.com/review/sparkproxy.io" \
--data-urlencode "render_js=false" \
--data-urlencode "premium_proxy=true"
Full parameter list and response fields live in the Scraping API docs. If you are weighing this against running your own residential pool, Web Scraping API vs Self-Managed Proxies lays out the trade-off.
Wrap the call so every request carries the Trustpilot-specific parameters and a page number:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch_reviews_page(domain: str, page: int = 1) -> str:
"""Fetch one Trustpilot review page. Reviews are server-rendered into
__NEXT_DATA__, so render_js can stay off (cheaper). Escalate if challenged."""
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": f"https://www.trustpilot.com/review/{domain}?page={page}",
"render_js": "false", # data is in the SSR JSON, no browser needed
"premium_proxy": "true", # residential IPs clear Trustpilot's Cloudflare
},
timeout=60,
)
resp.raise_for_status()
return resp.text
Get the reviews as JSON with __NEXT_DATA__
Every Trustpilot page ships a tag holding the full state the page was rendered from, including the review objects. Reading that beats parsing the visible HTML on every axis: it is faster, it is complete, and it does not care about the hashed class names that break selector-based scrapers.
Pull and decode the blob first. selectolax is a fast C-backed HTML parser; install it with pip install selectolax.
import json
from selectolax.parser import HTMLParser
def extract_next_data(html: str) -> dict:
"""Pull the __NEXT_DATA__ JSON that Next.js embeds in every page."""
tree = HTMLParser(html)
node = tree.css_first("script#__NEXT_DATA__")
if node is None:
raise ValueError("No __NEXT_DATA__ found (blocked page or layout change)")
return json.loads(node.text())
Trustpilot usually stores the reviews at props.pageProps.reviews, but that path has moved between redesigns. Rather than hard-code a brittle deep path, search the decoded object for the review-shaped array. This survives a schema shuffle that would break a fixed path:
def find_reviews(node) -> list:
"""Locate the reviews array without hard-coding a fragile deep path.
Fast path checks props.pageProps.reviews, then falls back to a search."""
if isinstance(node, dict):
page_props = node.get("props", {}).get("pageProps", {})
if isinstance(page_props.get("reviews"), list):
return page_props["reviews"]
for value in node.values():
found = find_reviews(value)
if found:
return found
elif isinstance(node, list):
if node and isinstance(node[0], dict) and "rating" in node[0] and "text" in node[0]:
return node
for item in node:
found = find_reviews(item)
if found:
return found
return []
The function returns a list of raw review dicts. It tries the known path first for speed, then recursively hunts for a list whose items look like reviews (they carry both rating and text). When Trustpilot reshuffles its JSON, this keeps working while selector-based scrapers go dark.
Parse the core fields
With the raw review dicts in hand, map each one to a flat record you control. This is where the fields reference table turns into code, including the company reply that so many Trustpilot scrapers drop entirely.
def parse_review(r: dict) -> dict:
consumer = r.get("consumer") or {}
dates = r.get("dates") or {}
reply = r.get("reply") or {}
labels = r.get("labels") or {}
return {
"review_id": r.get("id"), # stable primary key
"rating": r.get("rating"), # int 1-5
"title": r.get("title"),
"text": r.get("text"),
"published_at": dates.get("publishedDate"), # ISO 8601 UTC
"experienced_at": dates.get("experiencedDate"),
"reviewer_name": consumer.get("displayName"), # personal data (GDPR)
"reviewer_country": consumer.get("countryCode"),
"reviewer_review_count": consumer.get("numberOfReviews"),
"verified": bool(labels.get("verification")),
"company_reply": reply.get("message"), # None if unanswered
"company_reply_at": reply.get("publishedDate"),
}
Two things worth knowing. The rating comes through as a clean integer already, so you skip the "4.6 out of 5 stars" string parsing that HTML scraping forces on you. And reply is present only when the business has answered, so guard it with or {} as shown, otherwise a None reply raises AttributeError on the first unanswered review. The company reply is high-signal data: a brand's response rate and tone are exactly what reputation analysts want, and reading it here costs you nothing extra.
If you would rather have the API do the extraction server-side, the extract_rules parameter maps field names to CSS selectors and returns JSON. For Trustpilot the __NEXT_DATA__ route is usually better, because it dodges the class-name churn that extract_rules selectors would still be exposed to.
Paginate through the reviews
Trustpilot paginates with ?page=N and shows 20 reviews per page. The loop is simple, but it has to respect two limits: the roughly 10-page login wall and the roughly 20-requests-per-minute rate ceiling. Detect a block, stop cleanly when the reviews run out, and pace yourself between pages.
import time
import random
def is_blocked(html: str) -> bool:
markers = (
"Attention Required! | Cloudflare",
"cf-error-details",
"Checking your browser",
"Please enable JS and disable any ad blocker",
)
return any(m in html for m in markers)
def scrape_all_reviews(domain: str, max_pages: int = 10) -> list[dict]:
all_reviews = []
for page in range(1, max_pages + 1):
html = fetch_reviews_page(domain, page)
if is_blocked(html):
time.sleep(5)
continue
reviews = find_reviews(extract_next_data(html))
if not reviews: # login wall or last page reached
break
all_reviews.extend(parse_review(r) for r in reviews)
time.sleep(random.uniform(3, 5)) # stay under ~20 pages/minute
return all_reviews
To reach more than the anonymous page cap allows, do not fight the wall, split the query instead. Trustpilot's URL filters slice the same reviews into smaller, separately paginated sets:
?stars=1through?stars=5: one rating at a time, each with its own page range.?date=last30days,?date=last3months,?date=last6months: time windows.?languages=en: a single language.
Ten targeted queries (for example one per star rating, each run to its page cap) surface far more of a busy company's review history than one broad crawl that dies at page 10. Dedupe on review_id afterward, since filter sets overlap.
There is a faster path for large jobs. Trustpilot's frontend fetches subsequent pages from an internal JSON endpoint keyed on the site's build ID, which you can read from the first page's blob:
def next_build_id(data: dict) -> str:
return data["buildId"] # top-level in __NEXT_DATA__, changes on each deploy
def data_api_url(domain: str, build_id: str, page: int) -> str:
return (f"https://www.trustpilot.com/_next/data/{build_id}"
f"/review/{domain}.json?businessUnit={domain}&page={page}")
Hitting data_api_url(...) through the same Scraping API returns pure JSON with the same review structure, no HTML to parse. The catch: buildId changes every time Trustpilot deploys, so read it fresh from a real page load at the start of each run and do not cache it across days.
Scrape at scale without getting blocked
At volume, three habits keep the pipeline healthy: retries on soft blocks, backoff so you do not spike Cloudflare, and modest concurrency. Because the Scraping API rotates the exit IP for you, your ceiling is your plan's rate limit rather than the number of proxies you own. For review data, moderate parallelism across different companies is safer than hammering one company's pages, since Trustpilot's rate counters are largely per-target.
import time
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
def scrape_company(domain: str, attempts: int = 3) -> list[dict]:
for i in range(attempts):
try:
reviews = scrape_all_reviews(domain)
if reviews:
return reviews
except Exception:
pass
time.sleep(2 ** i + random.random()) # exponential backoff + jitter
return []
def scrape_many(domains: list[str], workers: int = 5) -> dict[str, list[dict]]:
out = {}
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(scrape_company, d): d for d in domains}
for fut in as_completed(futures):
out[futures[fut]] = fut.result()
return out
Keep worker counts sane (5 is plenty for review monitoring) and let retries absorb the occasional Cloudflare challenge. The jitter matters: it staggers retries so a batch of failures does not retry in lockstep and re-trigger the same rate limit. The general patterns behind high-volume collection, connection reuse, and error handling are in Using Datacenter Proxies for Web Scraping.
Prepare the data for sentiment analysis
Raw reviews are not analysis-ready. Load them into a dataframe, drop the rating-only rows that have no text to score, and normalize the timestamps so you can chart sentiment over time.
import pandas as pd
rows = scrape_all_reviews("sparkproxy.io")
df = pd.DataFrame(rows)
df["text"] = df["text"].fillna("").str.strip()
df = df[df["text"] != ""] # drop rating-only reviews
df["published_at"] = pd.to_datetime(df["published_at"], utc=True)
For a first pass, VADER (a lexicon model tuned for short, informal text) scores each review without a GPU or training data:
from nltk.sentiment import SentimentIntensityAnalyzer
# one-time: python -m nltk.downloader vader_lexicon
sia = SentimentIntensityAnalyzer()
df["sentiment"] = df["text"].apply(lambda t: sia.polarity_scores(t)["compound"])
One insight that saves you a bad dashboard: the star rating and the text sentiment are not the same signal, and the gap between them is the interesting part. A 5-star review with negative text usually means the reviewer loved the product but hit a shipping or support problem worth flagging. Track both columns and watch where they diverge. For heavier lifting, swap VADER for a transformer model (for example a fine-tuned DistilBERT) on the same text column. The end-to-end pipeline design, from collection cadence to aggregation, is covered in Using Proxies for Review Monitoring and Sentiment Analysis.
GDPR and reviewer data
This is the part that separates review scraping from product scraping. A reviewer's displayName, countryCode, and review history are personal data under the GDPR, and the fact that Trustpilot publishes them does not remove that status. If any reviewer is an EU resident, processing their data brings obligations even when your scraper only ever touches public pages.
Practical handling that keeps a review dataset defensible:
- Data minimization first. Ask what you actually need. Most sentiment and reputation work needs the rating, text, and dates, not the person's name. If you do not need identity, do not store it.
- Pseudonymize when you only need to dedupe. If you need to count distinct reviewers or catch repeat posters but not name them, hash the display name and drop the raw value:
import hashlib
def pseudonymize(name: str | None) -> str | None:
if not name:
return None
return hashlib.sha256(name.encode("utf-8")).hexdigest()[:16]
df["reviewer_id"] = df["reviewer_name"].apply(pseudonymize)
df = df.drop(columns=["reviewer_name", "reviewer_country"]) # keep only if truly needed
- Have a lawful basis. For competitive or market research, "legitimate interests" (GDPR Article 6(1)(f)) is the usual basis, and it requires a documented balancing test weighing your interest against the reviewer's privacy.
- Respect data subject rights. Be able to delete a person's records on request, which is another reason to key on a pseudonymous
review_idrather than scattering names across tables. - Do not republish identities. Aggregate sentiment, rating trends, and anonymized quotes are far safer to surface than a searchable copy of named reviews.
None of this blocks legitimate analysis. It shapes what you keep. A pipeline that stores ratings, cleaned text, dates, and a pseudonymous reviewer key answers almost every business question while holding almost no personal data at rest. When you do need reviews tied to real identities for your own brand, the Trustpilot Business API is the route built for it.
Frequently asked questions
FAQ
Scraping publicly accessible pages (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 Trustpilot's Terms of Use, which prohibit automated collection. Reviews also contain personal data, so GDPR applies to reviewer names and countries. Stick to public review content, minimize personal data, don't overload Trustpilot's servers, and get legal advice before commercial use.
Usually not. Trustpilot server-renders every review into the __NEXT_DATA__ JSON in the initial HTML, so you can read the data with render_js=false, which is cheaper than a rendered request. Only enable render_js=true plus stealth=true if you hit a persistent Cloudflare challenge that the residential premium_proxy route alone does not clear.
Trustpilot serves 20 reviews per page and shows roughly the first 10 pages anonymously before prompting for login on older reviews. To reach more history without logging in, split the crawl with URL filters (?stars=1 through ?stars=5, ?date=last3months, ?languages=en), run each filtered set to its own page cap, then dedupe on the review ID. Keep page requests under about 20 per minute to avoid rate limiting.
Parse the tag that Next.js embeds in every review page; it contains the full review objects (rating, title, text, dates, consumer, reply). For deeper pages you can call Trustpilot's internal /_next/data/{buildId}/review/{domain}.json?page=N endpoint, reading the buildId from a first page load. Both return structured JSON, so you skip HTML parsing and the class-name churn entirely.
Yes. A reviewer's display name, country, and history are personal data under the GDPR even though Trustpilot publishes them, so processing them for EU residents brings obligations. Minimize what you keep, pseudonymize the name with a hash when you only need to dedupe reviewers, document a lawful basis such as legitimate interests (Article 6(1)(f)), and be able to honor deletion requests.
Yes, and it is the compliant route for your own company's reviews. The Trustpilot Business API returns clean JSON with no anti-bot layer, but it is scoped to profiles you are authorized for and requires a business account, so it does not cover arbitrary competitor scraping. Use the API for first-party review data and reserve scraping for public, cross-company research within the legal guardrails above.
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 a Website Behind a Login
Scrape website behind login walls, the right way: persist session cookies, handle CSRF tokens, log in with Playwright, hold sticky proxies, use SparkProxy API.

How to Scrape eBay Listings and Prices
Learn how to scrape eBay listings and prices: extract title, price, bids, condition, and sold data, handle search pagination, and get past eBay anti-bot checks.

Java Web Scraping Proxy: Jsoup and HttpClient Guide
Java web scraping proxy guide: set Jsoup.connect proxies, fix the HTTPS auth gotcha, rotate IPs, parse pages with selectors, and call the SparkProxy API.
