Skip to main content
Thirdwatchthirdwatch
Reviews & ratings

Benchmark Booking.com Guest Sentiment Against Rival Hotels

Build a competitive set from Booking.com guest reviews and compare scores, sub-scores and traveller mix against the hotels you actually lose bookings to.

Sep 8, 2026 · 6 min read · 1,367 words
See the scraper →

Your score only means something next to the hotels a traveller sees beside you in the same search. Thirdwatch's Booking.com Reviews Scraper returns guest reviews for any list of properties with the hotel's id, city, coordinates, total and matching review counts and seven category sub-scores attached to every row, which is exactly what a competitive-set benchmark needs.

Skip the setup: Run this as a ready-to-go task on Apify — pre-loaded with the configuration from this guide.

Why benchmark guest sentiment against a competitive set

An 8.4 is good until the four hotels within a ten-minute walk are all at 8.9. Guests do not read scores in isolation; they read them in a sorted list on Booking.com, directly beside the alternatives, and a two-tenths gap in that context is a real conversion problem. Yet most properties benchmark on rate and occupancy and treat reputation as a single internal number.

The job-to-be-done is a comparison table with a diagnosis under it. A revenue manager wants to know where the property sits in its set and whether the gap is widening. A general manager wants the sub-score that is causing it, because "we are behind on cleanliness" and "we are behind on value for money" call for completely different responses. An asset manager reviewing a capital request wants evidence that the competitive set outscores the property specifically on comfort and facilities, not just overall. A brand team wants to know whether the gap is uniform or concentrated in one traveller segment.

All four need the same run: a set of properties, their reviews, their sub-scores and their traveller mix, refreshed on a schedule so the gap can be tracked rather than sampled once.

How does this compare to the alternatives?

Three ways to get competitive-set reputation data:

Approach Cost model Reliability Setup time Maintenance
Manual comparison in the browser Analyst hours per refresh Headline score only, no history Hours Repeats in full every time
Rate-shopping or RMS add-on Per-property subscription Strong on rate, thin on review text Days, with onboarding Vendor contract
Thirdwatch Booking.com Reviews Scraper Pay per review row Full review rows plus sub-scores Minutes Thirdwatch tracks Booking.com changes

Reading four competitor pages by hand gives you four headline numbers and no trend. A rate-shopping tool is built for pricing and usually surfaces only the aggregate review score. The Booking.com Reviews Scraper actor page returns the underlying reviews with the seven sub-scores and the traveller type on every row, which is what lets you say why the gap exists rather than only that it does.

How to benchmark a competitive set in 4 steps

Step 1: How do I define and pull the set?

Put your property first and its rivals after it in queries. Names, full property URLs, slugs and numeric hotel IDs all work in the same list.

{
  "queries": [
    "https://www.booking.com/hotel/gb/the-savoy-london.html",
    "The Ritz London",
    "Claridge's London",
    "The Langham London",
    "Shangri-La The Shard London"
  ],
  "maxResults": 300,
  "sortBy": "newest",
  "reviewLanguages": ["en"],
  "onlyWithText": false
}

Use a full property URL or a numeric ID for anything whose name is ambiguous. A plain name is resolved through Booking.com's own lookup and returns the first match, which is fine for a distinctive property and unreliable for a name shared across several cities.

Step 2: How do I build the headline comparison?

The property-level fields repeat on every review row, so one first aggregation per hotel gives you the comparison table.

import os
import pandas as pd
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("thirdwatch/booking-reviews-scraper").call(run_input={
    "queries": [
        "https://www.booking.com/hotel/gb/the-savoy-london.html",
        "The Ritz London",
        "Claridge's London",
        "The Langham London",
        "Shangri-La The Shard London",
    ],
    "maxResults": 300,
    "sortBy": "newest",
    "reviewLanguages": ["en"],
})
df = pd.DataFrame(client.dataset(run["defaultDatasetId"]).iterate_items())

SUBSCORES = [
    "hotel_score_staff", "hotel_score_facilities", "hotel_score_cleanliness",
    "hotel_score_comfort", "hotel_score_value_for_money",
    "hotel_score_location", "hotel_score_free_wifi",
]

comp = (
    df.groupby(["hotel_id", "hotel_name", "hotel_city"])
    .agg(
        sampled_reviews=("review_id", "nunique"),
        recent_mean=("review_score", "mean"),
        lifetime_reviews=("hotel_total_reviews", "first"),
        **{s: (s, "first") for s in SUBSCORES},
    )
    .reset_index()
    .sort_values("recent_mean", ascending=False)
)
print(comp)

Two different numbers matter here and they are easy to confuse. recent_mean is the mean of the reviews you actually pulled, which is a recency-weighted signal. The sub-scores are property-level values covering the full published history. A property whose recent_mean sits well below its own sub-scores is deteriorating; the reverse is recovering.

Step 3: How do I find which sub-score explains the gap?

Compare each property against the median of the rest of the set, sub-score by sub-score.

gaps = comp.set_index("hotel_name")[SUBSCORES]
set_median = gaps.median()
you = "The Savoy"

delta = (gaps.loc[you] - set_median).sort_values()
print(f"{you} vs competitive-set median")
for metric, value in delta.items():
    label = metric.replace("hotel_score_", "").replace("_", " ")
    print(f"  {label:18} {value:+.2f}")

Read the two ends. The most negative sub-score is the operational priority; the most positive is the one your listing copy and photography should be leading with. In practice hotel_score_value_for_money behaves differently from the rest — it is a judgement about rate relative to delivery, so a deficit there is often a pricing conversation rather than a service one.

