Irmu
Guides16 minIrmu Engineering

How to Scrape Hotel Prices and Build a Hotel Database (2026)

Build a hotel database and track nightly rates over time: search a destination, read each property, store rate snapshots and monitor changes — with working Python, Node, curl and SQL.

What people actually mean by hotel data

"Scrape hotel prices" usually covers two different jobs. The first is building a hotel database: the list of properties in a market, with name, address, star rating, review score and facilities. That list changes slowly. The second is rate collection: what one room costs for one stay, for a given number of guests, seen from a given country. That number changes many times a day.

Keep the two apart from the start. If you store a price on the hotel row, you lose its history the next time you collect it, and you can't say which dates or guest count it was for. Hotels are entities. Prices are observations of those entities, and each one is tied to a search context.

  • Revenue management: compare your own rates with the competitive set for the same dates.
  • Rate parity: check that the rate an OTA shows matches the rate you agreed.
  • Market research: count how many properties a city has, at what price levels and review scores.
  • Travel products: fill a comparison or deals page with current availability.

Why hotel prices are hard to scrape

A hotel price has no meaning without its context. The same room shows a different number depending on check-in date, number of nights, adults, rooms, currency, the country the visitor appears to be in, whether they are logged in, and sometimes the device. Without dates, many OTAs show no price at all.

Then there is the delivery. Results pages are rendered by JavaScript, cards load lazily, and the large booking sites run serious bot protection. A plain HTTP request from a datacenter IP often gets a challenge page instead of results. And those pages count: a challenge page is a real response from the target.

Rule of thumb: every price you store should carry the full search context it was collected under. If you can't recreate the query, you can't compare the number.

Step 1 — Build the hotel list with a destination search

Irmu has a dedicated Booking.com search endpoint. You give it a destination the way you'd type it into Booking's search box, and optionally a stay. It returns one row per property card on the first page of results. It costs a flat 30 credits. `premium`, `country` and `city` change where the request comes from, not what it costs.

Every field is text, exactly as Booking prints it (`"€ 212"`, `"9.1"`). A field the card doesn't show is left out of that row. Results always come from the English site.

search.sh
curl -G "https://app.irmu.com/api/booking/search" \
  -H "Authorization: Bearer $IRMU_API_KEY" \
  --data-urlencode "destination=Rome" \
  -d checkin=2026-11-11 -d checkout=2026-11-12 \
  -d adults=2 -d rooms=1 -d currency=EUR -d country=it
response.json
{
  "destination": "Rome",
  "checkin": "2026-11-11",
  "checkout": "2026-11-12",
  "adults": 2,
  "rooms": 1,
  "hotels": [
    {
      "name": "Hotel Artemide",
      "url": "https://www.booking.com/hotel/it/artemide.en-gb.html",
      "price": "€ 212",
      "priceInfo": "1 night, 2 adults",
      "taxes": "+€ 12 taxes and charges",
      "address": "Termini, Rome",
      "distance": "1.2 km from centre",
      "reviewScore": "9.1",
      "reviewWord": "Wonderful",
      "reviewCount": "3,214 reviews",
      "stars": "4 out of 5 Property rating"
    }
  ],
  "credits_charged": 30,
  "status": "done"
}

Covering a whole market

One search returns the first page of results, so a big city needs more than one query. Split the market into smaller searches: neighbourhoods, districts, landmarks and nearby towns. Then deduplicate on the property URL. The property URL is the most reliable key you have. Strip the query string and the language suffix before you compare URLs.

If a search comes back as `202`, the crawl is paid for and still running. Collect it from `GET /crawl/{job}` for free. That endpoint returns the crawl envelope, so the rows are under `data` there instead of `hotels`.

build_hotel_list.py
import os, re, time, requests

API = "https://app.irmu.com/api"
HEADERS = {"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"}

def canonical(url: str) -> str:
    url = url.split("?")[0]
    return re.sub(r"\.[a-z]{2}(-[a-z]{2})?\.html$", ".html", url)

def search(destination: str, **stay) -> list[dict]:
    r = requests.get(f"{API}/booking/search", headers=HEADERS,
                     params={"destination": destination, **stay}, timeout=180)
    body = r.json()
    if r.status_code == 202:                       # still running: collect, free
        job = body["job_id"]
        for _ in range(60):
            time.sleep(3)
            body = requests.get(f"{API}/crawl/{job}", headers=HEADERS, timeout=60).json()
            if body.get("status") == "done":
                return body.get("data") or []
        return []
    r.raise_for_status()
    return body.get("hotels") or []

areas = ["Rome Termini", "Trastevere, Rome", "Prati, Rome", "Monti, Rome", "Testaccio, Rome"]
hotels: dict[str, dict] = {}
for area in areas:
    for row in search(area, checkin="2026-11-11", checkout="2026-11-12",
                      currency="EUR", country="it"):
        hotels.setdefault(canonical(row["url"]), row)

print(len(hotels), "unique properties")

Step 2 — Get property details and room rates

Search cards are a summary. For each property, the hotel endpoint reads the property page itself. It returns a structured address, a numeric rating, a price with `amount`, `currency`, `original` and `discount`, a room-by-room list of prices, facilities, images, nearby points of interest, review subscores and a sample of reviews. This also costs a flat 30 credits.

One detail matters for your budget: when the crawler can't read a property page, the credits are refunded. The reply has `hotel: null`, `credits_charged: 0` and an `error` explaining why. Treat that as a retry later, not as an empty hotel.

hotel.mjs
const API = "https://app.irmu.com/api";
const headers = { Authorization: `Bearer ${process.env.IRMU_API_KEY}` };

