DEV Community

Cover image for Trip.com (Ctrip) hotel reviews: the guest corpus Western OTAs don't carry (Python + n8n)
Tedj MEABIOU
Tedj MEABIOU

Posted on

Trip.com (Ctrip) hotel reviews: the guest corpus Western OTAs don't carry (Python + n8n)

A reputation feed built from Booking, TripAdvisor and Google is missing a whole guest segment. Chinese-speaking travellers review on Ctrip, and Ctrip's international brand Trip.com serves those reviews alongside its own and the Expedia-group ones it aggregates, so trip.com reviews and Ctrip reviews are the same rows. One Lisbon boutique hotel returned nine languages in a single run, about a quarter Chinese — chinese traveller reviews that exist on no Western OTA. If your guest feedback data stops at English-language sites, this is how to read the rest as rows: a 0–10 score, four sub-scores, the language, the travel type and the hotel's reply.

1. One request, one row per review

The Trip.com Reviews Scraper on Apify is a hotel reviews scraper that takes Trip.com hotel URLs or numeric hotel ids (the number after hotel-detail- in any URL). No login, no API key, no browser: it reads the JSON endpoint Trip.com's own review panel calls.

curl -X POST "https://api.apify.com/v2/acts/kestrel~trip-com-reviews-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hotelIds": ["2561632"], "maxReviewsPerHotel": 100, "reviewsSort": "most_recent", "languages": ["Chinese"]}'
Enter fullscreen mode Exit fullscreen mode

A review row:

{ "type": "review", "review_id": "2067108455", "hotel_id": "2561632", "hotel_name": "Hotel Valverde Lisboa - Relais & Chateaux",
  "rating": 9.7, "rating_5": 4.85, "rating_text": "Amazing",
  "rating_location": 10, "rating_facility": 9.5, "rating_service": 10, "rating_room": 9.5,
  "text": "非常精緻的小型酒店,新裝修的設施很新,床品舒適…", "language": "zh", "recommend": true,
  "review_date": "2026-08-17T06:13:30", "check_in": "2026-08-01", "room_type": "Classic Room", "travel_type": "Couple",
  "reviewer_name": "Xiaoyufeifeijiejie", "reviewer_country": "China", "reviewer_country_code": "CN",
  "reviewer_reviews": 10, "reviewer_level": "Review Specialist", "helpful_votes": 0,
  "images": ["https://ak-d.tripcdn.com/images/…_W_1280_853_R5_Q70.jpg"],
  "source": "ctrip", "response": "Dear Guest, Thank you very much for your wonderful review…", "response_date": "2026-08-19",
  "url": "https://www.trip.com/hotels/lisbon-hotel-detail-2561632/valverde-hotel/", "locale": "en-US", "fetched_at": "2026-08-29T04:30:12+00:00" }
Enter fullscreen mode Exit fullscreen mode

rating is Trip.com's 0–10 scale and rating_5 the same score out of five. The four sub-scores — rating_location, rating_facility (amenities), rating_service, rating_room (cleanliness) — come with every review, so "which room type is losing on cleanliness with families" is a group-by on room_type and travel_type, not a text model. source says where the review was written: ctrip, Trip.com, or an Expedia or Hotels.com verified review. language is the text's language code; the actor does not translate.

2. Three filters, all before billing

languages is applied by Trip.com's server, so the reviews you skip are never fetched and never billed. maxRating keeps only reviews at or below a score (Trip.com's own labels put 6 and below at a poor stay), and requireText drops the rare score-only review. A complaints feed:

{ "startUrls": ["https://www.trip.com/hotels/lisbon-hotel-detail-2561632/valverde-hotel/"], "maxReviewsPerHotel": 0, "reviewsSort": "lowest_rated", "maxRating": 6, "requireText": true }
Enter fullscreen mode Exit fullscreen mode