Step 4: How do I track the gap over time and by segment?

Snapshot each run and diff. Then repeat the run with a travelerType filter to see whether the gap is concentrated.

import datetime, pathlib

stamp = datetime.date.today().isoformat()
path = pathlib.Path(f"snapshots/comp-set-{stamp}.json")
path.parent.mkdir(exist_ok=True)
comp.to_json(path, orient="records")

snaps = sorted(pathlib.Path("snapshots").glob("comp-set-*.json"))
if len(snaps) >= 2:
    prev = pd.read_json(snaps[-2])
    merged = comp.merge(prev, on="hotel_id", suffixes=("", "_prev"))
    merged["mean_delta"] = merged.recent_mean - merged.recent_mean_prev
    print(merged[["hotel_name", "recent_mean", "mean_delta"]])

For the segment cut, re-run with "travelerType": "businessTravellers" and then "couples". A ranking built on the blended score frequently reverses inside a single segment, and that reversal is usually the most useful thing the whole exercise produces. Put the run on a monthly schedule using the Apify schedules documentation and read the deltas rather than the levels.

Sample output

Each row is one review with the full property context attached:

{
  "review_id": "/reviews/gb/hotel/the-savoy-london.html?tab=1#41f2c8b7",
  "review_url": "https://www.booking.com/reviews/gb/hotel/the-savoy-london.html",
  "hotel_id": 32601,
  "hotel_name": "The Savoy",
  "hotel_url": "https://www.booking.com/hotel/gb/the-savoy-london.html",
  "hotel_city": "London",
  "hotel_region": "Greater London",
  "hotel_country": "United Kingdom",
  "hotel_country_code": "gb",
  "hotel_latitude": 51.5101,
  "hotel_longitude": -0.1206,
  "hotel_total_reviews": 1842,
  "hotel_matching_reviews": 1611,
  "review_title": "Faultless service, dated bathroom",
  "review_score": 8.0,
  "review_score_max": 10,
  "positive_text": "Doormen and concierge were exceptional. River view worth every penny.",
  "negative_text": "Bathroom fittings feel dated for the price point.",
  "review_language": "en",
  "reviewed_at": "2026-08-19T00:00:00+00:00",
  "reviewer_name": "Daniel",
  "reviewer_country": "Ireland",
  "traveler_type": "Couple",
  "room_type": "Deluxe King Room with River View",
  "nights": 2,
  "has_owner_response": true,
  "hotel_score_staff": 9.5,
  "hotel_score_facilities": 9.0,
  "hotel_score_cleanliness": 9.3,
  "hotel_score_comfort": 9.2,
  "hotel_score_value_for_money": 8.1,
  "hotel_score_location": 9.6,
  "hotel_score_free_wifi": 9.1
}

hotel_total_reviews is the property's lifetime count and hotel_matching_reviews is how many survived the filters you applied, so the ratio tells you how narrow your slice was. hotel_latitude and hotel_longitude let you widen a competitive set by radius instead of by memory. The seven hotel_score_* fields are the diagnostic layer. review_score is on Booking.com's ten-point scale.

Common pitfalls

Four things distort competitive benchmarks. Comparing recency-weighted means to lifetime sub-scores — they answer different questions, so label them clearly and never put them in the same column. Ignoring review volume — a property with a few hundred lifetime reviews moves its score far more easily than one with several thousand, so normalise on hotel_total_reviews before calling a gap meaningful. Language skew — restricting reviewLanguages to English silently changes the traveller mix per property, and it changes it unevenly across the set; either apply it to everyone or to no one. Ambiguous name resolution — a plain hotel name returns the first match Booking.com offers, so verify hotel_id, hotel_city and hotel_url on the first row of each property before you trust the table.

Thirdwatch's Actor handles property resolution, pagination, deduplication and retries so you can focus on the comparison rather than the collection.

Related use cases

Frequently asked questions

What identifies a hotel in the output?

Every row carries hotel_id, hotel_name, hotel_url, hotel_city, hotel_region, hotel_country, hotel_country_code, hotel_latitude and hotel_longitude, plus hotel_total_reviews and hotel_matching_reviews. That is enough to build a geographic competitive set and to normalise for review volume.

Which sub-scores does the Actor return?

Seven per property: hotel_score_staff, hotel_score_facilities, hotel_score_cleanliness, hotel_score_comfort, hotel_score_value_for_money, hotel_score_location and hotel_score_free_wifi. They are the diagnostic layer under the headline score and where competitive gaps usually show up first.

How do I define a competitive set?

Start with the properties your revenue team already treats as rivals, then widen using hotel_latitude and hotel_longitude to catch anything within a short radius at a similar review volume. Five to ten properties is the practical range; beyond that the comparison stops being read.

Can I compare a single traveller segment across the set?

Yes. Set travelerType to families, couples, groupOfFriends, soloTravellers or businessTravellers and every property in the run is filtered the same way. Comparing business travellers alone often reverses a ranking built on the blended score.

How do I identify a hotel unambiguously?

Pass a full Booking.com property URL or a numeric hotel ID in queries. A plain name is resolved through Booking.com's own lookup and returns the first match, which is fine for a distinctive property and risky for a name shared across several cities.

Should I filter out score-only reviews?

For a headline-score benchmark, no, because score-only ratings are part of the population that produces the published number. For a text-driven comparison, set onlyWithText to true so every row you analyse actually has something written in it.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.