# Changelog of Bolt Food Restaurant & Menu Scraper (`teodor_banea/bolt-food-restaurant-menu-scraper`) Actor

- **URL**: https://apify.com/teodor\_banea/bolt-food-restaurant-menu-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/teodor\_banea/bolt-food-restaurant-menu-scraper.md

## Changelog

All notable changes to this Actor are documented here.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### \[0.1.0] - 2026-08-24

Initial release — the first Bolt Food scraper on the Apify Store.

#### Added

- **Five scrape modes**, with menus part of the mode rather than a separate toggle, so the dropdown
  states what a run produces. `searchMenus` (the default) is a full crawl: keyword search via
  `cityWideSearch`, then every matching venue's menu.
  - `searchMenus` — keyword search across a city, then the full menu of every match.
  - `discoveryMenus` — the whole venue feed for a location, then every menu.
  - `venueUrls` — venue details plus the full menu for specific venue URLs.
  - `search` — keyword search, venue rows only.
  - `discovery` — the whole venue feed, venue rows only.
    Internally a mode splits into a venue source and a menu decision, so no handler special-cases a
    mode name. The retired `includeMenus` toggle still turns menus on for the two venues-only modes,
    so inputs saved before the split keep working.
- **Menu rows carry their venue.** Beyond `venueId`/`venueSlug`/`venueName`/`url`, each menu item
  also carries `venueCity`, `venueCountry`, `venueAddress`, `venueRating` and the `searchQuery` that
  surfaced the venue — so a billed CSV export answers cross-venue price questions without a second
  lookup, and a multi-keyword run stays attributable.
- **Full menu extraction with prices.** One request returns a restaurant's entire menu tree;
  dishes are flattened into rows carrying price, currency, pre-promotion price, discount flag,
  Bolt's formatted price string, availability, popularity, selling unit, category path and the
  number of customization option groups and options.
- **Bundled city registry** of 260 Bolt Food cities across 16 countries (AZ, BG, CY, CZ, EE, GE,
  GH, KE, LT, LV, MT, PL, PT, RO, SK, UA), each verified against the live API. City names match
  ignoring case and accents; an unknown name fails the run immediately with "did you mean"
  suggestions instead of scraping the wrong place.
- **Address-level targeting.** `city` picks the market, the optional `address` narrows the delivery
  point inside it, and `latitude`/`longitude` target any exact point. Because Bolt Food's venue
  list depends on the delivery address, this changes which venues, fees and ETAs you get.
- **Search result enrichment.** `cityWideSearch` returns no address or coordinates, so search mode
  loads the city feed once per run and back-fills address, coordinates, exact rating, rating count
  and price tier onto every search hit — one extra request for the whole run.
- **Optional store-catalog expansion.** `expandCatalogs` walks into supermarket category pages
  (bounded at 60 follow-up requests per venue). When it is off, the run ends with a warning naming
  the venues whose catalogs were only partial, so partial data is never reported as complete.
- **One dataset.** Everything goes to the run's default dataset and every row there is a billed
  result — menu items in the menu modes, venue records in the venue-list modes, never a mix. There
  are no named datasets, for two reasons. Handing venue records out free alongside menus undercut
  the venue-list modes, which bill per venue: a run with `maxItemsTotal=1` bought one menu item and
  took a whole city's venue records with it. And a published Actor runs under Apify's
  LIMITED\_PERMISSIONS, where it may only touch its own default storages — creating a named dataset
  returns HTTP 403, which failed the request that was writing venue rows, meant no menus were ever
  queued, and ended the run with zero results. Failed requests are reported in the run log.
- **No default caps.** `maxVenues`, `maxItemsTotal` and `maxMenuItemsPerVenue` all default to `0`,
  meaning no limit: a run returns everything it finds rather than silently truncating. Set any of
  them to bound a run. Input validation still fails before the first request, so invalid input can
  never bill a dataset item.
