DEV Community

Matvey
Matvey

Posted on

Booking.com Search Rows Hide 9–16% of the Price in a Field Labelled 21, 22 and 23

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 search pages: destination in, one row per property out, with the price for your dates, the matched room, the rating and the location.

The field people build their spreadsheet on is priceTotal. It is the number Booking prints in the search card, it is the number the site sorts by, and it is not the number anybody pays.

On 12 October 2026 I ran the same stay — 20 to 23 November 2026, two adults, one room — against four destinations and read all 24 rows. Here is what the excluded-charges field said.

24 rows, four cities, nothing tax-inclusive

Destination priceTotal range Excluded charges Share of the headline price
Lisbon (EUR) €289.81 – €540.27 flat €24 + 6.00% 10.4% – 14.3%
New York (USD) $370.15 – $1438.51 flat $10.50 + 14.75% 0% – 16.2%
Tokyo (USD) $483.42 – $1101.10 9.01% – 10.00% 9.0% – 10.0%
Bali (USD) $96.09 – $875.27 9.01% or 10.99% 9.0% – 11.0%

Read the New York column slowly. priceTotal for the Hyatt Regency Times Square was $1120.95, and the row also carried $175.84 of charges that price does not contain. The guest pays $1296.79. If your rate-shopping table says $1120.95, your table is wrong by the size of a whole night.

Two of the per-city numbers are suspiciously exact, and that is the useful part:

  • New York: 14.75%, to the cent, on every row that had it. $110.06 on $746.20. $138.15 on $936.63. $212.18 on $1438.51. A percentage that stable is a tax rate, not an estimate.
  • Lisbon: a flat €24 on all six properties, regardless of a price range that runs from €290 to €540. Three nights, two guests, €24 — €4 per guest per night, charged per stay rather than per euro spent.

Tokyo came back at 10.00% on five rows and 9.01% on one. Bali is the interesting one: within a single destination, three properties excluded 9.01% and three excluded 10.99%, and which rate applied tracked exactly with the charge code in the field.

The field has amounts but no names

This is what taxesAndFeesExcluded actually looks like:

{
  "name": "Hyatt Regency Times Square",
  "priceTotal": 1120.95,
  "currency": "USD",
  "taxesAndFeesExcluded": ["22: $10.50", "23: $165.34"],
  "taxesAndFeesExcludedTotal": 175.8402,
  "chargesInfo": null
}
Enter fullscreen mode Exit fullscreen mode

22 and 23 are not my invention and not a truncation. Booking's GraphQL search response returns excluded charges as a list of objects carrying chargeMode, chargeInclusion, chargeType and amountPerStay — and for an anonymous caller chargeType is an integer. There is no translation node next to it, the way there is on badges, cancellation copy and review text. The site renders "+ taxes and charges" and hides the breakdown behind a tooltip that is assembled elsewhere.

My first version of this mapper expected names and produced "CITYTAX: US$18.24" in a code comment that never matched reality. The field was empty on every row for a while, because I was reading a translation key that is not there.

Across the four markets the codes behaved consistently enough to describe, though not enough to label:

  • 22 was always a flat amount per stay, identical across every property in a city: €24 in Lisbon, $10.50 in New York, $0 in Tokyo and on three Bali rows.
  • 21 and 23 were always a percentage of priceTotal: 6% in Lisbon, 14.75% in New York, 10% in Tokyo, 9.01% under code 21 and 10.99% under code 23 in Bali.

I ship the integers. I am not going to guess that 22 means "city tax" and put a word in a dataset column on the strength of six rows per market, because the moment I do, someone builds a VAT reclaim report on it. An unexplained 22 makes you go look. A wrong CITYTAX does not.

What it does to a sorted table

The percentage is near-constant inside a city, so within-city ranking mostly survives. I checked all 24 rows: adding the excluded charges did not flip a single pair. Lisbon stayed in the same order; New York stayed in the same order.

Cross-city comparison is where it breaks. A $500 Tokyo stay really costs about $550. A $500 New York stay really costs about $580. Build a "cheapest city for a three-night trip" chart on priceTotal and you have quietly handed New York a 6% discount it does not get.

So compute the honest number before you sort:

import requests

rows = requests.post(
    "https://api.apify.com/v2/acts/lergassy~booking-hotels-scraper/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={
        "mode": "search",
        "destination": "New York",
        "checkin": "2026-11-20",
        "checkout": "2026-11-23",
        "adults": 2,
        "currency": "USD",
        "sortBy": "price",
        "maxResults": 25,
    },
).json()

