Skip to main content
Thirdwatchthirdwatch
Reviews & ratings

Mine Booking.com Reviews for Recurring Service Complaints

Turn Booking.com negative review text into a ranked, dated list of recurring service failures by theme and room type, and see which ones your team really fixed.

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

A hotel's negative reviews are already a defect log; they are just unsorted. Thirdwatch's Booking.com Reviews Scraper returns negative_text as its own field, separate from positive_text, with the score, room type, stay dates and any public reply attached, so you can rank recurring service failures by theme, watch each theme's share by month, and see which fixes actually held.

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

Why mine Booking.com review text for service complaints

Every hotel has a mental model of its top three problems, and it is usually a year out of date and shaped by whoever complained loudest at the desk. The written record is better: on Booking.com a guest is prompted separately for what they liked and what they did not, which produces an unusually clean corpus of criticism. Most properties never read it in aggregate because doing so means paging through hundreds of cards by hand.

The job-to-be-done is a ranked, dated defect list. A general manager wants the top ten recurring themes for the last two quarters, with counts, so the morning meeting argues about priorities rather than about what the problems are. A quality lead wants to know whether the theme that triggered a capital request last year has actually shrunk as a share of complaints. A group operations team wants the same list across twenty properties so a systemic issue — a supplier, a standard, a training gap — separates itself from a local one. A guest-experience lead wants each theme's public reply rate, since an unanswered recurring complaint compounds.

The raw material is the same in each case: written negative reviews, dated, with room type and score attached, refreshed on a schedule.

How does this compare to the alternatives?

Three ways to get a complaint corpus:

Approach Cost model Reliability Setup time Maintenance
Reading reviews manually Analyst hours per property Sampling, and recency-biased Hours per quarter Repeats in full every quarter
Guest-feedback survey tool Per-property subscription Structured, but low response rate Weeks to roll out Ongoing survey ops
Thirdwatch Booking.com Reviews Scraper Pay per review row Full census of written public reviews Minutes Thirdwatch tracks Booking.com changes

Manual reading is how most properties do it and it produces anecdotes, not a trend line. A survey tool asks the questions you already thought to ask, from the guests who bother to answer. The Booking.com Reviews Scraper actor page hands you the whole written corpus with the criticism already isolated in its own field, which is what makes theme counting reliable rather than approximate.

How to mine recurring complaints in 5 steps

Step 1: How do I pull only the reviews that carry criticism?

Sort worst-first and drop score-only cards. onlyWithText continues pagination past the excluded rows rather than stopping.

{
  "queries": [
    "Atlantis The Palm Dubai",
    "Marina Bay Sands Singapore"
  ],
  "maxResults": 500,
  "sortBy": "scoreLowToHigh",
  "onlyWithText": true,
  "reviewLanguages": ["en"],
  "travelerType": "all"
}

Leave keyword empty for the discovery pass. You want to learn what the themes are before you filter to one of them.

Step 2: How do I rank the recurring themes?

Count theme hits over negative_text only. Keeping positives out of the count is the single biggest accuracy win available here.

import os
import re
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": ["Atlantis The Palm Dubai", "Marina Bay Sands Singapore"],
    "maxResults": 500,
    "sortBy": "scoreLowToHigh",
    "onlyWithText": True,
    "reviewLanguages": ["en"],
})
df = pd.DataFrame(client.dataset(run["defaultDatasetId"]).iterate_items())
df["negative_text"] = df["negative_text"].fillna("")
df["reviewed_at"] = pd.to_datetime(df["reviewed_at"], utc=True, errors="coerce")

THEMES = {
    "check_in_wait": r"check[- ]?in|queue|waiting|reception line",
    "housekeeping": r"clean|dirty|dust|stain|towel|linen",
    "noise": r"noise|noisy|loud|construction|thin wall",
    "climate_control": r"air ?con|a/?c|heating|humid|too (hot|cold)",
    "maintenance": r"broken|leak|out of order|not working|worn|dated",
    "food_quality": r"breakfast|buffet|restaurant|food|coffee",
    "wifi": r"wi[- ]?fi|internet|connection drop",
    "value": r"overpriced|expensive|not worth|value for money",
    "staff_attitude": r"rude|unhelpful|ignored|attitude",
}

