🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

How to Scrape TripAdvisor Reviews and Ratings

Learn how to scrape TripAdvisor reviews and ratings: pull the bubble rating, review text, trip type, and date, paginate with -or offsets, and stay GDPR-safe.

S SparkProxy 4 22 min read
Share
How to Scrape TripAdvisor Reviews and Ratings

To scrape TripAdvisor reviews at any useful scale, you have to get two things right that most tutorials skip. TripAdvisor rotates its CSS class hashes on every redesign, so any scraper keyed on class="..." returns empty fields within weeks. And it paginates with an offset buried in the URL path (-or10-, -or20-), not the ?page=N query every generic guide assumes. Get those two wrong and you either capture nothing or you loop over page one forever. This guide walks the full pipeline for public review data: the fields worth pulling, how the bubble rating is actually encoded, how to page to the end, how to expand truncated "Read more" bodies, how to prep the text for sentiment analysis, and how to handle reviewer data under GDPR. Every request runs through the SparkProxy Scraping API, so the anti-bot layer is one parameter instead of an infrastructure project you babysit.

What review data you can extract (fields reference)

A public TripAdvisor listing page exposes a consistent set of fields per review, even though the class names wrapping them change constantly. The trick is to anchor on things that do not churn: stable HTML attributes (data-reviewid), accessibility labels (aria-label), the embedded JSON-LD, and the visible field labels the page prints for humans ("Trip type:", "Date of stay:"). Here is the reference set worth pulling and where each one actually lives.

FieldWhere to read it (resilient anchor)Notes
Rating (bubbles)`[aria-label*="of 5 bubbles"]` leading number; legacy `class="... bubble_45"` divided by 101.0 to 5.0 in 0.5 steps
Review titleheading link near the top of the cardshort headline the reviewer wrote
Review textlongest text run inside the cardtruncated by "Read more" until expanded
Date of stayline labeled `Date of stay: ...`month and year only
Published dateJSON-LD `Review.datePublished`ISO 8601 where present
Reviewer name`a[href*="/Profile/"]` textpersonal data (see GDPR)
Reviewer locationline under the reviewer namepersonal data, often a hometown
Contribution countreviewer stats ("N contributions")genuine vs throwaway account signal
Trip typeline labeled `Trip type: ...`Business, Couples, Family, Friends, or Solo
Helpful votes"N helpful votes" lineengagement signal
Management responseowner reply block under the reviewempty when the property has not answered
Review ID`data-reviewid` attributestable 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 two fields you will not find on Trustpilot or Yelp are trip type and date of stay, and they are the most valuable part of TripAdvisor data for segmentation. More on that in the sentiment section.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Why TripAdvisor is hard to scrape

TripAdvisor is one of the tougher public review targets. Four traits break naive scrapers.

Aggressive bot management. A plain requests.get from a datacenter IP gets a 403 or an "Access to this page has been denied" interstitial, sometimes a CAPTCHA, before you ever see a review. TripAdvisor fingerprints the request well beyond the User-Agent string.

Rotating CSS class hashes. The review markup uses short, obfuscated class names that rehash on redesigns. A selector like .biGQs._P.pZUbB works today and returns nothing next month. This is the single biggest reason scraped TripAdvisor pipelines quietly rot.

Offset pagination in the URL path. TripAdvisor does not use ?page=2. It injects an offset token, -or10-, into the path between the Reviews segment and the listing name. Miss this and you re-scrape page one on every iteration.

Truncated review bodies. Long reviews render with a "Read more" cutoff. A scraper that grabs the visible text captures half a review ending in an ellipsis.

SignalWhat you will seeHow to handle it
Bot challenge or denial403, "Access to this page has been denied", or a CAPTCHA interstitialRoute through residential IPs (`premium_proxy=true`); add `stealth=true` if still challenged
Rotating CSS class hashesClass selectors return nothing after a redesignAnchor on `data-reviewid`, `aria-label`, visible labels, and JSON-LD
"Read more" truncationBodies end in an ellipsis, half the text missingRender and click Read more, or read the full body node
Offset clampingOut-of-range `-or` offsets re-serve the last pageDedupe on `data-reviewid`, stop when a page adds nothing new