for r in sorted(rows, key=lambda r: r["priceTotal"] + (r.get("taxesAndFeesExcludedTotal") or 0)):
    true_total = r["priceTotal"] + (r.get("taxesAndFeesExcludedTotal") or 0)
    print(f'{r["name"][:40]:40} {r["priceTotal"]:8.2f} -> {true_total:8.2f}')
Enter fullscreen mode Exit fullscreen mode

Note the or 0. Which brings me to the bug this article found in my own code.

The zero that looked like a missing value

West Side YMCA, cheapest row in the New York sort at $370.15, came back like this:

{ "taxesAndFeesExcluded": ["23: $0"], "taxesAndFeesExcludedTotal": null }
Enter fullscreen mode Exit fullscreen mode

["23: $0"] is real: Booking returns the charge line with an amount of zero. But my aggregate was null, because the mapper ended in || null — and in JavaScript 0 || null is null. A property with genuinely no extra charges was indistinguishable from a property whose charges I failed to read. On a 25-row New York search, two rows were affected: West Side YMCA and HI New York City Hostel, both hostel-style properties.

One character, || to ??, except I removed the fallback entirely since the coercion already yields null for a missing field. Fixed and live as build 0.1.33 today; those two rows now report 0. If you pulled this Actor's data before 12 October 2026 and treated taxesAndFeesExcludedTotal: null as "unknown, skip the row", you dropped the cheapest properties in the market.

The other half of the row is prose, not data

While confirming the tax numbers I changed the language input and watched the same property come back four ways. MASA Hotel & Spa, Lisbon, same dates:

language distanceFromCentre
en-us (default) 2.6 miles from downtown
en-gb 2.6 miles from centre
de Zentrum: 4,2 km
fr 4,2 km du centre

Three things worth knowing before you parse that column:

  1. It is a sentence, not a number. There is no numeric distance field in the search response — Booking sends location.mainDistance already rendered.
  2. English means imperial, even British English. en-gb changed "downtown" to "centre" and left the miles alone. Metric arrives only when you leave English, and then with a comma decimal separator: 4,2 km.
  3. The wording changes, so your regex is locale-bound. de puts the label first and a colon in the middle.

Use latitude and longitude instead and compute the distance yourself. They are plain floats and they do not translate.

The same split hits reviewScoreWord: Wonderful in English, Sehr gut in German, Très bien in French. Filter on that string and your pipeline silently empties the day someone sets a language. Filter on reviewScore, which stays 9.3.

What does not translate is the half of the row my own code assembles: propertyType and roomBeds stayed Hotel and 1 king bed under de and fr, because those come from integer→name tables inside the Actor, not from Booking. So a non-English run gives you a row that is half localised and half English, and the seam runs exactly along "who mapped this field". Worth knowing if you ever diff two runs and wonder why only some columns moved.

One more locale trap: priceTotalText is a formatted string — "$147.25" but "€ 524.51", with a non-breaking space and the symbol on the other side. Parse priceTotal, which is a float.

Region searches have no centre

Bali returned destinationType: "REGION" and distanceFromCentre: null on all six rows. Lisbon, New York and Tokyo came back as "CITY" and filled it on every row. That is not a failure — a 5,780 km² island does not have a downtown — but if your dashboard has a "distance from centre" axis, it will be empty for every region, landmark and country-level search you run. Check destinationType before you plot it.

When this is the wrong tool

  • You need to actually book. This reads public search data. Taking a reservation means Booking's affiliate programme, which is a contract, not a scraper.
  • You need yesterday's prices. Nothing stores the past. Hotel pricing is only observable going forward, so schedule a daily run now if you want a history in March.
  • You need the exact final figure at checkout. The excluded charges are Booking's own estimate of what the property will add; resort fees, card fees and local levies collected at the desk are not all in there.
  • You need every rate of 500 hotels. Rooms and rates mean a request per property. That is fine for fifty and slow for five hundred.

If reading hotel search data as rows is what you want, the Actor is Booking.com Hotel Search Scraper — search, single hotel, reviews and a year-ahead price calendar, no browser and no login. The four-city measurement in this post cost 7 cents.

And whatever you use to get the data: before you sort by price, add the field that says the price is incomplete.

Top comments (0)