Skip to main content
Thirdwatchthirdwatch
Reviews & ratings

Analyze Agoda Guest Sentiment by Room and Traveller Type

Segment Agoda hotel reviews by room type, traveller group, reviewer country and length of stay to find which guests your property actually disappoints, and why.

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

A hotel's average score hides the segment that is actually unhappy. Thirdwatch's Agoda Reviews Scraper attaches reviewerRoomTypeName, reviewerGroupName, reviewerCountryName and reviewerLengthOfStay to every review, along with separate positive and negative text, so you can cut sentiment by room category and traveller segment and find the cell that is dragging the property down.

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

Why segment Agoda reviews by room and traveller type

An 8.6 is not an experience, it is an average of several different experiences. The couple in a refurbished deluxe king and the family of four in an unrenovated twin are rating two different hotels, and their scores get blended into one number that tells an operator nothing actionable. Most properties discover a room-category problem only when the aggregate has already slid, because Agoda surfaces the segmentation fields per review but never aggregates them for you.

The job-to-be-done is diagnostic. A general manager needs to know whether last quarter's dip came from one room category or from the property as a whole, because the answer decides whether the fix is capex or process. An asset manager evaluating a refurbishment wants a before-and-after cut restricted to the rooms that were actually touched. A revenue team wants to know which traveller groups score the property well, since those are the segments worth buying demand for. A brand team wants the country cut, because a mismatch between what a market expects and what the property delivers shows up as a persistent score gap for guests from that market.

Every one of those is a group-by over review rows that already carry the segmentation columns.

How does this compare to the alternatives?

Three ways to get segment-level guest sentiment:

Approach Cost model Reliability Setup time Maintenance
Post-stay survey platform Per-property subscription Low response rate, self-selected Weeks to roll out Ongoing survey ops
Manual review reading Analyst hours Sampling only, no census Hours per property Repeats every quarter
Thirdwatch Agoda Reviews Scraper Pay per review row Full census with segmentation attached Minutes Thirdwatch tracks Agoda changes

A survey platform gives you clean segmentation but only from the guests who answer, which is a self-selected minority. Manual reading gives you nuance and no statistics. The Agoda Reviews Scraper actor page gives you the full public review census with the room type, traveller group, country and stay length already on the row, which is what makes a proper cut possible.

How to segment guest sentiment in 4 steps

Step 1: How do I pull enough reviews for the segments to be readable?

Segment analysis needs volume. Raise maxReviewsPerHotel and keep both providers so no segment is thinned out by a channel filter.

{
  "hotelUrls": [
    "https://www.agoda.com/park-hyatt-tokyo/hotel/tokyo-jp.html",
    "https://www.agoda.com/the-ritz-carlton-hong-kong/hotel/hong-kong-hk.html"
  ],
  "maxReviewsPerHotel": 1000,
  "enabledProviders": ["Agoda", "Booking.com"],
  "language": "all",
  "sortBy": "recent",
  "cutoffDate": "2025-09-01",
  "aggregateRatings": true
}

A twelve-month cutoffDate is usually the right window: long enough to fill the cells, short enough that a refurbishment two years ago is not still averaged in.

Step 2: How do I build the room-type by traveller-group grid?

Group on the two dimensions and count. Normalise the room names first, because properties rename categories constantly.

import os
import re
import pandas as pd
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("thirdwatch/agoda-reviews-scraper").call(run_input={
    "hotelUrls": [
        "https://www.agoda.com/park-hyatt-tokyo/hotel/tokyo-jp.html",
        "https://www.agoda.com/the-ritz-carlton-hong-kong/hotel/hong-kong-hk.html",
    ],
    "maxReviewsPerHotel": 1000,
    "sortBy": "recent",
    "cutoffDate": "2025-09-01",
})
df = pd.DataFrame(client.dataset(run["defaultDatasetId"]).iterate_items())

FAMILIES = ["suite", "club", "executive", "deluxe", "premier", "superior", "standard"]

def room_family(name: str) -> str:
    text = (name or "").lower()
    for family in FAMILIES:
        if re.search(rf"\b{family}\b", text):
            return family
    return "other"

df["room_family"] = df["reviewerRoomTypeName"].map(room_family)

grid = (
    df.groupby(["hotelName", "room_family", "reviewerGroupName"])
    .agg(reviews=("hotelReviewId", "nunique"), mean_rating=("rating", "mean"))
    .reset_index()
)
grid = grid[grid.reviews >= 30]
print(grid.sort_values("mean_rating").head(20))

The thirty-review floor matters. Below it a single miserable stay moves a cell mean by half a point and you will chase a phantom.

Step 3: How do I find what each weak segment actually complains about?

reviewNegatives is a separate field, so complaint counting per segment is a straight term frequency over one column rather than a sentiment model over blended text.

from collections import Counter

TERMS = [
    "noise", "noisy", "air conditioning", "wifi", "breakfast", "check-in",
    "queue", "small", "bathroom", "cleanliness", "smell", "staff", "lift",
]

def complaint_profile(frame: pd.DataFrame) -> dict:
    blob = " ".join(frame["reviewNegatives"].fillna("").str.lower())
    counts = Counter({t: blob.count(t) for t in TERMS})
    return dict(counts.most_common(5))

