I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads Trip.com: a city, two dates and a guest count in, one row per hotel out, with the live nightly rate for exactly those dates. Trip.com is the booking channel for most of Asia and it has no public web API, so a scraper is the only way in. The field I expected to cause trouble was the price. It didn't. What caused trouble was something I hadn't thought to check: which hotels the page shows you at all. The same URL, five times, one day My QA runs hit a hardcoded list URL so I can diff the output day over day. On 21 October 2026 five of them fired against a byte-identical URL: https://www.trip.com/hotels/list?city=723&checkin=2026%2F11%2F20 &checkout=2026%2F11%2F23&adult=2&children=0&curr=USD&locale=en-XX Enter fullscreen mode Exit fullscreen mode Bali, three nights, two adults, USD. Pulls at 00:30, 07:48, 07:55, 08:20 and 12:10 — an 11-hour-40-minute span. Twelve hotel rows each, which is one page, 60 rows total. Those 60 rows contained 21 distinct hotels. Six appeared in all five pulls. Nine appeared exactly once. Appeared in Hotels Median nightly 5 of 5 pulls 6 $22 4 of 5 3 $30 3 of 5 3 $89 1 of 5 9 $52 Pairwise, the overlap decays with the gap between requests, and it decays cleanly: Pair Gap Common rows 07:55 vs 08:20 25 min 12 / 12 07:48 vs 07:55 7 min 11 / 12 00:30 vs 07:48 7 h 8 / 12 08:20 vs 12:10 4 h 8 / 12 07:48 vs 12:10 4 h 7 / 12 00:30 vs 12:10 11 h 40 6 / 12 So this is not per-request randomness. Requests minutes apart come back identical or off by one row. Requests hours apart come back half different. That is the signature of a result set that is assembled and cached, then reassembled later — not a ranking you can treat as stable. It replicates. Three pulls on 20 October against the 19–22 November window: the two fired a minute apart agreed 12 of 12, the one twenty hours earlier agreed 7 of 12 with both. Two pulls this morning, four hours apart, agreed 7 of 12 — a $260 Kempinski vanished, a $283 resort appeared. What that does to a number you'd actually report The prices held almost perfectly still. Across the six hotels present in all five pulls, the nightly rate moved in three of them, by exactly one dollar: $34→$35, $10→$11, $28→$27. Kenran Resort sat at $72 in all five, Moonloka at $16. If you track one named property, Trip.com is boringly consistent and this article is irrelevant to you. Aggregate those same five pulls and the stillness disappears: Pull Median nightly Mean nightly 00:30 $31.00 $51.60 07:48 $35.00 $62.80 07:55 $31.50 $61.40 08:20 $31.50 $61.30 12:10 $23.50 $39.00 The mean nightly rate for Bali swung from $39.00 to $62.80 — a 61% spread — on one day, from one URL, with essentially no price changing. Every dollar of that swing is sampling. The 12:10 pull happened to miss the expensive tail: no Anvaya at $110, no Kayon Jungle at $281, no ARNA at $90. The 07:48 pull caught all three. Which direction it biases is not random either. The six properties that never moved are all in the cheap half — $9 to $72, median $22. The nine one-time visitors have a median of $52 and include $86, $98, $106 and $166. Page one has a sticky cheap core and a rotating expensive tail, and a "city average" built on page one inherits whichever part of that tail it happened to catch. Asking for more rows is a different question, not a bigger answer The obvious fix is to pull deeper. It works, but not in the way "more data, same answer" suggests. Three runs on Bangkok this morning, 21–24 November, same two adults, minutes apart: 12 rows: median $38.50, mean $45.10 a second 12-row run three minutes later: 11 of 12 the same hotels, zero price differences, order reshuffled 48 rows, thirty seconds after that: 48 distinct properties, median $53.50, mean $75.30 Median $38.50 against $53.50 on the same city, the same dates, the same minute. Page one is not a random sample — it is cheap-skewed, and each extra page walks you up the price distribution. The 48-row run also did not contain 2 of the 12 hotels the first run had just returned. The practical rule I now use: if you want a city-level statistic, fix the row count and never compare two samples of different depth. If you want coverage, run it more than once and union on hotelId — the churn is working for you then, because five pulls of 12 gave me 21 properties, not 12. import requests, statistics from collections import defaultdict TOKEN = "" seen = defaultdict(dict) for _ in range(5): # five pulls, spaced hours apart by a scheduler rows = requests.post( "https://api.apify.com/v2/acts/lergassy~tripcom-scraper/run-sync-get-dataset-items", params={"token": TOKEN}, json={"cityId": 723, "cityName": "Bali", "checkIn": "2026-11-21", "checkOut": "2026-11-24", "adults": 2, "maxHotels": 48, "currency": "USD"}, ).json() for r in rows: if r.get("type") == "hotel" and r.get("price"): seen[r["hotelId"]] = r # last write wins, one row per property print(len(seen), "distinct hotels") print("median", statistics.median(r["price"] for r in seen.values())) Enter fullscreen mode Exit fullscreen mode Note the type == "hotel" filter. The last row of every run is a free notice row with the run's own accounting — rowsDelivered, chargedTotalUsd, timings. The 48-row Bangkok run reports {"rowsDelivered": 48, "chargedTotalUsd": 0.144} and finished in 28 seconds. Leave it in your dataframe and it becomes a hotel with no price. Two price fields you should not multiply While I had 451 priced rows from 22 runs sitting on disk, I checked the arithmetic between price (nightly) and totalPrice (the stay). totalPrice was larger than price × nights in 451 of 451 rows. Median ratio 1.200, range 1.056 to 1.339. Two things are stacked in there: totalPrice is the number Trip.com prints as incl. taxes & fees, and price is rounded to a whole currency unit, which on a $9 room is an 11% rounding step on its own. I am not going to tell you the tax rate, because Trip.com ships a formatted total and a rounded nightly figure and splitting them is guessing. What I will say is that price * nights is never the number the guest pays, and the error is a fifth, not a rounding wobble. The discount badges have a smaller version of the same gap. 373 rows carried an originalPrice, and it was above price in all 373 — median implied discount 29.4%. On the 364 rows that also carried an "N% off" tag in dealTags, the discount you compute from the two numbers beat the badge in 341 of them, median by 2.7 points: Lloyd's Inn says 61% off with $80 → $28 next to it, which is 65%. Use the badge as a label and compute your own number, and don't expect them to agree to the point. The Actor is Trip.com Scraper — hotel cards with the live rate for your dates, property details, and the guest reviews off the hotel page, by city ID or by URL. When this is the wrong tool You need a booking. This reads public pages. There is no availability hold, no rate code and no reservation here. You need every hotel in a city, exactly once. You will get there by unioning repeated runs, not by one run. If you need a guaranteed complete inventory as of a timestamp, Trip.com's page one will not give you one. You need per-room rate plans. Each row carries the one room Trip.com chose to price for your dates — roomName, roomId — not the hotel's full rate table. You need a tax breakdown. You get the pre-tax nightly figure and the all-in total. The split between them is not published as fields, and I'd rather ship two honest numbers than three invented ones. If you keep one thing from this: before you trust a scraped ranking, pull it twice an hour apart and count the overlap. A field that disagrees with itself is obvious. A row set that quietly swaps half its members is not, and it moves your averages far harder than any price ever will.
One Trip.com URL, Five Pulls, Twelve Hours: 21 Different Hotels, Six Every Time
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.