export async function getHotel(url, { checkin, checkout, adults = 2, currency = "EUR", country }) {
  const qs = new URLSearchParams({ url, checkin, checkout, adults: String(adults), currency });
  if (country) qs.set("country", country);
  const res = await fetch(`${API}/booking/hotel?${qs}`, { headers });
  const body = await res.json();
  if (body.hotel == null) {
    // unreadable page: refunded (credits_charged: 0) — queue for a later retry
    return { ok: false, error: body.error, charged: body.credits_charged };
  }
  return { ok: true, hotel: body.hotel, charged: body.credits_charged };
}
hotel.json
{
  "hotel": {
    "name": "Relais La Maison de Luxe B&B",
    "address": { "streetAddress": "Via Nazionale 10", "addressLocality": "Rome", "addressCountry": "IT" },
    "rating": { "score": 8.7, "word": "Fabulous", "count": 426 },
    "price": { "amount": 112, "currency": "EUR", "original": 140, "discount": 28 },
    "rooms": [{ "name": "Double Room", "prices": [{ "amount": 112, "currency": "EUR", "original": 140, "discount": 28 }] }],
    "facilities": [{ "items": ["Free WiFi"] }],
    "pointsOfInterest": ["Termini 600 m"]
  },
  "credits_charged": 30,
  "status": "done"
}

Sites without a dedicated endpoint

For hotel chains' own sites, smaller OTAs or regional portals, use the general crawl endpoint. Start by reading structured data: many hotel pages publish a `Hotel` or `LodgingBusiness` JSON-LD block with name, address and rating. That block is far more stable than the visible markup. For prices, which are rarely in JSON-LD, use `ai_query` to describe the fields you want.

Set `country` to the market you're pricing for, and use `premium=true` if the standard pool gets turned away. Keep in mind that on `/crawl`, unlike the Booking endpoints, premium and city targeting change the price.

bash
curl -G "https://app.irmu.com/api/crawl" \
  -H "Authorization: Bearer $IRMU_API_KEY" \
  --data-urlencode "url=https://www.example-hotel.com/rooms?arrive=2026-11-11&depart=2026-11-12&adults=2" \
  -d js=true -d premium=true -d country=it -d wait_until=domcontentloaded \
  --data-urlencode "ai_query=List each room type with name, nightly price, currency, cancellation policy and whether breakfast is included"

Step 3 — Design the hotel database

Use two tables. `hotels` holds one row per property, keyed on the canonical URL. `rate_snapshots` holds one row per observation, with the full search context. Store the price as a number together with its currency. Keep the original text too, because you will want it the day a parser changes.

schema.sql
create table hotels (
  id            bigserial primary key,
  source        text not null default 'booking',
  url           text not null unique,          -- canonical, no query string
  name          text not null,
  street        text,
  locality      text,
  country       char(2),
  review_score  numeric(3,1),
  review_count  integer,
  first_seen    timestamptz not null default now(),
  last_seen     timestamptz not null default now()
);

create table rate_snapshots (
  id            bigserial primary key,
  hotel_id      bigint not null references hotels(id),
  checkin       date not null,
  nights        smallint not null,
  adults        smallint not null,
  rooms         smallint not null,
  point_of_sale char(2),                        -- the country parameter used
  currency      char(3) not null,
  room_name     text,
  amount        numeric(10,2) not null,
  original      numeric(10,2),
  raw_price     text,                           -- exactly as the site printed it
  captured_at   timestamptz not null default now()
);

create index on rate_snapshots (hotel_id, checkin, captured_at desc);

Step 4 — Monitor prices on a schedule

A daily job is enough for most rate-shopping. Pick a grid of stays (for example, the next 30 check-in dates, one night, two adults), collect each tracked property once per stay, and insert a snapshot. Price changes then come from a query, not from a second scrape.

Do the budget maths before you start. 50 properties × 30 check-in dates = 1,500 hotel calls a day at 30 credits each, which is 45,000 credits a day. Most teams start with fewer dates for far-off stays and more for the next two weeks, when prices move the most.

changes.sql
-- rate changes since the previous capture for the same stay
select h.name, s.checkin, s.amount, s.currency,
       s.amount - lag(s.amount) over w as change
from rate_snapshots s
join hotels h on h.id = s.hotel_id
window w as (partition by s.hotel_id, s.checkin, s.nights, s.adults, s.room_name
             order by s.captured_at)
order by s.captured_at desc
limit 100;

What you are billed for

A response the target served is a billed request, even if it was a 404, a 500 or an anti-bot page. A search that finds nothing is billed too, because an empty `hotels` list with `status: done` is Booking's real answer. You are not billed when Irmu couldn't serve the request for internal reasons, for domains Irmu doesn't cover, or when the hotel endpoint couldn't read a property page (that one is refunded).

  • Empty results: billed. Check your destination spelling and dates before you retry.
  • `hotel: null` with `credits_charged: 0`: refunded. Retry later.
  • `202`: already paid for. Collect it for free from `/crawl/{job}` and don't start a new request.

Legal and ethical limits

Prices and property facts shown publicly are the usual target of rate-shopping. Even so, read each site's terms, keep your request rate reasonable, and don't republish reviews or photos as your own content. Reviews can contain personal data. If you store them, have a reason to, keep only what you need, and follow GDPR or your local equivalent. This is not legal advice. Check with your counsel before you build a product on someone else's data.

Start small

Run one destination search and one hotel call for a single stay. Check the fields against the live page, then load them into the two tables above. Once the numbers match what a traveller sees, add more stays to the grid. The free plan includes enough monthly credits to test this end to end.

Start building with Irmu today

200 free credits every month, no card required. Every API, every integration, one key.