A managed scraping API absorbs the first row for you. The other three are extraction discipline, and the rest of this guide is about getting them right. For the proxy-side theory behind clearing bot walls, How to Avoid Getting Your Proxy Blocked covers TLS fingerprinting and header consistency in depth.

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. TripAdvisor needs a heavier configuration than a lightweight SSR site, because the bot management is the real obstacle.

Two parameters carry the weight, with a third in reserve:

  • premium_proxy=true: routes through residential IPs, which clear TripAdvisor's bot management where datacenter IPs get denied.
  • render_js=true: run a real headless browser. TripAdvisor's review content is React-rendered and the anti-bot layer checks for browser behavior, so rendering is the safer default here (unlike a purely server-rendered target).
  • stealth=true: add advanced anti-detection layers. Turn it on only if premium_proxy plus render_js still draws a challenge, since it costs more.

TripAdvisor also localizes content by country, and you can pin the locale with country_code. Set the review language in the URL itself with filterLang=en. 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.tripadvisor.com/Hotel_Review-g60763-d93589-Reviews-The_New_Yorker_Hotel-New_York_City_New_York.html" \
  --data-urlencode "render_js=true" \
  --data-urlencode "premium_proxy=true"

A rendered request on the residential pool is the top credit tier, so scrape deliberately rather than crawling every page you can find. The full parameter list and current credit costs 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 TripAdvisor-specific parameters:

import requests

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"

def fetch_page(url: str) -> str:
    """Fetch one TripAdvisor listing page through the Scraping API.
    render_js clears the React render + behavior checks; premium_proxy
    gives a residential exit IP that TripAdvisor's bot wall accepts."""
    resp = requests.get(
        API,
        headers={"X-API-Key": API_KEY},
        params={
            "url": url,
            "render_js": "true",
            "premium_proxy": "true",
        },
        timeout=90,
    )
    resp.raise_for_status()
    return resp.text

Read the ratings: bubbles and JSON-LD

TripAdvisor does not show stars. It shows five bubbles, and the score comes in half-bubble steps from 1.0 to 5.0. There are two ways it encodes that value in the HTML, and both survive a class rehash better than a class selector does.

The newer React UI puts the score in an accessibility label, for example aria-label="4.5 of 5 bubbles". The legacy widget uses a class like ui_bubble_rating bubble_45, where the trailing number is the rating times ten (so bubble_45 is 4.5). Read the aria-label first, fall back to the class number:

import re
from selectolax.parser import HTMLParser

def parse_rating(card) -> float | None:
    # Newer UI: aria-label reads e.g. "4.5 of 5 bubbles"
    node = card.css_first('[aria-label*="of 5 bubbles"]')
    if node:
        m = re.match(r"([0-5](?:\.\d)?)", node.attributes.get("aria-label", ""))
        if m:
            return float(m.group(1))
    # Legacy widget: class "ui_bubble_rating bubble_45" -> 4.5
    node = card.css_first('[class*="bubble_"]')
    if node:
        m = re.search(r"bubble_(\d{2})", node.attributes.get("class", ""))
        if m:
            return int(m.group(1)) / 10
    return None

For the property-level average, do not compute it from the scraped page. TripAdvisor embeds a schema.org JSON-LD block for Google's rich results, and it carries the aggregateRating (the average and the total review count) reliably, plus often a few of the most recent review objects. Read it instead of averaging a partial scrape:

import json

def extract_jsonld(html: str) -> list[dict]:
    """Return every schema.org JSON-LD block on the page."""
    blocks = []
    for node in HTMLParser(html).css('script[type="application/ld+json"]'):
        try:
            blocks.append(json.loads(node.text()))
        except json.JSONDecodeError:
            continue
    return blocks