- **Apify Proxy always on, datacenter only, nothing to configure.** The proxy input is gone.
  Residential is not selectable, and a residential group passed through the old input shape is
  dropped with a warning: it is billed by bandwidth, and on a pay-per-result Actor that bandwidth is
  paid by the Actor's owner rather than by whoever runs it. Nothing is lost by restricting it — Bolt
  serves datacenter IPs normally under load, and rate limiting is keyed on the client identity
  rather than the IP. Country pinning is not offered either: measured against a datacenter pool,
  only US resolved while RO, DE and NL failed every request outright, and none of Bolt Food's 16
  markets are covered — datacenter without a country reached all 16.
- **Every row carries `runId`**, so exports from several runs can be merged and split apart again —
  which is what tracking prices over time needs. Null on a local run.
- 154 fixture-driven unit tests covering envelope parsing, error classification, mappers, the menu
  flattener, catalog-preview detection, city resolution, URL parsing, client-identity rotation,
  request building and input validation.

#### Implementation notes

- `HttpCrawler` on `apify/actor-node:20` — no browser. A city venue list is one request, and a
  restaurant's whole menu is one more; a measured run took 61 requests and 5 seconds to pull 60
  venues with 3,107 priced menu items.
- Bolt Food's API needs no authentication, only client-fabricated device parameters — and that
  identity is what it rate-limits on, not the IP. Measured from a single IP with no proxy: reusing
  one `deviceId` had 12–50% of requests rejected with HTTP 429 at ~18 req/s, while a fresh
  `deviceId` per request completed 600 requests at 126 req/s with none rejected. Varying the
  provider id changed nothing; only the identity did. So the identity is stamped on in the
  crawler's `preNavigationHook`, which means every retry also gets a new one — retrying with the
  identity that was just limited is the one case guaranteed to fail again. Keeping it out of
  `request.url` also keeps queue keys and error logs readable.
- **Errors arrive inside HTTP 200 bodies.** Every response is a `{ code, message, data }` envelope,
  so HTTP status alone never indicates success. Responses are classified by envelope code:
  `TOO_MANY_REQUESTS` (1005) is retried, while `NOT_EXISTS` (400), `INVALID_REQUEST` (702),
  `CITY_NOT_FOUND` (5812) and `DELIVERY_NOT_AVAILABLE` (5816) are terminal and skip retries.
- Menu item `tags` carry Bolt's attribute keys (`spicy`, `vegan`, `vegetarian`, `alco`, ...). Bolt
  returns these as objects, not strings, so a string-only filter silently dropped every one; about
  5-9% of dishes carry at least one. Venue records genuinely have no tags — 0 of 2,539 providers
  sampled across six countries returned any — so venue rows stay empty by nature, not by omission.
- Text from Bolt is stripped of markup before output. Merchants paste HTML into Bolt's own fields and
  Bolt serves it back verbatim, so `<b>`, `<br>`, `<span>` and `&nbsp;` were reaching the dataset;
  tags now become spaces and entities are decoded.
- Prices are decimal numbers already in major currency units — verified across EUR, PLN, CZK, RON,
  UAH, KES, AZN and GEL — so no minor-unit conversion is applied.
- Result slots and the venue budget are claimed before awaiting a push, so concurrent handlers
  cannot together overshoot the user's result cap.
- Reaching the result cap aborts the crawl instead of merely skipping the remaining handlers.
  Queued requests were previously still fetched so their handler could drop the response: a capped
  Prague run made 258 requests of which 16 were productive, taking 161s. It now makes 25 requests in
  13s for the same 1000 rows — a 10x cut in requests, run time and compute units.
- HTTP 403 stays retryable (IP-level block, recoverable by session or proxy rotation) while
  400/401/404/410 are terminal.
- Caps are represented internally as `Infinity` when unset, so the cap comparisons in routes.ts
  work unchanged whether or not the user set one.
- Because the rate limit is per identity, no throttling is imposed anywhere: the same supermarket
  catalog run that lost 8 of 30 requests to HTTP 429 under a single shared identity completes all
  30 first try, 7x faster, with rotation.
- `BOLT_APP_VERSION` pins the web-app version the API gates on (`FW.1.116`, captured 2026-08-24).
  If every request starts failing at once, that constant is the first thing to bump.
