HRS Hotel Reviews Scraper avatar

HRS Hotel Reviews Scraper

Pricing

from $2.00 / 1,000 reviews

Go to Apify Store
HRS Hotel Reviews Scraper

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

Korado Labs

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

5 hours ago

Last modified

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 ActorOthers on the Store
Reviews per hotelAll of them (36 for hotel 391864)As few as 2
Review textOriginal plus English translationOriginal German only
Sentiment + themesPer review, from a fixed vocabularyNot offered
Top complaints per hotelCounted and rankedNot offered
Incremental monitoringCross-run dedupe, zero LLM cost on repeatsDate cutoff at best
Reviewer privacy4 anonymisation modes + text redactionNot offered
Hotel detailsName, stars, city, country, timezone, GPSVaries
Requests1 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/391864
https://www.hrs.com/en/hotel/ibis-berlin-105179 slug with trailing ID
https://www.hrs.com/en/detail?hn=105179 legacy detail route
FieldTypeDefaultNotes
startUrlsarrayHRS hotel detail URLs
hotelIdsarray[]Numeric IDs; the stable join key
maxReviewsPerHotelinteger00 means every review
sortReviewsByenumnewestnewest, oldest, highestScore, lowestScore
Filters
reviewsFrom / reviewsTodateInclusive YYYY-MM-DD window
minScore / maxScoreintegerHRS 0–10 scale
travelerTypesarray[]BUSINESS, PRIVATE, YOUNG_COUPLE, …
reviewLanguagesarray[]de or deu both work
onlyWithTextbooleanfalseDrop score-only ratings
onlyRecommendedbooleanfalseKeep recommendations only
Translation
translateToEnglishbooleanfalseAdds the *En columns
llmApiKeysecretOpenAI or any compatible provider
llmModelstringgpt-4o-mini
llmBaseUrlstringOpenAIPoint at Azure, OpenRouter, Ollama, vLLM
translationBatchSizeinteger20Reviews per LLM call
Analysis
analyzeSentimentbooleanfalseSentiment + themes + hotel rollup
topThemesPerHotelinteger5Length of the top-N lists
analysisBatchSizeinteger15Reviews per LLM call
Incremental monitoring
deduplicateAcrossRunsbooleanfalseOnly return unseen reviews
dedupStoreNamestringhrs-seen-reviewsRename to isolate pipelines
Privacy
anonymizeReviewersenumnonenone, initials, hash, drop
anonymizeSaltsecretKeeps pseudonyms stable across runs
redactNamesInTextbooleanfalseStrip names from comment text
Output
includeHotelDetailsbooleantrueName, stars, geo, timezone
languageenumenHRS response locale
Advanced
proxyConfigurationobjectoffOnly 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 ApifyClient
client = 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:

EventPrice
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. aggregateRatingCount is usually higher than reviewsWithComments; 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 language field 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, so language is 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: false a mistyped hotel ID cannot be told apart from a real hotel that has no reviews yet, so no HOTEL_NOT_FOUND is claimed.
  • These are undocumented internal endpoints. They have changed before and will change again. See docs/source-notes.md for the evidence trail.

Reliability

Test suite621 tests
Soak, randomised inputs + fault injection120/120 clean exits, 0 crashes
25 identical back-to-back runs25/25 succeeded, 102 reviews each
500 hotels / 1,283 reviews10 HTTP requests, 18.8 s, 99 MB peak
2,000 hotels40 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 errors dataset 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.

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 .venv
uv 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.