DEV Community

Cover image for Monitor guest reviews across Airbnb and Agoda: a voice-of-customer feed for hotels (Python + n8n)
Tedj MEABIOU
Tedj MEABIOU

Posted on

Monitor guest reviews across Airbnb and Agoda: a voice-of-customer feed for hotels (Python + n8n)

A property with a few hundred hotel reviews across two booking sites gets a handful of bad ones a month. Those need a reply the same day and a line in the ops log; the five-star ones can wait. Turning that into guest feedback data — one feed, both sites, low scores only, with whether anyone answered — is a data job, and the cost question is whether you pay for the 95% of reviews nobody needs to read. Not if the filter runs before billing.

1. Two actors, both filter before billing

The Airbnb Reviews Scraper on Apify takes listing ids, URLs or a place name, and returns airbnb guest reviews as rows: star rating, original text plus Airbnb's translation, language, date, reviewer, stay type and the host's public reply. minRating keeps only reviews at or below that rating, and only those are billed: in a verified run, a listing with 441 reviews returned 4 rows under minRating: 3 — $0.02 instead of $2.20.

The Agoda Reviews Scraper takes hotel names (resolved through Agoda's own search), property URLs or numeric ids. Agoda scores 0–10, so the ceiling is maxRating; requireText drops the score-only reviews that are a large share of Agoda's corpus. A property with 324 visible reviews delivered 8 rows under maxRating: 5 with requireText on.

The Airbnb request:

curl -X POST "https://api.apify.com/v2/acts/kestrel~airbnb-reviews-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"listingIds": ["11562247", "45307024"], "maxReviewsPerListing": 0, "reviewsSort": "most_recent", "minRating": 3, "locale": "en"}'
Enter fullscreen mode Exit fullscreen mode

A review row (the docs' five-star example; minRating: 3 returns only 3 and below):

{ "type": "review", "id": "11562247", "url": "https://www.airbnb.com/rooms/11562247", "name": "Charming studio in Baixa",
  "reviews_total": 441, "review_id": "1752111840493470784", "rating": "5",
  "text": "El departamento es muy lindo. Esta todo muy bien ubicado.",
  "text_localized": "The apartment is very nice. Everything is very well located.", "language": "es",
  "created_at": "2026-08-14T10:53:27Z", "reviewer_name": "Luis Armando", "reviewer_location": "Buenos Aires, Argentina",
  "reviewer_is_superhost": false, "host_reply": null, "host_reply_date": null, "stay_type": null }
Enter fullscreen mode Exit fullscreen mode

rating is a string, as Airbnb returns it; host_reply is null when nobody answered — that null is the "unanswered" flag.

2. Agoda: positives and negatives already split

curl -X POST "https://api.apify.com/v2/acts/kestrel~agoda-reviews-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hotelNames": ["Tivoli Avenida Liberdade"], "maxReviewsPerHotel": 0, "reviewsSort": "most_recent", "maxRating": 5, "requireText": true}'
Enter fullscreen mode Exit fullscreen mode
{ "type": "review", "hotel_id": "63820", "hotel_name": "Tivoli Avenida Liberdade", "review_id": "1059514024",
  "rating": 10.0, "rating_5": 5.0, "rating_text": "Exceptional", "title": "Nice Hotel", "text": "Beautiful hotel in the center!",
  "positives": null, "negatives": null, "language": "en", "review_date": "2025-12-15T10:54:00+07:00",
  "check_in": "2025-12-08T00:00:00+07:00", "check_out": "2025-12-14T00:00:00+07:00", "room_type": null,
  "reviewer_name": "VARDAN", "reviewer_country": "Mozambique", "source": "Agoda",
  "responder": "Tivoli Avenida Liberdade", "response": null, "response_date": null }
Enter fullscreen mode Exit fullscreen mode

When the guest filled Agoda's two boxes, positives and negatives arrive as separate fields — the complaint is a column read, not a sentiment model. check_in, check_out and room_type tie a score to a stay and a room. rating_5 is the same score out of five, for lining up with Airbnb.

3. Python: one voice-of-customer table from both

import csv, os
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"]

airbnb = rows("kestrel/airbnb-reviews-scraper", {"listingIds": ["11562247", "45307024"], "maxReviewsPerListing": 50, "reviewsSort": "most_recent", "minRating": 3})
agoda = rows("kestrel/agoda-reviews-scraper", {"hotelIds": ["63820"], "maxReviewsPerHotel": 50, "reviewsSort": "most_recent", "maxRating": 6, "requireText": True})

feed = [("airbnb", r["id"], int(r["rating"]), r["created_at"][:10], r["text_localized"] or r["text"], r["host_reply"] is not None) for r in airbnb]
feed += [("agoda", r["hotel_name"] or r["hotel_id"], r["rating_5"], r["review_date"][:10], r["negatives"] or r["text"], r["response"] is not None) for r in agoda]
with open("voc_feed.csv", "a", newline="") as f: csv.writer(f).writerows(feed)
for src, prop, score, day, text, replied in sorted(feed, key=lambda x: x[3], reverse=True):
    print(f"{day} {src:6} {prop:24} {score}/5 {'replied' if replied else 'UNANSWERED'}  {text[:80]}")
Enter fullscreen mode Exit fullscreen mode

Every row carries fetched_at, so a daily run makes the CSV a time series: complaints per property per week, response rate, which negatives repeat. language lets you route by language before spending model tokens.

4. Slack and a Sheet, no code (n8n)

Two templates in the n8n/ folder of kestrel-actors-examples, one per site, share a shape: an 08:00 Schedule trigger, an HTTP Request to the run-sync endpoint for the 50 most recent reviews per property (3 stars or below on Airbnb, 6/10 or below with text on Agoda), 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. The Airbnb one flags each message unanswered until host_reply appears. Swap listingIds for locationQueries: ["Lisbon, Portugal"] to watch a whole market.

5. Cost and limits

  • $0.005 per delivered review row on both actors; listing, hotel and status rows are free, and whatever minRating, maxRating or requireText removes is never billed. Worst case per property per day at 50 most recent: $0.25; the verified template runs delivered 1–3 rows, $0.005–$0.015.
  • Reviews are personal data — a first name, a location, someone's words. Under GDPR you are a controller once you store them: keep them only as long as needed, do not republish identities. Neither actor exposes email, phone or full names.
  • reviewer_location on Airbnb is self-declared and often blank; positives, negatives, room_type and stay_length on Agoda are only present when Agoda shows them.
  • Both sites' terms discourage automated access; that is a terms question, with the risk on whoever runs it.

That is the review monitoring feed: two filtered calls, one normalised table, a schedule. Full references on the actor pages: apify.com/kestrel/airbnb-reviews-scraper and apify.com/kestrel/agoda-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.

Top comments (0)