weak = grid.nsmallest(5, "mean_rating")
for _, cell in weak.iterrows():
    slice_ = df[
        (df.hotelName == cell.hotelName)
        & (df.room_family == cell.room_family)
        & (df.reviewerGroupName == cell.reviewerGroupName)
    ]
    print(cell.hotelName, cell.room_family, cell.reviewerGroupName,
          round(cell.mean_rating, 2), complaint_profile(slice_))

The output is the sentence an operations meeting can act on: "families in standard rooms, mean 7.1, top negatives noise and bathroom" is a maintenance ticket, not a mood.

Step 4: How do I add the country and stay-length cuts?

Both fields are already on the row. Country exposes expectation mismatches; stay length separates a bad first impression from a stay that wore thin.

country = (
    df.groupby(["hotelName", "reviewerCountryName"])
    .agg(reviews=("hotelReviewId", "nunique"), mean_rating=("rating", "mean"))
    .reset_index()
    .query("reviews >= 30")
)
country["gap"] = country.mean_rating - country.groupby("hotelName").mean_rating.transform("mean")

df["stay_band"] = pd.cut(
    df["reviewerLengthOfStay"], [0, 1, 3, 7, 999],
    labels=["1 night", "2-3 nights", "4-7 nights", "8+ nights"],
)
stay = df.groupby(["hotelName", "stay_band"], observed=True).rating.mean()
print(country.sort_values("gap").head(10))
print(stay)

A score that falls as stay length rises points at consistency — housekeeping cadence, maintenance, food variety. A score that is lowest on one-night stays points at arrival and departure friction. Schedule the pull monthly using the Apify schedules documentation so each quarter's cut is comparable to the last.

Sample output

Every review arrives with its segmentation columns already attached:

{
  "hotelId": 910,
  "hotelName": "Park Hyatt Tokyo",
  "hotelReviewId": 1041228390,
  "reviewProviderText": "Booking.com",
  "rating": 7.5,
  "ratingText": "Very good",
  "ratingCategory": "very_good",
  "reviewTitle": "Lovely hotel, small room for four",
  "reviewComments": "Service was outstanding but the twin room was tight for a family.",
  "reviewPositives": "Service, views, bar",
  "reviewNegatives": "Room size, only one bathroom, slow lift at peak times",
  "reviewDate": "2026-06-02T14:22:00+09:00",
  "checkInDate": "2026-05-29",
  "checkOutDate": "2026-06-02",
  "reviewerName": "Marie",
  "reviewerCountryName": "France",
  "reviewerCountryCode": "fr",
  "reviewerGroupName": "Family with young children",
  "reviewerRoomTypeName": "Park Twin Room",
  "reviewerLengthOfStay": 4,
  "reviewerReviewedCount": 12,
  "isExpertReviewer": false,
  "sourceLanguage": "en",
  "combinedAggregateReviewScore": 9.1
}

reviewerRoomTypeName and reviewerGroupName are the two axes of the grid. reviewerCountryName and reviewerLengthOfStay are the two extra cuts worth running. reviewerReviewedCount and isExpertReviewer let you down-weight or exclude prolific reviewers whose scoring habits differ from an ordinary guest's. sourceLanguage keeps multilingual analysis honest.

Common pitfalls

Four failure modes in segment analysis. Thin cells — a room-type by traveller-group grid fragments fast, so enforce a minimum cell size and roll sparse cells up into a family before reporting anything. Unnormalised room names — properties rename categories and add merchandising words, so raw reviewerRoomTypeName values scatter the same room across five labels; normalise into families and keep the raw string. Language confounding — non-English reviews are not distributed evenly across segments, so a country gap can partly be a language-population gap; check it with sourceLanguage. Ignoring the provider split — the Agoda and Booking.com review populations shown on a property page differ, so verify that a segment difference holds inside each provider before you brief it.

Thirdwatch's Actor handles pagination, deduplication and the per-review field flattening so you can focus on the segmentation rather than the parsing.

Related use cases

Frequently asked questions

Which segmentation fields come with every review?

Four: reviewerRoomTypeName, reviewerGroupName, reviewerCountryName and reviewerLengthOfStay. Each row also carries reviewerCountryCode, reviewerReviewedCount and isExpertReviewer, so you can weight prolific reviewers differently from one-time contributors if your analysis calls for it.

How many reviews do I need for a segment to be readable?

Treat thirty reviews as a soft floor for a room-type or traveller-group cell and one hundred before you act on a difference of a few tenths of a point. Below thirty, one bad stay swings the mean far more than any real quality difference between segments.

Do room type names stay stable over time?

No. Properties rename and re-merchandise room categories, so a raw group-by on reviewerRoomTypeName will fragment across a long history. Normalise names into families such as suite, club, deluxe and standard before aggregating, and keep the raw string alongside.

Can I analyse reviews written in other languages?

Yes. Set language to all to keep every review and use the sourceLanguage field to segment, or pass an ISO 639-1 code such as en or de to restrict the run to one language. Review text is returned in its original language, not translated.

How do positives and negatives arrive?

As two separate fields, reviewPositives and reviewNegatives, in addition to the blended reviewComments. That separation is what makes segment-level complaint counting reliable, because you never have to guess which half of a sentence was the criticism.

Should I mix Agoda and Booking.com reviews in one segment analysis?

Only if you control for it. The two review populations differ in traveller origin and scoring behaviour, so keep reviewProviderText as a dimension and check that a segment difference survives within each provider before you brief it to anyone.

Related

Try it yourself

100 free credits, no credit card.

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