Booking.com hotels scraper and hotel price tracker (Booking scraper): hotel prices from Booking.com search results for any destination and dates — stay price, taxes, review score, reviews, stars, type, location, photo. Tracks price changes between runs. One flat row per property, no personal data.
History of booking-hotels-scraper (folder actors/booking-hotels). Versions are the Apify build numbers.
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.5, two columns after taxesAndFees, nothing else changes (priceTotal keeps its meaning: the stay before the charges Booking.com shows as excluded).
taxesIncluded: false when Booking.com shows excluded charges (excludedCharges, every result of the committed captures), true when it shows an excluded amount of 0, null without that amount, with an amount in another currency than the price, or without a price.
priceTotalAllIn = priceTotal + taxesAndFees when taxesIncluded is false, priceTotal when it is true, null otherwise (allInPriceTotal of @scraper-studio/core/schema): €637.43 + €62.25 = €699.68 for the README example.
Dataset schema: both columns documented; Overview shows priceTotalAllIn next to priceTotal, Prices shows both after taxesAndFees. README: Which price to compare paragraph, sample output with priceTotalAllIn (instead of propertyType), the strengths worth knowing when switching (hard maxResults cap never charged beyond, onlyChanges), More scrapers pointing at priceTotalAllIn to compare platforms.
Unreleased (list-first input for AI agents)
Brief docs/cloud-tasks/2026-10-23_travel-hotels-locations-first.md (2026-10-23), after Apify's post on Actors dropped by agents for a string primary input ("Make your primary input an array"): locations is the first field of the form, titled Destinations, prefilled with ["Lisbon, Portugal"] (default still []), its description starting with "One or more …".
location kept (example tasks, daily checks and API callers use it), titled Single destination, moved below startUrls, with destId and destType after it, without prefill; description "Single destination (kept for compatibility; prefer locations)…". No field is both required and with a default; every description stays under 500 characters.
inputs/daily-check.json = the new prefilled form: locations instead of location, as runInputQualityChecks requires (the daily check runs what Apify's daily test runs). Same search, same rows and charges; the daily-check task is updated at the next release.
Form behaviour to know: locations is always searched, also with a destId or startUrls (unchanged), so the prefilled Lisbon is searched too unless the list is cleared: a destId typed in the form gives two searches (the id, then Lisbon), and with locations set maxResults caps each search, so the prefilled form plus one search URL can write and charge up to 2 × maxResults rows. Before, the prefilled location was searched by the destId (one search), and with search URLs maxResults capped the whole run. The descriptions of locations and destId and the locations row of the README Input table say so ("clear the list").
README input example: the search of the validation run written with locations (destId and destType left out: a destId is searched for location, so locations plus a destId would make two searches).
README: the Input section (introduction, example, table) shows locations first; nothing else changes.
Tests: the prefilled form is locations alone, searched once, non-empty within maxResults on the fixtures and with the rows of the former prefilled form (location alone). No code change: location, locations or both are accepted as before.
Unreleased (quality score: input documented for AI agents)
Brief docs/cloud-tasks/2026-10-20_studio-quality-score-insights.md: the input form passes the shared Store checks (runInputQualityChecks, runSearchTextChecks of packages/core/src/testing/store-check.ts).
Bounds: destId −2,147,483,648 to 2,147,483,647 (any Booking.com destination id; negative for cities, said in its description), priceMin 0 to 1,000,000,000 and priceMax 1 to 1,000,000,000 (generous enough for currencies counted in millions such as IDR or VND). Descriptions of destId, priceMin, priceMax and maxResults completed accordingly; the other fields were already written as a tool documentation.
maxResults prefilled with 20 (default still 100): Apify's daily test of the public Actor runs the prefilled form, now a 20-property run.
inputs/daily-check.json = the prefilled form: { location: "Lisbon, Portugal", maxResults: 20, proxyConfiguration }. Removed destId: -2167973 and destType: "CITY" (put there in 0.1, when the text lookup was not yet validated): the daily check now resolves the destination as text, as Apify's daily test and the visitors' runs do since the location repair of 0.2, so it watches that path too (one to three light lookup requests more). Removed keys equal to the defaults (checkInDaysFromNow 30, nights 3, adults 2, rooms 1, currency EUR, language en-us, delayMs 800): same parsed input. store.json › dailyCheck.what says so; the live daily-check task follows when the pilot re-sends it.
store.json › searchSynonyms: Booking scraper, Booking.com scraper, hotel price tracker, hotel prices, rate shopping (all in the README's indexed head). README input table: destId, maxResults, priceMin, priceMax rows.
No code change: rows, logs and charges are identical.
Unreleased (several destinations, input written for AI agents)
Several destinations per run (brief docs/cloud-tasks/2026-10-20_travel-hotels-extend-agent-ready.md, 2026-10-21, model: airbnb-listings 0.3): new field locations (stringList, empty by default) next to location, which stays (location alone = the behaviour of the previous versions; both = union without duplicates, same text up to case and spacing; empty lines dropped; at most 50 destinations with location or destId, refused with a clear message beyond). location (or destId alone) is searched first, as before, then every other destination by text with the same dates, guests, rooms, sort, filters, currency and language, then the search URLs. With locations, maxResults caps each destination and each search URL (the runner's charger allows maxResults × searches, a full search stops without stopping the run); a property found by two destinations is written and charged once, under the first one (RUN_STATS.extra.duplicates); a property cut by a destination's cap stays available to the next ones; searchLocation is the destination that found the property; one log line and one RUN_STATS.searches entry per search (properties taken, duplicates skipped, searched or not); a warning when maxRequests is likely to stop the run before its last destinations. Price monitoring keeps one record per destination (the record identity holds the destination text). Without locations, maxResults keeps capping the whole run: the charger's run-level stop is reproduced by stopAtRunCap, and the rows, logs, charges and monitor records of every committed input and example task are those of the previous version (test replaying each of them at three maxResults on the fixtures, before / after).
Lisbon only without any destination: the default destination applies when the input names no location, destId, locations or search URL ({} unchanged). A run made of a destId alone is no longer labelled "Lisbon, Portugal": its task is dest <id> and searchLocation is null, as dataset-fields.json already documented (the monitor record of such a search, keyed by the destination text, starts a new reference once).
Input form written as the documentation of a tool: every description under 500 characters (one sentence on the field, the format with an example, the default and why: Default 100: enough to compare a city, about $0.42 on the Free plan, figure checked against the pricing file in force), no internal detail; filters shortened, the README keeps the full list. Most requested values first: sortBy top picks, price, best reviewed; currency USD, EUR, GBP (default still EUR); language becomes a select with suggested codes en-us, en-gb, fr, de, es… (any code still accepted, default en-us). Store tests: no required field, no default on a required field, every default named in its description, description length, locations documented with maxResults per destination.
README: locations row of the input table, three-city input example, For AI agents (MCP) minimal input with locations, FAQ Can I search several cities in one run? (the export and support question moved to Integrations and API, FAQ kept at 10), monitoring and pricing lines per destination, searchLocation documented. Example task Compare hotel prices in Paris, Rome and Barcelona (locations, 20 properties per city; 10 tasks in all).
Unreleased (filter fields)
Explicit filters (brief docs/cloud-tasks/2026-10-20_booking-hotels-extend-filters.md, 2026-10-20). The input form gets the filters the leading Booking scrapers offer, translated into elements of Booking.com's nflt string, the filters.selectedFilters variable already sent to FullSearch (src/filters.ts): propertyTypes (multi-select: hotels, apartments, hostels, guest houses, bed and breakfasts, villas, holiday homes, aparthotels, homestays) → ht_id=204|201|203|216|208|213|220|219|222 (the ids of the output's accommodation-type table); starRatings (list of 1 to 5) → class=N; minReviewScore (6, 7, 8 or 9) → review_score=60|70|80|90; priceMin / priceMax (per night, in the currency of the search, which is the URL's for a pasted URL) → price=<CUR>-<min|0>-<max|max>-1; freeCancellation → fc=2; breakfastIncluded → mealplan=1. Elements in a fixed order (types in list order, stars ascending, then score, price, cancellation, breakfast) so the same choices give the same string and the same monitoring record.
Combination: the fields complete the filters string (kept for experts) and the nflt of every pasted URL, without duplicates, the base first; several values of a group add up (Booking.com's OR); a URL keeps its own price range and minimum score when it has one, the field value left out being logged (keptFromSearch); the same price range or minimum score given twice in the form (fields and filters) with two values is refused at input time; a value outside a list is refused with the accepted values (propertyTypes, starRatings, minReviewScore, negative or non-integer prices, priceMax ≤ priceMin).
To confirm at the first run: only ht_id=204 is attested by the audit (probe run 12); every other element is listed in the start log line of the search (filterFields.notVerifiedAtBuildTime), in the input form (property types titled "— to confirm", descriptions) and in the README (Filters table, status column). The destination lookup by text still sends no filter.
No change without the fields: empty or off by default; the tasks, the requests, the rows, the pushes (charges) and the start log line are those of 0.2 (test on the fixtures with a fake HTTP client). Example tasks 4-star hotels in Paris sorted by price and Top-rated hotels in Amsterdam now use propertyTypes + starRatings / minReviewScore instead of the raw string and send exactly the same nflt (ht_id=204;class=4, ht_id=204;review_score=80). README: input table rows, section Filters with an example input and the string it sends, FAQ Which filters are supported? Can I use a Booking.com search URL? (replaces the search-URL question, FAQ kept at 10), mapping of the usual input names (minScore, stars, propertyType, minMaxPrice) to the fields, MCP prompt with propertyTypes. Store tests on the new form fields.
Unreleased (README visibility)
README first screen and links (brief docs/cloud-tasks/2026-10-20_studio-readme-visibility.md, 2026-10-20). The Store search indexes only the first ~3,000 README characters, so they now hold: an opening sentence with the phrase agents search and its synonyms, a "No login, no cookies, no personal data" line, the Free-plan price in figures in How much ($4.21 per 1,000 properties; Apify's $5 monthly credit covers about 1,187), computed from pricing/2026-10-19_pay-per-event.autopilot.json (Free tier of the primary event, one apify-actor-start event deducted), and a real output snippet (12 key columns of the README example row). Further down: Use it from your AI assistant (MCP URL https://mcp.apify.com?tools=myth84/<name> and a ready-to-copy prompt with a minimal input), Alternatives and switching (official API, home-made scraper, input-name mapping table, no competitor named), More scrapers by Flatrow Data Studio (Store links to the other Actors of the studio) with an invitation to rate the Actor (no counterpart), and the non-affiliation line at the very bottom. The cost section and the MCP cost bullet quote the same figures; the line 1 heading is unchanged. Checked by runStoreChecks (readmeVisibility): keyword in the opening prose, snippet equal to the example row and within 3,000 characters, figures equal to the pricing file in force (a new grid fails the test until the README follows), MCP block with a valid input, links to existing Actors of the same studio only, no fpr=. No code change: rows, logs and charges are identical.
Unreleased (version 0.2)
Fix: text destination not resolved now and then (brief docs/cloud-tasks/2026-10-20_booking-hotels-repair-location.md, 2026-10-20). Symptom: run A8gyISGBihAMkrDb4 (public task booking-nice-beach-hotels-for-two, "Nice, France", ht_id=204, build 0.1.1) and run zBszBJYcVhvBIWRF3 ("Rome, Italy", ht_id=204, 0.1.2) failed in 1 s with "Could not resolve the location", while the same input succeeded a few hours later: the lookup made one text search and one AutoComplete call on a single datacenter session, and an empty or failed answer failed a run whose input was good (1 public run in 30 days, failed). Fix: the one-row FullSearch that resolves the text no longer carries the filters (the search pages keep them); when AutoComplete has nothing for "City, Country" it is asked for "City", the suggestion naming the country preferred (also on the first answer); a lookup that resolves nothing runs again on a new session (new IP), 3 lookups in all with a 1 s then 2 s pause, then once on Apify residential when the run uses datacenter proxies (next tier of the cascade of packages/core/src/proxy.ts, created on first use, one to three light requests, skipped with a warning when the account has no access); the search pages go back to the run's tier. Every empty or failed lookup request is logged with its reason (status, block reason, body excerpt, GraphQL errors, results announced), every retry is logged (retrying destination lookup with a new session (2/3)) and counted in RUN_STATS.extra.destinationRetries. A destination Booking.com does not know still ends with the message of 0.1 after the retries; when the first lookup works, the requests, the rows and the charges are those of 0.1. Example tasks: Lisbon weekend hotels and Rome cheapest stays carry their destId; README FAQ The run says the location could not be resolved (questions on currencies and relative dates merged to stay at 10).
Search URLs (brief docs/cloud-tasks/2026-10-20_travel-extend-monitor.md, 2026-10-20): startUrls takes Booking.com search results URLs pasted by the customer; parseSearchUrl converts them to the validated FullSearch variables (ss / dest_id / dest_type, checkin / checkout or the older checkin_year-style parameters, group_adults, group_children, age, no_rooms, order, nflt, selected_currency, lang or the searchresults.<lang>.html path); the URL wins over the form, the form fills what the URL does not set (relative dates when it has none); a URL that is not a results page, names no destination or carries an inconsistent value is refused at input time with the reason; tracking parameters are ignored silently, other unknown ones are listed in the start log line. One task per URL, after the form search; the default destination (Lisbon, Portugal) applies only when no URL is given, a cleared destination still needs a destId or a URL. Task building moved to build-tasks.ts (tested).
Price monitoring (same brief, model: the AliExpress Actor): monitorPrices (off by default), monitorStoreName (default booking-price-monitor) and onlyChanges; previous total prices kept in the customer's named key-value store, one record per search (destination, stay, guests, rooms, sort, filters, currency, language; the stay is the fixed dates, or the relative definition check-in in N days, M nights so that a schedule compares a constant lead time); changeTypenew / price_changed / unchanged with previousPrice, priceDelta, priceDeltaPercent on every row; onlyChanges writes and charges only the new properties and the price changes; first run = reference (RUN_STATS.monitorBaseline), RUN_STATS.monitor summary and extra.monitor* counters; records written only after a run whose searches ended cleanly (fail-closed); a monitoring run without any change succeeds with an empty dataset; in monitoring mode maxResults is the number of properties compared. Same currency only.
Output schema travel-listing/1.2 (additive): changeType, previousPrice, priceDelta, priceDeltaPercent appended to every row (null outside monitoring runs); new dataset view Changes. Without the new options the rows and log lines are identical to 0.1 apart from the new columns.
README: sections Search URLs and Price monitoring, input table, FAQ entries; one more example task (booking-lisbon-price-monitor), 9 in total.
location is prefilled, no longer a schema default (review of PR #34): Apify adds schema defaults to every input, so a run made of startUrls alone also searched Lisbon and could spend maxResults on unrequested results. The code still searches Lisbon when no location, destId or URL is given ({} unchanged).
Unreleased
Store texts (brief docs/cloud-tasks/2026-10-20_studio-promote-store-texts.md, 2026-10-20): README opens with what you get / for whom / how much, shows the real input of the validation run, a pricing table that points to the Pricing tab (no figure in the text), a For AI agents (MCP) section with a minimal input, the key output fields, the cost per 1,000 results and the typical time; FAQ kept to 8–10 questions; no proxy or anti-bot detail left in the texts visible on the Store. Tests on the Store lengths (seoTitle ≤ 60, seoDescription ≤ 155, description ≤ 300), the README structure and the forbidden mentions. No code change.
0.1 — 2026-10-19
First version, written offline on the committed fixtures of the audit (docs/audits/booking.md, brief docs/cloud-tasks/2026-10-19_booking-build.md): Booking.com internal GraphQL search API (POST /dml/graphql, operation FullSearch, query text sent as is, no cookie, no token), 100 results per request, offset pagination until the announced total / maxResults / two pages without a new property, global deduplication by property id (sponsored cards), one sticky datacenter session per search with one rotation on an unusable response, pay-per-event result charging, RUN_STATS, explicit failure on empty output.
Output schema travel-listing/1.1 with ratingMax: 10; priceTotal is the total of the stay for the matched unit, taxesAndFees the taxes and charges Booking.com shows as excluded, pricePerNight the average per night; region is the neighbourhood of the card; propertyType comes from Booking's accommodation type id (table to confirm at the first run); no personal data (nothing about guests or reviewers is selected, privacy filter on every row).
Destination given as text (default "Lisbon, Portugal") or as destId / destType (safe path, validated). Text resolution in two steps, neither validated by the audit: FullSearch with the text alone, then the AutoComplete operation; an explicit error asks for destId when both fail.
Dates fixed (checkIn / checkOut) or relative (checkInDaysFromNow, nights) so that schedules and example tasks never expire; sort (price validated, the other orders marked unverified) and raw nflt filters (ht_id=204 validated).
Decisions: roomType is derived from the matched unit first ("Entire apartment" → entire_place, dorm beds → shared_room), then from the accommodation type (hotel-like → hotel_room); country is derived from the country code with Node's ICU data in the requested language; one destination per run (a control run per city).
Store readiness: README, input schema, dataset schema with Overview / Prices / Location views, output schema, 8 example tasks with relative dates, consistency tests (README ⇔ schemas ⇔ code, Ajv replay of fixture rows), crawler tests on a fake HTTP client.