# Changelog of Booking.com Hotels Scraper & Price Tracker (`myth84/booking-hotels-scraper`) Actor

- **URL**: https://apify.com/myth84/booking-hotels-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/myth84/booking-hotels-scraper.md

### Changelog

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); `changeType` `new` / `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.
