kestrel actors docs

kestrel actorsGuides

How to scrape TripAdvisor reviews without getting blocked

TripAdvisor is not blocked by rate limits. It is blocked by a bot gate that reads your TLS handshake before it reads your request, and the fix is two specific settings — not more retries. Here is what passes, what does not, and the paging step that silently costs you a third of a restaurant's reviews.

The short version

  1. Use a residential IP. TripAdvisor refuses every datacenter range: three fresh datacenter sessions returned 403 on every impersonation profile tried. Residential answers 200.
  2. Impersonate Firefox 147, not Chrome. With curl_cffi, impersonate="firefox" resolved to Firefox 147 returns 200 on residential; the Chrome profile is refused with a DataDome device check even from a clean home IP.
  3. Read the review offset off the page. Reviews live in the server-rendered page at -orN- offsets: 10 per page on a hotel or attraction, 15 on a restaurant. Take the step from the page's own paging links.
  4. Filter before you store. Rating, trip type, date, translation and text filters decide what you keep. Applied before billing they are also what makes a complaints feed cost cents.

Why TripAdvisor blocks most scrapers on the first request

TripAdvisor sits behind DataDome, and DataDome decides before it looks at your headers. Two variables settle everything:

There is a version trap inside the fingerprint. With curl_cffi, the firefox alias resolves to a different browser version depending on which release you installed: 0.13.0 maps it to Firefox 135, which is hard-blocked on every pool; 0.16.2 maps it to Firefox 147, which passes. The same machine flipped from blocked to open by upgrading one library. Pin the profile by name so a future alias bump cannot silently change your verdict:

from curl_cffi import requests   # requires curl_cffi >= 0.16

s = requests.Session(impersonate="firefox147", proxies={"https": "http://groups-RESIDENTIAL:<pass>@proxy.apify.com:8000"})
r = s.get("https://www.tripadvisor.com/Hotel_Review-g189158-d4509998-Reviews-Memmo_Alfama_Hotel-Lisbon.html", timeout=45)
print(r.status_code, len(r.content))   # 200, ~0.9 MB

Paced at one request per second on a single residential session, ten distinct hotel pages returned 10/10 with 200s in 0.83-1.28 seconds each. TripAdvisor is not rate-limiting you at that pace; it is deciding whether to talk to you at all.

Where the review data actually lives

The pages are server-rendered. Each one carries the review cards in HTML and the same records again as JSON in the page's hydration state (ReviewsProxy_getReviewListPageForLocation). Nothing is fetched over XHR, so there is nothing to reverse-engineer and no token to mint.

Paging is a URL offset: .../Hotel_Review-g227592-d228423-Reviews-or10-Hotel_Marina-....html. The orN is the review offset. No cookie, no referer and no token are required — a datadome cookie is set on the first 200 and can be reused.

The paging step that silently truncates restaurants

A hotel page lists 10 reviews. A restaurant page lists 15. An attraction lists 10.

This is the single most common bug in TripAdvisor scrapers, because getting it wrong does not look like an error. Walk a restaurant in steps of ten and the requests return HTTP 200 with real reviews — but the wrong offset silently re-serves page one, so you collect duplicates, deduplicate them, and conclude the restaurant has fewer reviews than it does. Read the step off the page's own paging links instead of assuming it, and a 4,000-review restaurant does not quietly become a truncated one.

The TripAdvisor restaurant and attraction scraper reads that step per family, and refuses a target of the wrong type with a free wrong_type status row that names what the id actually is, instead of billing you for the wrong place.

Working code

from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("kestrel/tripadvisor-reviews-scraper").call(run_input={
    "locationIds": ["4509998"],
    "languages": ["en", "fr"],
    "maxRating": 3,
    "requireText": True,
    "maxReviewsPerHotel": 500,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    if row["type"] == "review":
        print(row["language"], row["rating"], row["review_date"], row["title"], "-", (row["response"] or "")[:40])

The same job in one curl call:

curl -X POST "https://api.apify.com/v2/acts/kestrel~tripadvisor-reviews-scraper/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"startUrls": ["https://www.tripadvisor.com/Hotel_Review-g189158-d4509998-Reviews-Memmo_Alfama_Hotel-Lisbon.html"], "maxReviewsPerHotel": 50}'

What one row looks like

{
  "type": "review",
  "review_id": "1075128384",
  "location_id": "4509998",
  "hotel_name": "Memmo Alfama Hotel",
  "language": "en",
  "original_language": "en",
  "translated": false,
  "title": "A Gem in the Heart of Alfama",
  "text": "This place is a gem! On a quiet side street just off the route of the famous tram Electrico #28...",
  "rating": 5,
  "sub_ratings": {"value": 4, "rooms": 4, "location": 5, "cleanliness": 5, "service": 5, "sleep_quality": 5},
  "review_date": "2026-08-27",
  "stay_date": "2026-08-31",
  "trip_type": "COUPLES",
  "reviewer_hometown": "Brooklyn, New York",
  "response": "Dear Frank, thank you so much for your incredible review...",
  "url": "https://www.tripadvisor.com/ShowUserReviews-g189158-d4509998-r1075128384-..."
}

Language domains multiply your corpus, and your duplicates

TripAdvisor is not one site. tripadvisor.com serves English, tripadvisor.de German, tripadvisor.fr French, and each domain shows reviews written in its language plus machine translations of the rest. In the verified example the hotel row reported review_count 1,977 across all languages, while the English site listed 1,456 and the German site 595.

So two things follow. Reading several domains is how you get a complete corpus — and it is also how you get the same German review twice, once as the original and once as an English machine translation. Turn translated reviews off when you want each review exactly once; leave them on when you want everything an English-reading guest sees.

The honest limitations

Run it

The TripAdvisor Reviews Scraper does the above per hotel at $0.005 per delivered review, with the hotel context row, status rows and every filtered review free. For restaurants and attractions — with the four dining sub-ratings and the diner tips — use the restaurant and attraction scraper. If you are choosing between review sources first, the review scraper comparison puts the ceilings side by side, and which review sites can be scraped without a browser has the full evidence table this page draws on.

FAQ

Does scraping TripAdvisor need a headless browser?

No. The full review record — bubbles, title, text, the six sub-ratings, trip type, reviewer profile, photos and the management response — is inlined in the server-rendered HTML and in the page's own urqlSsrData JSON. One HTTP request returns ten reviews. What you need is the right IP pool and the right TLS fingerprint, not a browser.

Why does my Chrome-fingerprinted client get 403 and a browser does not?

Because the gate is DataDome, and its verdict is per-profile. Chrome profiles came back with a device check or a captcha; the Firefox 147 profile came back 200 on the same IP, in the same second. The same probe run against Despegar gave the exact opposite answer — Chrome passes, Firefox 147 is refused everywhere. Try both before calling a source closed.

Is there a TripAdvisor API for reviews?

Not a public one. The Content API is a partner product and does not return review text. TripAdvisor's own GraphQL gateway accepts persisted query ids only: a POST to /data/graphql/ids with x-requested-by answers 200, but the review-list query id is not in any of the page bundles (3.3 MB scanned, zero hits). The -orN- review pages carry everything anyway.

How many TripAdvisor reviews can one hotel return?

All of them, in the language site you read. In the verified example the English site listed 1,456 reviews against an all-language review_count of 1,977 — the rest live on the other language domains. Reading two domains returns a machine translation twice unless you turn translated reviews off.

Run TripAdvisor Reviews Scraper on Apify →

Other actors used on this page: apify.com/kestrel/tripadvisor-restaurant-reviews.

More guides