def aggregate_rating(blocks: list[dict]) -> dict | None:
    for b in blocks:
        agg = b.get("aggregateRating") if isinstance(b, dict) else None
        if agg:
            return {"average": agg.get("ratingValue"), "count": agg.get("reviewCount")}
    return None

Now you have the headline number without trusting a class name, and a resilient way to read each review's own bubble score.

Parse the review fields

Each review sits in a container that carries a data-reviewid attribute, which is the one anchor TripAdvisor keeps stable across redesigns. Iterate those containers, then pull each field from an anchor that does not churn: the aria-label for the rating, the profile link for the reviewer, and the visible labels for trip type and date of stay.

The label trick is the part most guides miss. TripAdvisor prints "Trip type: Business" and "Date of stay: March 2026" as literal text for human readers. You can match that label string instead of any CSS class, and it keeps working through every frontend rewrite:

def field_after_label(text: str, label: str) -> str | None:
    """Grab the value TripAdvisor prints after a visible label."""
    m = re.search(rf"{re.escape(label)}\s*:?\s*([^\n]+)", text)
    return m.group(1).strip() if m else None

def review_body(card) -> str | None:
    """Class names rotate, so take the longest text run in the card
    as the review body. It is reliably the longest thing there."""
    runs = [n.text(strip=True) for n in card.css("span")]
    runs = [r for r in runs if len(r) > 40]
    return max(runs, key=len) if runs else None

def parse_reviews(html: str) -> list[dict]:
    tree = HTMLParser(html)
    rows = []
    for card in tree.css("div[data-reviewid]"):
        block = card.text(separator="\n", strip=True)
        profile = card.css_first('a[href*="/Profile/"]')
        rows.append({
            "review_id": card.attributes.get("data-reviewid"),   # stable primary key
            "rating": parse_rating(card),                        # from aria bubbles
            "text": review_body(card),
            "trip_type": field_after_label(block, "Trip type"),  # Business/Family/...
            "date_of_stay": field_after_label(block, "Date of stay"),
            "reviewer_name": profile.text(strip=True) if profile else None,  # personal data
        })
    return rows

Two things worth knowing. The data-reviewid gives you a clean dedupe key for free, so you never store the same review twice across overlapping filter runs. And the label-based extraction for trip_type and date_of_stay is deliberate insurance: those two fields have no stable class or attribute of their own, so matching the printed label is the only approach that does not break on the next redesign. If you would rather have the API do extraction server-side, the extract_rules parameter maps fields to selectors and returns JSON, but on TripAdvisor the same class-churn problem applies to any selector you feed it, so the attribute-and-label approach above is sturdier.

Paginate with the -or offset

This is where generic scrapers fail on TripAdvisor. The offset lives in the URL path, not a query parameter. A hotel review URL looks like this:

https://www.tripadvisor.com/Hotel_Review-g60763-d93589-Reviews-The_New_Yorker_Hotel-New_York_City_New_York.html

The g60763 is the geographic ID and d93589 is the listing ID. To get the second page, TripAdvisor inserts -or10- right after the Reviews segment:

https://www.tripadvisor.com/Hotel_Review-g60763-d93589-Reviews-or10-The_New_Yorker_Hotel-...html

The offset increments by the page size, and the page size depends on the listing type. Match them or you skip or repeat reviews.

Listing typeReview URL prefixTypical page sizeOffset tokens
Hotel`/Hotel_Review-g-d-Reviews-.html`around 10`-or10-`, `-or20-`
Restaurant`/Restaurant_Review-g-d-Reviews-.html`around 15`-or15-`, `-or30-`
Attraction`/Attraction_Review-g-d-Reviews-.html`around 10`-or10-`, `-or20-`

Build the paginated URL by inserting the token, then loop until a page adds nothing new. TripAdvisor clamps an out-of-range offset and re-serves the last page, so a dedupe-and-stop check is what ends the loop cleanly:

import time
import random

def review_page_url(base_url: str, offset: int) -> str:
    """Insert the -or{offset}- token TripAdvisor uses for pagination."""
    if offset == 0:
        return base_url
    return re.sub(r"-Reviews-", f"-Reviews-or{offset}-", base_url, count=1)