for theme, pattern in THEMES.items():
    df[theme] = df.negative_text.str.contains(pattern, case=False, regex=True)

ranked = (
    df.melt(
        id_vars=["hotel_name", "review_id", "review_score", "reviewed_at"],
        value_vars=list(THEMES),
        var_name="theme",
        value_name="hit",
    )
    .query("hit")
    .groupby(["hotel_name", "theme"])
    .agg(mentions=("review_id", "nunique"), mean_score=("review_score", "mean"))
    .reset_index()
    .sort_values(["hotel_name", "mentions"], ascending=[True, False])
)
print(ranked)

Report mean_score next to mentions. A theme mentioned often inside otherwise decent reviews is an irritant; a theme that shows up almost exclusively on the lowest scores is a stay-ruiner, and the two deserve different budgets.

Step 3: How do I check whether a theme is getting better or worse?

Track each theme as a share of written negative reviews per month, not as a raw count.

monthly = (
    df.assign(month=df.reviewed_at.dt.to_period("M"))
    .groupby(["hotel_name", "month"])
    .agg(written=("review_id", "nunique"), **{t: (t, "sum") for t in THEMES})
    .reset_index()
)
for theme in THEMES:
    monthly[f"{theme}_share"] = monthly[theme] / monthly.written

print(monthly[["hotel_name", "month", "written",
               "check_in_wait_share", "housekeeping_share", "noise_share"]].tail(18))

Raw counts follow occupancy and review volume, so they rise in a good season and fall in a quiet one regardless of quality. The share is the honest metric. Allow one to two months after a genuine fix before expecting it to move, since reviews lag the stay.

Step 4: How do I confirm a theme and pull the evidence?

Once discovery names a theme, re-run with keyword to concentrate the feed on it, then read the actual sentences.

run = client.actor("thirdwatch/booking-reviews-scraper").call(run_input={
    "queries": ["Atlantis The Palm Dubai"],
    "maxResults": 300,
    "sortBy": "scoreLowToHigh",
    "onlyWithText": True,
    "keyword": "check-in",
})
focus = pd.DataFrame(client.dataset(run["defaultDatasetId"]).iterate_items())
focus = focus[focus.negative_text.fillna("").str.contains("check", case=False)]

by_room = (
    focus.groupby(focus.room_type.fillna("unspecified"))
    .agg(mentions=("review_id", "nunique"), mean_score=("review_score", "mean"))
    .sort_values("mentions", ascending=False)
)
print(by_room.head(10))
for _, row in focus.head(5).iterrows():
    print(f"[{row.review_score}] {row.reviewed_at} :: {row.negative_text}")

keyword is Booking.com's own relevance search rather than a literal substring filter, so the client-side confirmation on negative_text is not optional. Five verbatim quotes with dates and scores also travel far better into an operations meeting than any chart.

Step 5: How do I see which complaints went unanswered?

has_owner_response and owner_response are on every row, so reply coverage per theme is a group-by away.

coverage = (
    df.melt(
        id_vars=["hotel_name", "review_id", "has_owner_response"],
        value_vars=list(THEMES), var_name="theme", value_name="hit",
    )
    .query("hit")
    .groupby(["hotel_name", "theme"])
    .agg(mentions=("review_id", "nunique"), reply_rate=("has_owner_response", "mean"))
    .reset_index()
    .sort_values("reply_rate")
)
print(coverage.head(10))

A high-mention, low-reply theme is the worst combination on the page for a prospective guest: a repeated complaint with nobody from the hotel answering it. Schedule the discovery run monthly using the Apify schedules documentation so the share series keeps building.

Sample output

A written negative review with its full context:

{
  "review_id": "/reviews/ae/hotel/atlantis-the-palm.html?tab=1#9c3ad014",
  "review_url": "https://www.booking.com/reviews/ae/hotel/atlantis-the-palm.html",
  "hotel_id": 63219,
  "hotel_name": "Atlantis The Palm",
  "hotel_city": "Dubai",
  "hotel_country": "United Arab Emirates",
  "hotel_total_reviews": 9784,
  "hotel_matching_reviews": 6120,
  "review_title": "Great for the kids, painful check-in",
  "review_score": 6.0,
  "review_score_max": 10,
  "positive_text": "Aquaventure access and the aquarium were worth the trip on their own.",
  "negative_text": "Check-in took ninety minutes with two tired children and the room was not ready until late afternoon.",
  "review_text": "Aquaventure access and the aquarium were worth the trip on their own. | Check-in took ninety minutes with two tired children and the room was not ready until late afternoon.",
  "review_language": "en",
  "reviewed_at": "2026-08-11T00:00:00+00:00",
  "reviewer_name": "Sarah",
  "reviewer_country": "United Kingdom",
  "traveler_type": "Family with young children",
  "room_type": "Ocean Deluxe Room",
  "room_id": 6321903,
  "checkin_date": "2026-08-05",
  "checkout_date": "2026-08-11",
  "nights": 6,
  "helpful_votes": 7,
  "owner_response": "We are sorry your arrival was delayed. We have added arrival hosts during peak weeks.",
  "has_owner_response": true,
  "photo_count": 0
}

negative_text is the field the whole analysis runs on. review_text is the two halves joined, useful for full-text search but wrong for complaint counting. room_type and room_id let a theme be localised to a room category. reviewed_at is the publication timestamp and drives the monthly share series, while checkin_date and checkout_date describe the stay itself.

Common pitfalls

Four ways complaint mining goes wrong. Counting themes over blended text — running keyword matches over review_text picks up "no noise at all" as a noise complaint; count over negative_text only. Trusting raw counts — mention counts track occupancy and review volume, so always convert to a share of written negatives before declaring a trend. Treating keyword as a substring filter — it is Booking.com's relevance search, so confirm the term in the returned text before counting. Over-reading room-level attributionroom_type is absent on some rows, so report the coverage rate alongside any claim that a problem belongs to one room category.

Thirdwatch's Actor handles property resolution, pagination past filtered cards, deduplication and retries so you can focus on the themes rather than the fetch loop.

Related use cases

Frequently asked questions

Why is complaint mining easier on this data than on raw review text?

Because Booking.com splits a review into what the guest liked and what they did not, and the Actor preserves that split as positive_text and negative_text. Counting complaint themes over negative_text alone removes the biggest source of false positives in hotel review mining.

What does the keyword input actually do?

It applies Booking.com's own review-text relevance search, so it narrows the feed to reviews the platform considers related to that term. It is a relevance filter, not a guaranteed literal substring match, so always confirm the term in the returned text before counting.

How do I surface the worst complaints first?

Set sortBy to scoreLowToHigh and onlyWithText to true. You get the lowest-scoring written reviews first, which is the fastest path to the recurring failures, and pagination continues past score-only cards rather than stopping at them.

Can I tie a complaint back to a specific room?

Partly. Each row carries room_type and room_id when Booking.com exposes them, so a theme that concentrates in one room category is visible. Rows without a room type still count toward the property-level theme, so report coverage alongside any room-level claim.

How do I know whether a fix worked?

Track the theme's share of negative reviews by month, not its raw count. Raw counts move with occupancy and review volume; the share moves when the underlying experience changes. Give a genuine fix one to two months before you expect the share to fall.

Does the Actor return the hotel's reply to a complaint?

Yes. owner_response holds the public property reply and has_owner_response is a boolean, so you can measure what share of each complaint theme was answered publicly and read the replies alongside the complaints they address.

Related

Try it yourself

100 free credits, no credit card.

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