I run a visa agency in Bali. The other half of my week goes into scrapers on Apify, and one of them reads Booking.com reviews — one row per review, score, the liked and disliked boxes, stay dates, guest country, room, and the hotel's reply.
Reviews are a nice dataset to work with because almost every column is either a number or a date. There is exactly one column that looks trivial and isn't: language.
Everybody uses it. You filter to English before you run sentiment analysis, you group by it to see which markets a property attracts, you split the corpus by it before you feed anything to a model. It is a two-letter string. What could go wrong.
The panel
I did not need new runs for this. The Actor has been testing itself every night since 10 October with one fixed input — the newest 25 reviews of two Bali hotels, The Magani in Seminyak and Alila Seminyak — and reading back my own datasets is free. I pulled every run in the window, including the ad-hoc ones I did by hand.
- 48 runs, 10 October – 10 November 2026
- 1,983 rows: 1,955 reviews, 23 free run-summary notices, 5 free error rows
-
146 distinct
reviewIds, across six properties (two Bali hotels read daily, four read once)
Here is the language column over those 146 distinct reviews, counted on the first read of each:
en 95 de 10 it 3 ar 1 zh 1 nl 1 pt 1 ru 1 ja 1
xu 16 fr 8 tr 2 el 1 xa 1 xm 1 pl 1 no 1 bg 1
xu. xa. xm. Those are not ISO 639-1 codes, and together they are 18 of 146 rows — 12% of the panel. Sixteen of the eighteen are xu, and xu is the second most common value in the whole column.
What xu actually is
Eleven of the sixteen xu rows carry no review text at all, just a score. The five that do are in English:
xu AE 10 "I honestly loved everything about this hotel—the beautiful architecture, the room, pools…"
xu AU 10 "The rooms are beautiful, spacious and clean and lovely to come home to after exploring…"
xu US 8 "Buffet and service \nRai was very attentive"
xu SA 9 "All" (negative: "Nothing")
xu AE 10 "Every detail of this hotel is amazing! The calmness, the pools, overall hotel design…"
The guests sit in Australia (5), the UAE (4), Saudi Arabia (2), Kuwait, Canada, Cambodia, the US and Bulgaria. So my working theory was the obvious one: xu is one of Booking.com's internal locale keys for English, the way en-us and en-gb are two keys for one language.
A theory about a scraped field is worth nothing, so I asked the site. The Actor passes the Reviews language input straight into Booking.com's own review filter, which means I can ask for a code and see which rows come back. Six paid experiments, all on 11 November 2026, all newest order, two hotels — Alila Seminyak in Bali and a hotel in Prague with a mixed European guest list.
| I asked for | rows | what the language column said |
|---|---|---|
en (Bali) |
25 | 19 × en, 6 × xu
|
xu (Bali) |
10 | 9 × en, 1 × xu
|
zz (Bali) |
0 | — |
es (Prague) |
10 | 10 × es
|
xm (Prague) |
10 |
10 × xm, all guests in Mexico, all texts Spanish |
xa (Prague) |
5 | 5 × es, guests in Spain and Uruguay |
xb (Prague) |
5 | 4 × xb (Brazil), 1 × pt
|
Three things fall out of that table, and only the first one is comfortable.
xu really is English. Ask for en and you get xu rows mixed in; ask for xu and you get en rows mixed in. Server-side, they are one bucket. So any pipeline that filters English through Booking's own filter has always been correct, and any pipeline that filters row.language == "en" in pandas has been silently throwing away a tenth of its English reviews.
Spanish is not one bucket. es returned Spain and Uruguay. xm returned Mexico, and nothing labelled es came with it. Ask Booking.com for Spanish reviews the obvious way and you do not get the Mexican ones. The two xm/xa rows in my month-long panel are a rounding error; on a Mexico-facing property they would not be.
An unknown code is silent. zz is a valid-looking two-letter string and not a language Booking.com knows. The response was not an error — it was a successful page with zero reviews on it. My run ended with an empty dataset, SUCCEEDED, nothing charged, and nothing anywhere saying why. That is the failure mode I hate most: it looks exactly like a hotel with no reviews.
What I shipped
Three changes, all live as build 0.1.42.
A languageIso column. language keeps Booking.com's raw key, because changing a column's values under people who already store them is rude. The new column folds the locale keys into plain ISO 639-1 — xu → en, xa and xm → es, xb → pt — and that is the column to group by. On the same Bali hotel, 10 rows: before, nine en and one xu; after, languageIso reads en ten times out of ten.
The language filter now asks for the whole family. en sends en and xu, es sends es, xa and xm, pt sends pt and xb. Booking's filter takes an array, so this costs nothing extra. Measured on the Prague hotel, 25 reviews, reviewsLanguage: "es":
build 0.1.40 25 rows 25 × es, 0 Mexican Spanish
build 0.1.42 25 rows 23 × es, 2 × xm
An unknown code now says so. A hotel that returns zero reviews while a language filter is set produces one free type: "error" row naming the code, listing the keys that exist, and telling you to clear the field. Free rows are free: that run is still billed $0.00.
{
"type": "error",
"scope": "reviews-language",
"error": "Alila Seminyak: Booking.com returned no reviews in language \"zz\". The filter is matched against Booking.com's own language keys, which are not all ISO 639-1 codes — English also appears as \"xu\", Spanish as \"xa\" and \"xm\", Portuguese as \"xb\". Clear \"Reviews language\" to get every review, or try one of those keys."
}
Four more things a month of re-reading the same hotels showed
Re-reading one query every night is a boring way to run a scraper and a very good way to find out which of your columns are stable.
The hotel's reply is not in the row you bought. Of 146 distinct reviews, 17 already had a reply when I first saw them, 14 grew one later, and 115 still have none. The lag between my first read and the reply appearing ran 1.8 to 7.0 days, median 3.9. If you monitor a property daily and dedupe by reviewId — which my own only-new mode does — you will buy every review the morning after it is posted and never see a single reply. For reply tracking you have to re-read the window on purpose. Related and more interesting than the mechanics: The Magani (9.1 on 1,594 reviews) has answered 31 of its 59 reviews in the panel; Alila Seminyak, same neighbourhood, 9.0 on 700 reviews, has answered 0 of 47.
42% of the reviews have no text. 62 of 146 have both the liked and the disliked box empty, and 85 have no title. Those rows are not junk — they carry a score, a country, a traveller type and a room — but they are not corpus. And they are not a random sample of opinion either: the textless rows average 9.13 against 8.76 for the ones with text, and exactly 1 of 62 is below 7, against 8 of 84. People who type nothing are people who had a fine time. Measure "share of reviews under 7" over the text-bearing subset and you will overstate the complaint rate.
Photo URLs change on every read without changing. Sixteen reviews in the panel have photos. The same image id came back as r-xx.bstatic.com/... on one run and q-xx.bstatic.com/... on the next, alternating all month — 760 URLs on one host, 758 on the other, same path, same ?k= signature. If your change detection hashes the whole row, every review with a photo is "new" forever. Dedupe on reviewId.
Three columns never moved. helpfulVotes was 0 on all 146, isAnonymous false on all 146, stayStatus "stayed" on all 146. I am not deleting them — they are real fields and other properties will fill them — but do not build a feature on a column until you have re-read it a few hundred times.
Calling it
curl -X POST "https://api.apify.com/v2/acts/lergassy~booking-reviews-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"hotelUrls": ["https://www.booking.com/hotel/id/alila-seminyak.html"],
"maxReviewsPerHotel": 25,
"reviewsSortBy": "newest",
"reviewsLanguage": "en"
}'
from collections import Counter
from apify_client import ApifyClient
client = ApifyClient("YOUR_TOKEN")
run = client.actor("lergassy/booking-reviews-scraper").call(run_input={
"hotelUrls": ["https://www.booking.com/hotel/cz/jeromehouse.html"],
"maxReviewsPerHotel": 100,
"reviewsSortBy": "newest",
})
rows = [r for r in client.dataset(run["defaultDatasetId"]).iterate_items()
if r.get("type") == "review"]
print(Counter(r["languageIso"] for r in rows)) # group by this
print(Counter(r["language"] for r in rows)) # not by this
print(sum(1 for r in rows if not r["positive"] and not r["negative"]), "rows have no text")
That is Booking.com Reviews Scraper — $1 per 1,000 reviews, no per-run and no per-hotel fee, error rows free. The ten-row verification run above finished in 5 seconds.
When this is the wrong tool
You want every review a property ever had, including the silent ones. You will get what the review list serves, in the order you asked for, up to 5,000 per hotel per run. The headline count on the page is not a target you can hit with text filters on.
You want reply dates. Booking.com's review query returns the reply text and no date for it. I dropped the column rather than ship an empty one; if you need reply latency, you need the timestamp of your own read.
You want to track edits. Scores, titles and texts did not change once in a month across 1,955 reads of 146 reviews. Only replies, photo hosts and — on exactly one review — the room name and stay dates ever moved. If you are hunting for edited reviews, this is not where they live.
Top comments (0)