I run a visa agency in Bali. The other half of my week goes into scrapers on Apify, and one of them answers a question my own travel planning keeps asking: what does every hotel in a city cost for these dates, all in, with the taxes?

That part I have written about. What I had never measured is the optional half of the Actor — the per-seller breakdown. Switch includeOffers on and each hotel row grows an offers array: every booking site Google lists for that property, with the stay total, the per-night price and a deep link, quoted for your dates and your currency.

I always assumed that array was a price comparison. It mostly isn't.

The panel

Four runs, all on 10 November 2026, all for the same stay: check-in 2026-12-09, check-out 2026-12-11, two adults, USD.

  • Paris, Tokyo, Bali, 60 hotels each — 180 rows in 184 seconds, $0.54. No offers; this one was for the base fields.
  • Three more runs with includeOffers on, 12 hotels a city.

After de-duplicating by name and city that gives me 44 hotels carrying an offer list — 20 Paris, 12 Tokyo, 12 Bali — and 645 individual seller offers. Every one of the 44 rows came back with offersForSearchedDates: true, so these are prices for my stay, not for some default two-weeks-out window Google likes to fall back on.

18 sellers, 11 prices

Thirty-three of the 44 hotels had eight or more sellers. For those, the median hotel is listed by 18 sellers quoting 11 distinct totals. Roughly a third of the names in the list are not carrying new information.

The compression is not evenly spread. Edo Sakura, a small Tokyo hotel, came back with 17 sellers:

$224 Agoda, KAYAK.com, Expedia.com, Hotels.com, Booking.com, Vio.com, Orbitz.com, Travelocity.com, CheapTickets.com, Hotelscombined.com, goseek.com, HomeToGo, momondo.com, eDreams, Etrip.net <- 15 sellers, one number $240 Edo Sakura (the hotel's own site) $247 Bluepillow.com 

Seventeen rows, three prices, and the only seller that beat the pack is nobody — the hotel's own site is $16 dearer than fifteen agencies selling the same two nights.

Three families that never disagree

I took all 645 offers, formed every pair of sellers that appear on the same hotel, and asked how often the two quote the identical total. Pairs that met at least eight times:

pair identical totals
Expedia.com == Travelocity.com 24 / 24
Expedia.com == Orbitz.com 22 / 22
Orbitz.com == Travelocity.com 22 / 22
CheapTickets.com == Expedia.com / Orbitz.com / Travelocity.com 19 / 19 each
Hotelscombined.com == KAYAK.com 17 / 17
KAYAK.com == momondo.com 16 / 16
Hotelscombined.com == momondo.com 16 / 16
Etrip.net == Vio.com 9 / 9
Etrip.net == goseek.com 8 / 8
Hotels.com == Expedia.com 18 / 28
Hotels.com == CheapTickets.com 14 / 19

Not "usually". Not "within a dollar". All four Expedia Group names appear together on 19 of my 44 hotels, and on all 19 they print one identical total. KAYAK, HotelsCombined and momondo appear together on 15 hotels and agree on all 15. Expedia Group owns the first four; KAYAK operates both HotelsCombined and momondo. The vendor list is not a market, it is a corporate org chart rendered as rows.

The honest exception is in the same table. Hotels.com is an Expedia Group brand too, and it agrees with its siblings only 64–74% of the time — so "same owner" is not a rule you can hard-code either. It is a hypothesis you check per run.

What this does to a shopping feature is concrete. If you show a user "17 places to book this hotel", you are showing them three choices and fourteen duplicates. Collapse on totalPrice before you render anything, keep one seller per price — preferably the one with a direct link — and the list gets honest and short.

The spread is not a discount, it is a different room

The other half of the surprise runs the opposite way. Across those 33 hotels the most expensive seller charges a median of 1.52x the cheapest. The minimum was 1.10x. The maximum was 5.52x:

Pita Maha Resort & Spa, Ubud — card price $176, 11 sellers, 11 prices $176 Traveloka.com $331 Pita Maha Resort & Spa (official) $198 Stayforlong $335 Priceline $199 Super.com $497 Booking.com $209 Agoda $889 Hotels.com $214 Evendo $971 Qantas Hotels $224 Bluepillow.com 

Nobody is selling the same room at 5.5x. Those are different room categories, different board, different cancellation terms — and Google's offer list does not tell you which. offers[].rooms is populated on only 65 of my 645 offers (387 room entries in total), so for nine sellers in ten you get a number with no idea what it buys.

So: the minimum of offers[].totalPrice is a floor, not an apples-to-apples best price. Treat a seller that is 3x the rest as a signal that it is quoting a suite, not as a seller to shame in your UI.

Three field-level things I checked because I ship this

cheapestOfferTotal matched the card's totalPrice on 42 of 44 hotels. The two that didn't are worth knowing about: Crowne Plaza Paris – République carded at $306 while Etrip.net offered $295, and Jimbaran Bay Beach Resort carded at $80 against Traveloka's $81. The card price and the offer list are produced by two different Google responses, and on a small minority of hotels they are a few dollars out of sync. If you need them consistent, take the minimum of the two yourself.

vendorCount equalled len(offers) on 44 of 44. No hidden sellers behind the count.

The offer totals are pre-tax, exactly like the card price. MEININGER Hotel Paris Porte de Vincennes shows totalPrice: 100 and totalWithTaxes: 124.52, and the hotel's own deep link in the offer array spells it out in the query string: &total=124.52&tax=24.59. Only 29 of my 645 links carry that parameter — most go straight to the seller's own domain — but where it exists it confirms that the number you are reading is not the number at checkout.

The gotcha I walked into myself

maxOfferLookups is a cap per run, in result order — not per location. I asked for three cities, 12 hotels each, maxOfferLookups: 12, and got exactly what I asked for and not what I meant: all 12 lookups went to Paris, and Tokyo and Bali came back with no offers at all. That is why the panel above is four runs instead of two.

If you want offers for several cities, run the cities separately. The charge is per hotel looked up ($8 per 1,000 on top of $3 per 1,000 for the row), so splitting the run costs the same as not splitting it.

Calling it

The whole thing from curl, one city, collapsed to distinct prices:

curl -s -X POST \ "https://api.apify.com/v2/acts/lergassy~hotel-price-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'content-type: application/json' \ -d '{"locations":["Tokyo, Japan"],"checkIn":"2026-12-09","checkOut":"2026-12-11", "adults":2,"currency":"USD","maxHotelsPerLocation":12, "includeOffers":true,"maxOfferLookups":12}' \ | jq -r '.[] | select(.type=="hotel" and (.offers|length)>0) | [.name, (.offers|length), ([.offers[].totalPrice]|unique|length)] | @tsv' 

And in Python, the de-duplication I argued for above — one seller per distinct total, cheapest first:

from apify_client import ApifyClient client = ApifyClient("<APIFY_TOKEN>") run = client.actor("lergassy/hotel-price-scraper").call(run_input={ "locations": ["Paris, France"], "checkIn": "2026-12-09", "checkOut": "2026-12-11", "adults": 2, "currency": "USD", "maxHotelsPerLocation": 12, "includeOffers": True, "maxOfferLookups": 12, }) for row in client.dataset(run["defaultDatasetId"]).iterate_items(): if row.get("type") != "hotel" or not row.get("offers"): continue best = {} for o in sorted(row["offers"], key=lambda o: (o["totalPrice"], not o.get("isOfficialSite"))): best.setdefault(o["totalPrice"], o) # keep one seller per price print(row["name"], f'card ${row["totalPrice"]} ' f'all-in ${row["totalWithTaxes"]:.2f} ' f'{len(row["offers"])} sellers -> {len(best)} prices') 

The Actor is Hotel Price Scraper; the inputs above are the inputs that produced every number in this post. The second run — 36 hotels plus 12 offer lookups — finished in 44 seconds and was billed $0.204, which is 36 × $0.003 + 12 × $0.008 to the cent.

When this is the wrong tool

You need the rate for a specific room. The offer list gives you totals per seller, and room-level detail only when the seller happens to publish it. For bed type, board and cancellation policy, go to that seller.

You need every seller's real cheapest price. Google decides which sellers to show and which rate from each one. A seller quoting a suite will outrank its own cheaper room in your table, and you cannot tell from the row.

You are building a price-history chart. One run is one moment. Schedule it; don't infer a trend from two reads a week apart.

What it is good at is the thing I built it for: a whole city priced for exact dates in under four minutes, with the all-in number next to the headline one — and, if you ask for it, an honest count of how few sellers are actually competing for a given hotel.