DEV Community

Cover image for HRS hotel reviews: twelve category votes from business travellers, twenty hotels per request
Tedj MEABIOU
Tedj MEABIOU

Posted on

HRS hotel reviews: twelve category votes from business travellers, twenty hotels per request

HRS is the German corporate-travel booking platform: companies put their hotel programme on it and staff book through it, so an HRS rating is usually a business traveller's. A rating is also more than a score and a paragraph — twelve category votes, the traveller type and age group on the booking — and the ratings endpoint returns a hotel's whole comment list in one answer, twenty hotels per request. This is how to read hrs reviews as rows, why a 100-hotel comp set is five requests, and why the proxy must be residential.

1. One request, one row per commented rating

The HRS Reviews Scraper on Apify takes HRS hotel URLs or the numeric id after /hotel/ and returns hrs hotel reviews as rows, through the JSON endpoint the hotel page's own ratings dialog loads. No login, no browser, no API key.

curl -X POST "https://api.apify.com/v2/acts/kestrel~hrs-reviews-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN&timeout=300" \
  -H "Content-Type: application/json" \
  -d '{"hotelIds": ["10520"], "maxReviewsPerHotel": 30, "reviewsSort": "most_recent", "proxyConfiguration": {"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]}}'
Enter fullscreen mode Exit fullscreen mode

A review row:

{ "type": "review", "review_id": "315653282", "hotel_id": "10520", "hotel_name": "Bilderberg Bellevue Hotel Dresden",
  "rating": 9.36, "rating_5": 4.68,
  "positives": "Tolle Lage direkt an der Elbe und fußnah zu Altstadt", "negatives": null,
  "hotel_reply": "Das klingt nach einer schönen Zeit in Dresden! Wir freuen uns, dass Sie bei der Gelegenheit mit uns als Ihren Gastgeber geplant haben.",
  "traveller_type": "PRIVATE", "age_group": "OVER_59", "language": "deu",
  "arrival_date": "2025-12-08", "departure_date": "2025-12-11", "nights": 3, "review_date": "2025-12-11T14:28:35Z",
  "votes": { "FRIENDLINESS_OF_RECEPTION": 10, "SERVICE_OF_HOTEL_EMPLOYEES": 10, "HOTEL_AMBIANCE": 9, "ROOM_CONFIGURATION": 10, "HOTEL_TIDINESS": 10, "SANITARY_FACILITIES": 8, "ROOM_SIZE": 10, "BREAKFAST_SERVICE": 9, "RESTAURANT_SERVICE": 0, "PRICE_PERFORMANCE_RATIO": 9, "WELLNESS_RANKING": 8, "QUALITY_OF_BEDS": 10 },
  "fetched_at": "2026-08-29T10:20:11+00:00" }
Enter fullscreen mode Exit fullscreen mode

rating is HRS's 0–10 score, rating_5 the same out of five, votes the twelve categories behind it (0 = not rated). positives and negatives are the guest's two boxes, hotel_reply the hotel's answers to each, joined. traveller_type is one of eight HRS booking types (BUSINESS, CONFERENCE, PRIVATE …), age_group UP_TO_59 or OVER_59, and language an ISO 639-2 code.

2. Twenty hotels per request, only the commented ratings

The ratings endpoint has no paging: one answer holds a hotel's whole comment list, and one request takes up to twenty hotel ids. A comp set of 100 hotels is five requests, plus one page per hotel for the name unless includeHotelRow is off. maxReviewsPerHotel is a cost cap applied after reviewsSort, not a saving on requests.

Only ratings with a written comment become rows: rating_count counts every rating HRS has and comment_count the ones with text — a property may show 150 and 85. Both sit on the free hotel row (with avg_by_category and ratings_by_traveller_type) and on the status row as total and comments.

The proxy is not optional: hrs.com and its ratings API answer 403 to datacenter IPs, so the input defaults to Apify's RESIDENTIAL group; traffic is about 100 KB of JSON per hotel plus the page.

3. Business travel hotel reviews, filtered before billing

travellerTypes: ["BUSINESS"] is asked of HRS directly, so nothing else downloads; languages: ["de"] keeps German comments; maxRating: 7 keeps ratings at or below 7 (a middling stay here); requireText guarantees every billed row carries text. Dropped rows count as filtered, never charged.

{ "startUrls": ["https://www.hrs.com/de/hotel/162"], "travellerTypes": ["BUSINESS"], "languages": ["de"], "language": "de", "maxReviewsPerHotel": 0, "proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] } }
Enter fullscreen mode Exit fullscreen mode

4. Python: a comp set ranked on breakfast and beds

import os
from collections import defaultdict
from statistics import fmean
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("kestrel/hrs-reviews-scraper").call(run_input={
    "hotelIds": ["10520", "162", "22991"], "maxReviewsPerHotel": 0,
    "proxyConfiguration": {"useApifyProxy": True, "apifyProxyGroups": ["RESIDENTIAL"]}})
rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())

for h in (r for r in rows if r["type"] == "hotel"):
    cat, biz = h["avg_by_category"] or {}, (h["ratings_by_traveller_type"] or {}).get("BUSINESS") or {}
    print(f"{h['hotel_name']:36} {h['rating']}  beds={cat.get('QUALITY_OF_BEDS')} breakfast={cat.get('BREAKFAST_SERVICE')}  business={biz.get('rating')} (n={biz.get('count')})  {h['comment_count']}/{h['rating_count']} commented")

votes = defaultdict(list)
for r in (r for r in rows if r["type"] == "review"): votes[r["traveller_type"]].append(r["votes"]["PRICE_PERFORMANCE_RATIO"])
for kind, vs in votes.items(): print(kind, len(vs), "comments, price-performance", round(fmean([v for v in vs if v] or [0]), 1))
Enter fullscreen mode Exit fullscreen mode

The hotel rows are the benchmark sheet; the review rows say whether business guests vote price-performance lower than private ones. For the other half of the German market, the Kurzurlaub Reviews Scraper reads hotelbewertungen from Kurzurlaub.de, the leisure short-break site: HRS for the weekday guest, Kurzurlaub for the weekend one.

5. Hotel guest feedback on a schedule (n8n)

The review-alert templates in the n8n/ folder of kestrel-actors-examples share one shape: an 08:00 Schedule trigger, an HTTP Request to the run-sync endpoint, a Code node that drops review_ids seen before, an IF, a Google Sheets append and a Slack message per row. For HRS the body is the curl body in section 1 with more hotelIds, maxRating: 7 and includeHotelRow: false; the message carries rating, negatives and traveller_type, and an empty hotel_reply marks the row to route.

6. Cost and limits

  • $0.003 per delivered review row; hotel rows, status rows, unknown hotels (not_found) and filtered reviews are free. A busy city hotel has 50–150 commented ratings: $0.15–$0.45 in full; 100 hotels at 60 comments is $18; a daily complaints feed over 20 properties is a few cents, plus residential proxy traffic on your Apify plan.
  • Comments come in the guest's language, mostly German then English, untranslated; language says which.
  • The scraper reads the same publicly accessible ratings any visitor sees on a hotel page, through the endpoint that page uses, without logging in or bypassing any access control. Reviews contain personal data in the sense of the GDPR — a display name, a stay date, a text — so you need a lawful basis for what you do with them, you should not republish reviewer names, and you should honour deletion requests. Use the data for analysis, not to rebuild HRS's review pages. This is not legal advice; check the rules that apply to you.

That is the feed: five requests for a hundred hotels, twelve votes per row, filters before billing and a schedule. Full reference on the actor page: apify.com/kestrel/hrs-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)