def is_blocked(html: str) -> bool:
    markers = (
        "access to this page has been denied",
        "captcha-delivery",
        "px-captcha",
        "please verify you are a human",
    )
    low = html.lower()
    return len(html) < 1500 or any(m in low for m in markers)

def scrape_all_reviews(base_url: str, page_size: int = 10, max_pages: int = 20) -> list[dict]:
    seen, out = set(), []
    for i in range(max_pages):
        html = fetch_page(review_page_url(base_url, i * page_size))
        if is_blocked(html):
            time.sleep(5)
            continue
        fresh = [r for r in parse_reviews(html) if r["review_id"] not in seen]
        if not fresh:                       # last page reached, or offset clamped
            break
        seen.update(r["review_id"] for r in fresh)
        out.extend(fresh)
        time.sleep(random.uniform(3, 6))    # pace yourself between pages
    return out

To pull more than the first stretch of pages on a busy listing, narrow with TripAdvisor's own URL filters (a rating filter, or filterLang=en for one language), run each filtered set to its end, and dedupe on review_id afterward since the sets overlap.

Handle "Read more" truncation

Long reviews render truncated with a "Read more" link, and the visible text ends in an ellipsis. Sometimes the full body is already in the server HTML and only hidden by CSS, in which case your review_body heuristic picks it up and you are done. Check that first before adding complexity.

When the full text really is loaded on click, use a JavaScript scenario to expand every review before the page is captured. The Scraping API runs the interaction inside the same rendered session:

import json

def fetch_page_expanded(url: str) -> str:
    resp = requests.get(
        API,
        headers={"X-API-Key": API_KEY},
        params={
            "url": url,
            "render_js": "true",
            "premium_proxy": "true",
            "js_scenario": json.dumps({"steps": [
                {"wait_for": "div[data-reviewid]"},        # wait for reviews to mount
                {"click": "span:has-text('Read more')"},   # expand truncated bodies
                {"wait": 800},
            ]}),
        },
        timeout=120,
    )
    resp.raise_for_status()
    return resp.text

Check the Scraping API docs for the exact js_scenario action keys, since the shape can change. The pattern is what matters: wait for the review cards, click each Read more, wait a beat for the DOM to settle, then capture. Swap fetch_page for fetch_page_expanded in the pagination loop only when you confirm the bodies come back truncated, because rendering plus interaction is slower and costs more credits per page.

Scrape at scale without getting blocked

At volume, three habits keep the pipeline healthy: retries on soft blocks, exponential backoff so you do not spike the bot wall, and modest concurrency across different listings rather than hammering one property's pages. Because the Scraping API rotates the exit IP for you, your ceiling is your plan's rate limit, not the number of proxies you own.

from concurrent.futures import ThreadPoolExecutor, as_completed

def scrape_listing(base_url: str, attempts: int = 3) -> list[dict]:
    for i in range(attempts):
        try:
            rows = scrape_all_reviews(base_url)
            if rows:
                return rows
        except Exception:
            pass
        time.sleep(2 ** i + random.random())   # exponential backoff + jitter
    return []

def scrape_many(urls: list[str], workers: int = 4) -> dict[str, list[dict]]:
    out = {}
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futs = {pool.submit(scrape_listing, u): u for u in urls}
        for fut in as_completed(futs):
            out[futs[fut]] = fut.result()
    return out

Keep worker counts sane. Four concurrent listings is plenty for review monitoring, and the jitter matters because it staggers retries so a batch of failures does not retry in lockstep and re-trigger the same block. The general patterns behind high-volume collection, connection reuse, and error handling are covered 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 rating-only rows with no text to score, then run a first-pass sentiment model.

import pandas as pd
from nltk.sentiment import SentimentIntensityAnalyzer
# one-time: python -m nltk.downloader vader_lexicon

