Quick answer: window.__NUXT__ on Hostelworld is not JSON. It's a minified Nuxt.js IIFE — (function(a,b,c,...){...})(...) — a JavaScript expression that only becomes data once a JS engine evaluates it. Feed the regex-matched text straight to json.loads and it fails outright, because it isn't JSON text, it's code. The fix is to actually run it: a real (non-browser) JS evaluator, JSON.stringify the result inside that engine, then json.loads the clean output on the Python side.
The blob that looks like JSON and isn't
Plenty of frameworks hydrate their client with a script tag that's genuinely window.__SOMETHING__ = {...} — a JSON literal assigned to a global. You regex it out, strip the assignment, json.loads, done. That pattern works on a lot of sites and it's the first thing worth trying.
Nuxt doesn't do that. What ships on a Hostelworld city-search page is:
window.__NUXT__=(function(a,b,c,d,e,f,...){return {...}})(A,B,C,D,E,F,...)
An immediately-invoked function expression, minified, with short single-letter parameter names standing in for repeated values in the payload — Nuxt's own de-duplication trick. There is no valid JSON substring to extract, because the actual data only exists after the function body runs and substitutes those parameters back in.
Running it without a browser
The obvious fix is Camoufox — spin up a real browser, let it execute the page's own JS, read window.__NUXT__ off the live DOM. That works, but it's roughly 10x the compute cost of an HTTP-only fetch, and this isn't a case where you need a browser's rendering — you need one function evaluated.
quickjs (the Python bindings over QuickJS, a pure C-extension JS engine) does exactly that without being a browser at all: no DOM, no navigator object, no fingerprint surface, nothing our anti-bot policy has any opinion about. We locate the blob by regex, hand its text straight to a QuickJS context, and pull the result back out as JSON:
NUXT_BLOB_START_RE = re.compile(r"window\.__NUXT__=\(function")
def extract_nuxt_blob(html: str) -> str | None:
match = NUXT_BLOB_START_RE.search(html)
if match is None:
return None
end = html.find("</script>", match.start())
raw = html[match.start():end].rstrip().rstrip(";")
return raw[len("window.__NUXT__="):]
def evaluate_nuxt_blob(blob: str) -> dict | None:
ctx = quickjs.Context()
ctx.set_memory_limit(500_000_000)
ctx.eval(f"globalThis.__R__ = {blob}")
json_str = ctx.eval("JSON.stringify(globalThis.__R__)")
return json.loads(json_str)
The JSON.stringify call happens inside the QuickJS context, on the already-evaluated object — so by the time the string crosses back into Python, it's real JSON and json.loads just works. No regex archaeology on minified JS, no manually walking parameter substitutions.
Once evaluated, the actual property list sits behind a cache key matching ^getCityProperties* inside data — Nuxt's per-page-component cache namespacing — so a second small function picks the longest matching entry rather than hardcoding one exact key name that a redeploy could rename.
The pagination that isn't
While building this we checked whether ?page=N deepens the result set, because Hostelworld's own search UI implies there's more than one page per city. We fetched ?page=1, ?page=2, ?page=3 for the same city and diffed the property lists.
They were identical. All three pages returned the same set of properties. Whatever paging Hostelworld's frontend does, it isn't driven by that query parameter server-side in a way that changes what gets embedded in __NUXT__.
So rather than ship a maxResultsPerCity input that silently caps out at the same ~30 properties no matter how high you set it, the Actor is honest about scope: it returns each city's first page of listings and stamps every row with search_total_properties_count, the total the search actually reports, so you can see the gap between what you asked for and what you got instead of discovering it by counting rows.
What a real run produced
A cloud QA run against Barcelona + Bangkok returned 40 rows, all 40 unique — exactly 20 per city, matching the honest first-page scope above. A sample row:
{
"name": "Kabul Party Hostel Barcelona",
"city": "Barcelona",
"avg_rating": 9.48,
"number_reviews": 11584,
"rating_breakdown": {
"security": 9.3,
"location": 9.7,
"staff": 9.5,
"atmosphere": 9.6,
"cleanliness": 9.1,
"value_for_money": 9.4,
"facilities": 9.2
},
"shared_min_price": {"amount": 18.5, "currency": "USD"},
"detail_url": "https://www.hostelworld.com/hostels/p/.../kabul-party-hostel-barcelona/"
}
Real numbers, not rounded for the post — 11,584 reviews on that one property alone.
What we handle so you don't have to
We rotate through Chrome / Firefox / Safari TLS fingerprints, retry with exponential backoff on 408/429/5xx, and route through Apify's proxy pool with a pinned country so currency stays consistent across a batch of cities instead of silently drifting between USD and EUR mid-run. One unresolvable city fails that city, not the whole batch.
🎒 Hostelworld Hostel Listings Scraper turns a list of cities into structured hostel rows — ratings breakdown, review count, shared/private pricing, badges, and a direct listing URL — instead of you paging through the Store's own search UI by hand. $5.20 per 1,000 results, and you only pay for rows that land.
FAQ
Is window.__NUXT__ valid JSON I can just json.loads?
No. It's a minified Nuxt.js IIFE — an expression that has to be evaluated, not parsed as text.
Do you use a browser to evaluate it?
No. quickjs is a pure C-extension JavaScript value evaluator — no DOM, no browser fingerprint — so it stays outside our anti-bot policy entirely while still running the actual JS.
Does ?page=N return deeper results on Hostelworld?
Not in what gets embedded server-side — we diffed pages 1 through 3 for the same city and got an identical property list back each time. The Actor is scoped to each city's first page and reports search_total_properties_count so you know what you haven't fetched.
How many hostels does one run return per city?
A verified cloud run returned 20 unique properties each for Barcelona and Bangkok — 40 rows total, all unique.
Top comments (0)