The free hotel row (includeHotelRow, on by default) lists the languages the property's reviews come in with a count for each, plus the property's overall score and the same four sub-scores. Read that row first, then decide which languages are worth paying for.

3. Python: sub-scores by language, then Agoda alongside

import os
from collections import defaultdict
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
def rows(actor, inp): return [r for r in client.dataset(client.actor(actor).call(run_input=inp)["defaultDatasetId"]).iterate_items() if r["type"] == "review"]

trip = rows("kestrel/trip-com-reviews-scraper", {"hotelIds": ["2561632"], "maxReviewsPerHotel": 0, "reviewsSort": "most_recent"})
subs = ("rating_location", "rating_facility", "rating_service", "rating_room")
by_lang = defaultdict(list)
for r in trip: by_lang[r["language"]].append(r)
for lang, rs in sorted(by_lang.items(), key=lambda kv: -len(kv[1])):
    means = {s: sum(r[s] for r in rs if r[s] is not None) / max(1, sum(r[s] is not None for r in rs)) for s in subs}
    print(f"{lang:3} n={len(rs):4}  " + "  ".join(f"{s[7:]}={m:.1f}" for s, m in means.items()))

agoda = rows("kestrel/agoda-reviews-scraper", {"hotelIds": ["63820"], "maxReviewsPerHotel": 200, "reviewsSort": "most_recent"})
feed = [("tripcom", r["rating"], r["language"], r["review_date"][:10], r["text"]) for r in trip]
feed += [("agoda", r["rating"], r["language"], r["review_date"][:10], r["negatives"] or r["text"]) for r in agoda]
Enter fullscreen mode Exit fullscreen mode

The first loop answers, per language, whether Chinese-speaking guests score service or cleanliness differently from English-speaking ones — a question TripAdvisor cannot answer. The second half is the pairing: the Agoda Reviews Scraper also scores 0–10 and carries language and review_date, so Agoda's Asia-Pacific corpus and Trip.com's Chinese one land in one feed on the same scale. Together they are the voice-of-customer feed for the guests Western OTAs do not hear from.

4. A daily channel without code (n8n)

The Agoda template in the n8n/ folder of kestrel-actors-examples is this workflow with a different URL: an 08:00 Schedule trigger, an HTTP Request to the run-sync endpoint for the 50 most recent reviews per hotel at or under 6/10 with text, a Code node that drops review_ids seen before via workflow static data, an IF, a Google Sheets append, one Slack message per review, and a NoOp. Pointing it at Trip.com is the endpoint and the body (hotelNames to hotelIds); the message fields already share names — rating, text, language, response. Add languages: ["Chinese"] and the channel carries only the reviews an English-reading team would otherwise never see, tagged so a translation step knows what it has.

5. Cost and limits

  • $0.004 per delivered review row; hotel and status rows and anything languages, maxRating or requireText removes are free. A 95-review property in full: $0.38. Three hotels at 50 most recent, daily: at most $0.60 a day; the complaints version delivers a handful of rows, a cent or two.
  • Reviews arrive as written; Trip.com's translations are not exposed by the endpoint.
  • A throttled request looks like a hotel with no reviews. The actor treats that shape as an error and retries on a fresh IP; it never reports it as no_reviews.
  • Hotel names are not an input: Trip.com has no public autocomplete, and a guessed property would bill for the wrong hotel. Take the id from the URL.
  • Reviewer names and countries are personal data under GDPR: process them for a stated purpose, keep them no longer than you need, and read Trip.com's terms before scraping at scale. Not legal advice.

That is the feed: one call per hotel, three filters before billing, a schedule for the history, and the Chinese-language corpus in the same table as everything else. Full reference on the actor page: apify.com/kestrel/trip-com-reviews-scraper.

Every actor's inputs, output fields, a sample row and its honest limits are documented at mtedj.github.io/kestrel-actors-examples, including a side-by-side comparison of every review scraper — what each source really carries and where its ceiling is.

Top comments (0)