df = pd.DataFrame(scrape_all_reviews(BASE_URL))
df["text"] = df["text"].fillna("").str.strip()
df = df[df["text"] != ""]                      # drop rating-only reviews

sia = SentimentIntensityAnalyzer()
df["sentiment"] = df["text"].apply(lambda t: sia.polarity_scores(t)["compound"])

VADER is a lexicon model tuned for short, informal text, so it scores each review with no GPU or training data. For heavier work, swap it for a transformer such as a fine-tuned DistilBERT on the same text column.

Here is the analysis angle TripAdvisor gives you that Trustpilot and Yelp do not: the trip type field. Segment sentiment by who was traveling, and the gaps tell you which guest a property underserves.

by_trip = df.groupby("trip_type")["sentiment"].mean().sort_values()

If business travelers score a hotel a full point below families, the problem is usually workspace, wifi, or check-in speed, not the pool. That segmentation is impossible on review sites that do not capture the travel context, and it is the single most actionable thing in TripAdvisor data. Pair it with the star-versus-text gap: a 5.0 review with negative text usually flags one specific incident worth reading. 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 name, hometown, and posting history are personal data under the GDPR, and the fact that TripAdvisor publishes them does not change that. If any reviewer is an EU resident, processing their data brings obligations even when your scraper only touches public pages.

Handling that keeps a review dataset defensible:

  • Data minimization first. Most sentiment and reputation work needs the rating, text, trip type, 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 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"])    # keep the hash, drop the identity
  • 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_id rather 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, trip type, dates, and a pseudonymous reviewer key answers almost every business question while holding almost no personal data at rest. When you need reviews tied to real identities for a property you represent, the TripAdvisor Content 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 TripAdvisor's Terms of Use, which prohibit automated collection. Reviews also contain personal data, so GDPR applies to reviewer names and locations. Stick to public review content, minimize personal data, do not overload TripAdvisor's servers, and get legal advice before commercial use.

Usually yes. TripAdvisor's review content is React-rendered and its bot management checks for real browser behavior, so render_js=true plus a residential exit (premium_proxy=true) is the reliable baseline. Add stealth=true only if you still hit a challenge. You need the rendered session anyway when you expand truncated "Read more" bodies with a JavaScript scenario.

TripAdvisor paginates with an offset in the URL path, not a ?page= query. Insert -or{offset}- right after the Reviews segment, for example -or10- for the second page. The offset increments by the page size, which is around 10 for hotels and 15 for restaurants. Loop until a page adds no new review IDs, because an out-of-range offset re-serves the last page.

TripAdvisor shows bubbles, not stars, in half-point steps from 1.0 to 5.0. In the newer UI, read the score from an accessibility label such as aria-label="4.5 of 5 bubbles" and parse the leading number. In the legacy widget, the class bubble_45 means 4.5, so divide the trailing number by 10. Both survive the rotating CSS class hashes better than a class selector.

Yes. A reviewer's name, hometown, and posting history are personal data under the GDPR even though TripAdvisor 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 a property you represent. The TripAdvisor Content API returns clean JSON with no anti-bot layer, but it caps you at a handful of the most recent reviews per location and requires a partner agreement, so it does not cover broad competitor research. Use the API for first-party data and reserve scraping for public, cross-property research within the legal guardrails above.

Limited-time · 50% off

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

Claim Discount

About the Author

SparkProxy Technical Team. The SparkProxy engineering team builds and maintains global datacenter and residential proxy infrastructure plus a managed Scraping API. This guide reflects patterns tested against tripadvisor.com in 2026 using Python 3.11+, requests 2.32+, and selectolax 0.3+. TripAdvisor changes its frontend classes and page layout often, so treat the selectors as current-as-of and confirm the bubble aria-label, the data-reviewid attribute, and the -or offset against a live page before a large run.

Citations: hiQ Labs v. LinkedIn, 9th Cir. 2022 · TripAdvisor Content API · SparkProxy Scraping API docs

Keep reading

Related articles

How to Set Up and Use a Proxy in Postman

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.

SparkProxy·Guides