HRS Hotel Reviews Scraper
Pricing
from $2.00 / 1,000 reviews
HRS Hotel Reviews Scraper
Extract HRS guest reviews and hotel ratings: comments, category scores, reviewer profiles, traveler segments, aggregated summaries, stars, address, GPS. Optional English translation + sentiment. Date/score filtering. Reputation monitoring, competitor benchmarking, travel analytics, AI workflows.
Pricing
from $2.00 / 1,000 reviews
Rating
0.0
(0)
Developer
Korado Labs
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
5 hours ago
Last modified
Categories
Share
HRS Hotel Reviews Scraper — translated, analysed, complete
Extract every guest review HRS publishes for a hotel, translated into English, labelled with sentiment, and rolled up into per-hotel top complaints and top praise.
HRS is a German corporate-travel platform. Its review text is written in the reviewer's own language regardless of which locale page you open — measured across 261 reviews from 54 hotels, 92% were German. Every other HRS scraper hands you that German text unchanged and leaves the translation, the sentiment work and the theme aggregation to you.
What makes this different
| This Actor | Others on the Store | |
|---|---|---|
| Reviews per hotel | All of them (36 for hotel 391864) | As few as 2 |
| Review text | Original plus English translation | Original German only |
| Sentiment + themes | Per review, from a fixed vocabulary | Not offered |
| Top complaints per hotel | Counted and ranked | Not offered |
| Incremental monitoring | Cross-run dedupe, zero LLM cost on repeats | Date cutoff at best |
| Reviewer privacy | 4 anonymisation modes + text redaction | Not offered |
| Hotel details | Name, stars, city, country, timezone, GPS | Varies |
| Requests | 1 per 100 hotels (batched) | Typically 1 per hotel |
That last row matters for cost: 200 hotels and roughly 2,000 reviews come back in a single HTTP request.
Quick start
{"startUrls": [{ "url": "https://www.hrs.com/en/hotel/391864" }]}
That returns all 36 reviews for the hotel plus a hotel summary row. Add
"translateToEnglish": true and "analyzeSentiment": true to get the English and
analysis columns.
Input
All three HRS hotel URL shapes are accepted, in any of the seven locales, on both
hrs.com and hrs.de:
https://www.hrs.com/en/hotel/391864https://www.hrs.com/en/hotel/ibis-berlin-105179 slug with trailing IDhttps://www.hrs.com/en/detail?hn=105179 legacy detail route
| Field | Type | Default | Notes |
|---|---|---|---|
startUrls | array | — | HRS hotel detail URLs |
hotelIds | array | [] | Numeric IDs; the stable join key |
maxReviewsPerHotel | integer | 0 | 0 means every review |
sortReviewsBy | enum | newest | newest, oldest, highestScore, lowestScore |
| Filters | |||
reviewsFrom / reviewsTo | date | — | Inclusive YYYY-MM-DD window |
minScore / maxScore | integer | — | HRS 0–10 scale |
travelerTypes | array | [] | BUSINESS, PRIVATE, YOUNG_COUPLE, … |
reviewLanguages | array | [] | de or deu both work |
onlyWithText | boolean | false | Drop score-only ratings |
onlyRecommended | boolean | false | Keep recommendations only |
| Translation | |||
translateToEnglish | boolean | false | Adds the *En columns |
llmApiKey | secret | — | OpenAI or any compatible provider |
llmModel | string | gpt-4o-mini | |
llmBaseUrl | string | OpenAI | Point at Azure, OpenRouter, Ollama, vLLM |
translationBatchSize | integer | 20 | Reviews per LLM call |
| Analysis | |||
analyzeSentiment | boolean | false | Sentiment + themes + hotel rollup |
topThemesPerHotel | integer | 5 | Length of the top-N lists |
analysisBatchSize | integer | 15 | Reviews per LLM call |
| Incremental monitoring | |||
deduplicateAcrossRuns | boolean | false | Only return unseen reviews |
dedupStoreName | string | hrs-seen-reviews | Rename to isolate pipelines |
| Privacy | |||
anonymizeReviewers | enum | none | none, initials, hash, drop |
anonymizeSalt | secret | — | Keeps pseudonyms stable across runs |
redactNamesInText | boolean | false | Strip names from comment text |
| Output | |||
includeHotelDetails | boolean | true | Name, stars, geo, timezone |
language | enum | en | HRS response locale |
| Advanced | |||
proxyConfiguration | object | off | Only needed if a large batch gets refused |
Output
Three datasets. Reviews go to the default dataset; hotels and errors to named datasets.
Reviews (default dataset)
{"reviewUid": "391864:317018243","hotelId": "391864","hotelName": "Hotel Brandenburger Tor Potsdam","bookingId": 317018243,"reviewerName": "Jürgen B.","travelerType": "BUSINESS","travelerAgeGroup": "UP_TO_59","postedAt": "2026-05-29T11:14:44.207000+00:00","postedDate": "2026-05-29","arrivalDate": "2026-03-27","departureDate": "2026-03-30","score": 8.4,"recommended": true,"comfortRating": null,"positiveComment": "Die zentrale Lage ist nahezu perfekt.","negativeComment": null,"reviewText": "Die zentrale Lage ist nahezu perfekt.","hotelReply": null,"locale": "en","language": "eng","categoryRatings": { "FRIENDLINESS_OF_RECEPTION": 9, "HOTEL_TIDINESS": 9 },"sourceUrl": "https://www.hrs.com/en/hotel/391864","positiveCommentEn": "The central location is almost perfect.","negativeCommentEn": null,"hotelReplyEn": null,"reviewTextEn": "The central location is almost perfect.","translationStatus": "translated","translationModel": "gpt-4o-mini","sentiment": "positive","praiseThemes": ["LOCATION"],"complaintThemes": [],"themes": ["LOCATION"],"enrichmentStatus": "enriched","enrichmentModel": "gpt-4o-mini"}
reviewTextEn is always populated, even with translation disabled or when a
translation fails — it falls back to the original. So you get exactly one column
you can always read, with no per-row conditionals downstream. Original text is
never overwritten.
Hotels (hotels dataset)
{"hotelId": "391864","hotelName": "Hotel Brandenburger Tor Potsdam","aggregateScore": 9.1,"aggregateRatingCount": 69,"reviewsWithComments": 36,"reviewsSaved": 36,"recommendationRatio": 95.7,"scoreByTravelerType": { "BUSINESS": 9.0, "PRIVATE": 9.1 },"ratingCountByTravelerType": { "BUSINESS": 40, "PRIVATE": 19 },"categoryAverages": { "FRIENDLINESS_OF_RECEPTION": 9.4, "PRICE_PERFORMANCE_RATIO": 8.7 },"stars": 4,"status": "OPEN","city": "Potsdam","country": "DEU","countryAlpha2": "DE","timezone": "Europe/Berlin","latitude": 52.399619,"longitude": 13.048601,"sourceUrl": "https://www.hrs.com/en/hotel/391864","scrapedAt": "2026-08-14T09:17:22.860604+00:00","runId": "ojF0jQhaFEzqYy4Dj","enrichedReviewCount": 36,"sentimentBreakdown": { "positive": 28, "mixed": 7, "negative": 1 },"topComplaints": [{ "theme": "BED_QUALITY", "count": 4 }],"topPraise": [{ "theme": "LOCATION", "count": 22 }]}
Three review counts are reported because they answer different questions:
aggregateRatingCount— every rating HRS counts, including score-only ones.reviewsWithComments— commented reviews HRS exposes. A property of the hotel.reviewsSaved— rows this run wrote, after filters and deduplication.
On a monitoring run that finds nothing new, reviewsSaved is legitimately 0
while the other two stay constant.
The hotels and errors datasets accumulate across runs. They are named
datasets, so unlike the per-run default dataset they persist and grow. That makes
hotels a score-over-time series, which is what you want for reputation
monitoring — filter or group by scrapedAt / runId to get a single run's rows.
If you would rather each run start clean, delete the dataset in Storage between
runs, or read the default (reviews) dataset only.
Errors (errors dataset)
One row per hotel that could not be collected, with a reason code:
CDN_BLOCKED, RATE_LIMITED, REQUEST_REJECTED, HOTEL_NOT_FOUND, and others.
A refused request is never reported as "this hotel has no reviews". The run
exits non-zero only if every hotel failed.
Recipes
Find out what guests complain about
{"hotelIds": ["391864", "1075307"],"translateToEnglish": true,"analyzeSentiment": true,"llmApiKey": "sk-...","topThemesPerHotel": 5}
Read topComplaints from the hotels dataset.
Alert on bad reviews only
{"hotelIds": ["391864", "1075307"],"maxScore": 7,"onlyWithText": true,"translateToEnglish": true,"llmApiKey": "sk-..."}
HRS scores skew high, so pick the threshold deliberately. Across five sampled
hotels, maxScore: 6 matched 4 reviews out of 102 while maxScore: 7 matched 13.
Two of the five hotels had no review at or below 7 at all.
Daily monitor that only reports new reviews
{"hotelIds": ["391864", "1075307", "65907"],"deduplicateAcrossRuns": true,"translateToEnglish": true,"analyzeSentiment": true,"llmApiKey": "sk-..."}
Deduplication runs before the LLM stages, so a repeat run with no new reviews makes zero LLM calls and costs almost nothing.
GDPR-conscious export
{"hotelIds": ["391864"],"anonymizeReviewers": "hash","anonymizeSalt": "your-stable-secret","redactNamesInText": true}
API
from apify_client import ApifyClientclient = ApifyClient("APIFY_TOKEN")run = client.actor("YOUR_USERNAME/hrs-reviews-scraper").call(run_input={"hotelIds": ["391864"],"translateToEnglish": True,})for item in client.dataset(run["defaultDatasetId"]).iterate_items():print(item["reviewTextEn"], item["sentiment"])
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: 'APIFY_TOKEN' });const run = await client.actor('YOUR_USERNAME/hrs-reviews-scraper').call({hotelIds: ['391864'],translateToEnglish: true,});const { items } = await client.dataset(run.defaultDatasetId).listItems();
curl -X POST "https://api.apify.com/v2/acts/YOUR_USERNAME~hrs-reviews-scraper/runs?token=APIFY_TOKEN" \-H 'Content-Type: application/json' \-d '{"hotelIds":["391864"],"translateToEnglish":true}'
Pricing
Pay per event, so you only pay the premium rate for rows that received premium processing:
| Event | Price |
|---|---|
| Run start | $0.004 |
| Review (raw) | $0.0010 |
| Review (translated and/or analysed) | $0.0035 |
| Hotel summary | $0.0020 |
A review whose translation failed bills at the raw rate. You are not charged for work that did not land, and rows removed by your filters or by deduplication are never charged at all.
Limitations
Stated plainly, because the alternative is a surprise:
- Only reviews HRS publishes with written comments are retrievable.
aggregateRatingCountis usually higher thanreviewsWithComments; the gap is score-only ratings that carry no text. There is no way to fetch text that HRS does not publish. - HRS's own
languagefield is unreliable — it reports the locale of the submission page, not the language of the text, and was wrong for 8 of the 11 reviews it labelled English in our sample. This Actor inspects the text instead, solanguageis passed through as-is and should not be trusted for filtering precision. - Translation and analysis need an LLM key. Without one the Actor still returns every review, just without the English and analysis columns.
- With
includeHotelDetails: falsea mistyped hotel ID cannot be told apart from a real hotel that has no reviews yet, so noHOTEL_NOT_FOUNDis claimed. - These are undocumented internal endpoints. They have changed before and will
change again. See
docs/source-notes.mdfor the evidence trail.
Reliability
| Test suite | 621 tests |
| Soak, randomised inputs + fault injection | 120/120 clean exits, 0 crashes |
| 25 identical back-to-back runs | 25/25 succeeded, 102 reviews each |
| 500 hotels / 1,283 reviews | 10 HTTP requests, 18.8 s, 99 MB peak |
| 2,000 hotels | 40 HTTP requests, 49 s, 91 MB peak |
Design choices behind that:
- Nothing HRS returns can crash the run. Every field is type-coerced, so an unexpected shape degrades one value instead of raising. A hostile-payload suite feeds deliberately wrong types into every mapper.
- Migration resumes. If Apify moves the run to another machine, it picks up where it left off rather than re-fetching and re-charging.
- Timeouts stop early, not abruptly. The run halts on a batch boundary with time in reserve so results and the status message are written.
- One bad hotel never discards a batch. Failures land in an
errorsdataset with a reason code; the run still exits 0. - A total refusal retries through a proxy once before giving up.
failOnNoResults (default on) controls whether collecting nothing is a failed run.
Set it to false to treat an upstream outage as an uneventful run instead.
Full detail in docs/reliability.md.
Health check
Run the Actor with {"healthCheck": true} to verify the HRS API still returns the
shape this Actor expects. It runs twelve structural checks against five stable
hotels and exits with a failure if anything broke:
PASS total-review-volume: 102 reviews across 5 hotels (floor 40)PASS api-returns-more-than-the-page-embeds: best hotel returned 36 (floor 10)PASS review-fields-present: all present...
Schedule it and attach a webhook to run failure. You will hear about an HRS change from the canary rather than from an empty dataset.
Legal and privacy
This Actor reads publicly visible HRS review data without a login, an account, or a token. It batches aggressively and rate-limits conservatively to keep load on HRS low.
Reviewer display names are personal data. HRS publishes them as surname plus
initial (Meyer F.), which is identifying when combined with a hotel and a stay
date, and most reviewers are in the EU. If you are loading this into a warehouse,
use anonymizeReviewers and store only what your workflow needs. Do not use this
data to identify, contact or profile individual reviewers.
Not affiliated with, endorsed by, or sponsored by HRS.
Development
uv venv --python 3.13 .venvuv pip install --python .venv/bin/python -r requirements-dev.txt.venv/bin/python -m pytest -q # 327 tests.venv/bin/python -m src # reads storage/key_value_stores/default/INPUT.json
docs/stub_llm_server.py serves a fake OpenAI-compatible endpoint so you can
exercise the full translation and analysis path without spending tokens.
docs/capture_fixtures.py refreshes the test fixtures from the live API.