Google Hotels scraper and hotel price tracker: Google Hotels prices for any destination and dates — lowest price per night and for the stay with taxes, every booking site's rate with direct links (rate parity), class, rating, reviews, amenities, location. One flat row per hotel, no personal data.
History of google-hotels-scraper (folder actors/google-hotels). Versions are the Apify build numbers.
Unreleased (free cancellation, freeCancellationOnly and the counters of each search)
Brief docs/cloud-tasks/2026-10-23_google-hotels-extend-cancellation-counts.md (2026-10-23). No schema change: the row stays travel-listing/1.7, the deadline goes into the existing cancellationPolicy with the studio's convention (booking-hotel-details), so no new field and no second minor version after the all-in price.
cancellationPolicy is filled from the prices answer (lvkpBc), hence with includeOffers, hotelUrls or freeCancellationOnly: Google gives cancellation terms only for the room rates of the sponsored booking-site blocks (h[6][16][0], h[6][17][0], rates e[24][*]); the organic offers of offers carry none. A rate is free to cancel when it has feature code 19 or an English "Free cancellation until " label: in the three prices captures (314 rates) the code and the label go together on every rate, code 2 being breakfast. Values: free_cancellation_until:<YYYY-MM-DD> with the earliest deadline of those rates (every free-cancellation rate shown can be cancelled at least until that day; the year is the last one that puts the day on or before the check-in, never before the scrape day), free_cancellation when no label gives a readable deadline (another language than English), null otherwise. Captures: Brown's Avenue free_cancellation_until:2026-11-17 (Priceline's rate, the other sites the 18th), The Central House 2026-11-18 in USD and 2026-11-17 in EUR (Hostelworld). The README example shows it.
freeCancellationOnly (boolean, default false): keeps only the hotels with a free-cancellation rate. Applied by the Actor (no Google filter was found in the audit), so it turns includeOffers on; a hotel left out is never charged, the next new hotels of the same page take its place before the next page is requested; a hotel whose rates cannot be fetched is left out too (its terms are unknown). Same rule for hotelUrls. A later search of the run counts the hotels an earlier one left out without asking Google again. Part of the price-monitoring identity only when on (the existing records keep their keys).
Counters of each search, for "I asked for N hotels and got fewer": RUN_STATS.searches now gives kind, requested, announced (Google's total, 15,000 at most), served (distinct hotels of the pages Google served; for hotelUrls, the hotels whose rates were obtained), returned, pages, notReturned (beyondMaxResults, vacationRentals, foundEarlier, noFreeCancellation, unchanged, errors, chargeLimit, runStopped), stopReason (max_results, last_page, exhausted, page_cap, request_cap, charge_limit, page_failed, all_hotels, not_searched) and explanation (one sentence, also the last log line of the search); the first fields (search, hotels, duplicatesSkipped, announced, searched) are unchanged. served = returned + the sum of notReturned (checked by every crawler test). The same list goes to a new SUMMARY record of the default key-value store (google-hotels-summary/1.0, in the output schema), and, shortened, to the terminal status message ("60 hotels · 9 requests · 0 blocked · hotels in Lisbon 60/60 (maxResults), 15,000 announced, 134 served", 250 characters at most, "+N more in SUMMARY" beyond): the shared runner writes that message, main.ts wraps Actor.setStatusMessage to append the counters to the terminal message only.
New counters extra.freeCancellation (hotels written with a free-cancellation rate) and extra.noFreeCancellation in RUN_STATS and the "Done" log line. The message of a run without any result mentions SUMMARY for freeCancellationOnly.
README: cancellationPolicy bullet, input row, Free cancellation in Booking sites and direct links, a Limits line on the counters, What is not included, AI agents, alternatives and pricing table; dataset schema: description of cancellationPolicy, shown in the Booking sites view.
Not verified offline (first run on the Mac): the share of hotels whose prices answer has sponsored rates (2 of 2 in the captures), labels and deadlines in other languages (hl other than en), the share of hotels kept by the filter and the extra rate calls it costs, the status message as the Console shows it.
Not changed: prices, example tasks, Store texts.
Unreleased (all-in price: taxesIncluded and priceTotalAllIn)
Brief docs/cloud-tasks/2026-10-23_travel-prices-all-in.md (2026-10-23): rows are travel-listing/1.7 (the price-comparison schema, formerly 1.4, on top of the search schema travel-listing/1.5), two columns after taxesAndFees, nothing else changes (priceTotal keeps its meaning: the lowest stay price with taxes and fees).
taxesIncluded = true whenever there is a priceTotal (the last item of Google's [base, taxes, fees, total] of the stay; a test checks on every priced hotel of the committed searches that the total is the sum of the three), null without a price.
priceTotalAllIn = priceTotal, null without a price (allInPriceTotal of @scraper-studio/core/schema). The offers of offers are unchanged (they already carry their prices before and with taxes).
Dataset schema: both columns documented, priceTotal no longer called "the column to compare" (that is priceTotalAllIn); Overview shows priceTotalAllIn next to priceTotal, Prices shows both after priceTotal. README: priceTotal / priceTotalAllIn bullet, FAQ on taxes, sample output with priceTotalAllIn (instead of propertyType), the strengths worth knowing when switching (hard maxResults cap per search never charged beyond, onlyChanges), More scrapers pointing at priceTotalAllIn.
Unreleased (repair: a call Google refuses no longer fails the whole run)
Brief docs/cloud-tasks/2026-10-22_google-hotels-fix-release.md (2026-10-22), PR #77. The control run of the release after PR #76 (Yc79YVYWEcLxCjnLm, inputs/control-scale.json: Paris and Rome, 300 hotels each, without the booking-site rates) ended as FAILED after 467 hotels, without RUN_STATS, on the log line ERROR Run failed. The run log was not available to the repair session; read from the code, the only exception of the crawler reachable in that run (no monitoring, no booking-site rates) once rows were written is GoogleRejectedError: one AtySUc call answered by wrb.fr with an error code, which 0.1 never retried and turned into a failure of the whole run (a mid-run "format change", although the previous pages of the same request had been served).
A wrb.fr answer with an error code is now an unusable answer like the others: retried 3 times on a new proxy session with a pause of 1, 2, 4 s, then the page is abandoned (the search stops with its hotels, the next searches run) or the hotel is written without its booking-site rates (offersIncluded: false), or, for hotelUrls, abandoned. The rows already written are kept, RUN_STATS is written and the run succeeds.
The run fails with GoogleRejectedError ("Google rejected the request (code N): the request format changed") only when it obtained no hotel and the last failure was a refused call: a real change of the request format still gives the explicit message, after the retries.
New counter extra.rejected (attempts refused by Google) in RUN_STATS and in the "Done" log line; the reason of each refused attempt is logged with its code ("Google refused the call, error code N"), so that the next control says which code Google sent.
Price monitoring stays fail-closed: a search with an abandoned page is an error, its record is not updated.
Tests: a page refused once then served (no error, 1 retry); every attempt of page 2 refused (rows of page 1 kept, next search run, 1 error); a refused prices call (hotel written without rates); every call refused with nothing obtained (explicit error after 1 + 3 attempts). The four fail on 0.1. The committed inputs are also checked against requestBudgetWarning (maxRequests enough for maxResults per search).
Not changed: inputs, Store texts, example tasks, output schema, prices.
0.1 — 2026-10-22
First version, written offline on the committed fixtures of the audit (docs/audits/google-hotels.md, brief docs/cloud-tasks/2026-10-22_google-hotels-build.md). Source: the batchexecute RPC endpoint of the Google Travel web app, AtySUc for the hotel search (20 hotels per call, 18 in a variant) and lvkpBc for the rate of every booking site of one hotel. The only URL ever requested is https://www.google.com/_/TravelFrontendUi/data/batchexecute (checked before every request, and by a test over every request of the crawler tests): never /travel/search, /travel/entity, /travel/clk, /travel/lodging/clk nor /aclk, disallowed by robots.txt. Requests are byte for byte those of the captures (URL with the bl build label of the audit, headers without cookie, f.req form body); Accept-Language follows hl.
Transport got (the default of httpRequest), Apify datacenter proxies by default, a new proxy session per attempt, no cookie. An unusable answer (status ≠ 200, HTML page, body without wrb.fr) is retried 3 times with a pause of 1, 2, 4 s, then the page (or the hotel) is abandoned with a log line; a wrb.fr answer with an error code fails the run at once ("Google rejected the request (code 3)"). Usability is decided by parsing the answer, not by the generic block markers, which could match a hotel description ("just a moment from the beach").
Pagination by the token of each answer (qs[1]), global deduplication by entity id per stay (Google's order is not deterministic: about 16 new hotels per call), stops at maxResults hotels per search, without a next token, after two pages without a new hotel, or after ceil(maxResults / 12) + 3 calls (30 at most). Hotels cut by a search's cap stay available to the next search of the run. The pages of a search run one after the other; the booking-site calls (includeOffers, hotelUrls) run concurrency at a time (default 3).
Inputs: searchQueries (prefilled with "hotels in Lisbon", no schema default so that runs made of hotelUrls do not search Lisbon too; {} still searches it), startUrls (Google Hotels search URLs read locally: q, the ts protobuf for dates, travellers and currency, hl, gl; other parameters ignored and listed in the log), hotelUrls (URLs with /entity/<id> or ids, up to 500, split into tasks of maxResults so that the charge cap covers them), fixed or relative dates, adults (1–6), children's ages, currency (any ISO code; USD and EUR validated), language (hl), country (gl), maxResults (1–300 per search), includeVacationRentals, includeOffers, maxOffersPerHotel, price monitoring, maxRequests, concurrency, delayMs, debugStoreResponses (alias of the runner's debugStoreHtml).
Output schema travel-listing/1.4 (packages/core/src/schema/travel-prices.ts, additive): every travel-listing/1.2 column, then pricePerNightWithTaxes, priceTotalBeforeTaxes, description, checkInTime, checkOutTime, website, ratingBreakdown, googleHotelId, googleMapsCid, googlePlaceId, offersIncluded, offers (site, site id, official site, prices per night and of the stay before and with taxes, direct link decoded from pcurl), offerCount, cheapestOfferSite, cheapestOfferPriceTotal, officialSitePriceTotal. pricePerNight is Google's nightly price before taxes (base + fees per night), priceTotal the stay with taxes and fees, taxesAndFees = taxes + fees, priceTotalBeforeTaxes = base + fees (Google's "before taxes" total). Offers are sorted by stay price with taxes, offers without it last.
Local decisions on the fixtures: country is the English name of countryCode; city is the city Google resolved for the search (the hotel's own city for hotelUrls); vacation rentals keep maxGuests, bedrooms and bathrooms from h[19][1] but beds stays null (the 5th value of that array differs from the "N beds" Google displays for 11 of the 13 rentals of the captures, so it is not the number of beds); non-breaking spaces of Google's labels ("3:00 PM", "Hôtel 3 étoiles") become plain spaces; amenities of hotels come from the code table of the audit (§ 5 bis, codes without a label ignored), rental amenities are Google's own labels; in prices-only mode (hotelUrls) amenities is [] (the prices answer uses other codes). The address of the prices answer is not output (no column in the schema).
Price monitoring (model: expedia-hotels): monitorPrices, monitorStoreName (default google-hotels-price-monitor), onlyChanges; one record per search (google-hotels-monitor/1): search text or list of hotels, stay (fixed or relative), adults, children, currency, hl, gl, rentals included or not; compared price priceTotal (else pricePerNight); records written only after a clean crawl.
Not verified at build time (first run on the Mac): the occupancy of the prices RPC beyond 2 adults (children passed as the search echoes them), currencies other than USD and EUR, the real pagination depth, the pace at concurrency 3, the share of 18-hotel answers, the cost with offers.
Store readiness: README (Booking sites and direct links, Search from Google Hotels URLs, Price monitoring, What is not included, FAQ), input schema, dataset schema with Overview / Prices / Booking sites / Location / Changes views, output schema, 8 example tasks with relative dates on datacenter proxies, consistency tests (README ⇔ schemas ⇔ code, Ajv replay of the 121 fixture rows), crawler tests on a fake HTTP client.