// Pararius HTML parsing. Pure functions, no network: the worker fetches, these parse.
//
// Pararius has no public API, so everything here reads markup that can change
// without notice. Each parser prefers structured data (ld+json, the analytics
// blob) and falls back to looser text matching, so a redesign degrades fields
// instead of dropping listings.
import type { ListingCard, ListingDetail, Offer, PropertyType } from './types';
export const PARARIUS_ORIGIN = 'https://www.pararius.nl';
const listingPath =
/\/(appartement|huis|kamer|studio|woning|[a-z]+)-te-(huur|koop)\/([a-z0-9-]+)\/([0-9a-f]{8})\/([a-z0-9-]+)/;
const typeWords: Record = {
appartement: 'apartment',
apartment: 'apartment',
huis: 'house',
house: 'house',
kamer: 'room',
room: 'room',
studio: 'studio'
};
const entities: Record = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
euro: '€',
sup2: '²',
sup3: '³'
};
export function decodeEntities(value: string): string {
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z0-9]+);/gi, (match, code: string) => {
if (code[0] === '#') {
const n =
code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : Number(code.slice(1));
return Number.isFinite(n) ? String.fromCodePoint(n) : match;
}
return entities[code.toLowerCase()] ?? match;
});
}
export function textOf(html: string): string {
return decodeEntities(html.replace(/<[^>]*>/g, ' '))
.replace(/\s+/g, ' ')
.trim();
}
/** Bot challenges (Cloudflare "Just a moment...") instead of real content. */
export function isChallenge(html: string, status = 200): boolean {
if (status === 403 || status === 429 || status === 503) return true;
const head = html.slice(0, 20_000);
return (
/\s*Just a moment/i.test(head) ||
head.includes('challenges.cloudflare.com/turnstile') ||
head.includes('cf-chl-') ||
/\s*Attention Required/i.test(head)
);
}
export type ListingRef = {
sourceId: string;
url: string;
offer: Offer;
propertyType: PropertyType;
citySlug: string;
};
export function listingRef(href: string): ListingRef | null {
const match = listingPath.exec(href);
if (!match) return null;
const [, type, offer, citySlug, shortId, slug] = match;
return {
sourceId: shortId,
url: `${PARARIUS_ORIGIN}/${type}-te-${offer}/${citySlug}/${shortId}/${slug}`,
offer: offer === 'koop' ? 'buy' : 'rent',
propertyType: typeWords[type] ?? 'other',
citySlug
};
}
/** Whole euros from "€ 1.558 per maand", "€1,558 per month", "€ 425.000 k.k." */
export function parseEuros(value: string | null | undefined): number | null {
if (!value) return null;
const match = /€\s*([\d.,]+)/.exec(value) ?? /([\d][\d.,]*)/.exec(value);
if (!match) return null;
const digits = match[1].replace(/[.,]\d{2}$/, '').replace(/[.,]/g, '');
const n = Number(digits);
return digits && Number.isSafeInteger(n) ? n : null;
}
export function parseInteger(value: string | null | undefined): number | null {
const match = value ? /(\d[\d.]*)/.exec(value) : null;
if (!match) return null;
const n = Number(match[1].replace(/\./g, ''));
return Number.isSafeInteger(n) ? n : null;
}
/** "2801 BC Gouda (Centrum)" → postal code, city and neighbourhood. */
export function parseLocation(value: string | null | undefined) {
const out = {
postalCode: null as string | null,
city: null as string | null,
neighbourhood: null as string | null
};
if (!value) return out;
let rest = value.trim();
const hood = /\(([^)]+)\)\s*$/.exec(rest);
if (hood) {
out.neighbourhood = hood[1].trim();
rest = rest.slice(0, hood.index).trim();
}
const postal = /^(\d{4})\s?([A-Z]{2})\b/.exec(rest);
if (postal) {
out.postalCode = `${postal[1]} ${postal[2]}`;
rest = rest.slice(postal[0].length).trim();
}
if (rest) out.city = rest;
return out;
}
function classText(html: string, className: string): string | null {
const re = new RegExp(
`<([a-z0-9-]+)[^>]*class="[^"]*\\b${className}\\b[^"]*"[^>]*>([\\s\\S]*?)\\1>`,
'i'
);
const match = re.exec(html);
if (!match) return null;
const value = textOf(match[2]);
return value || null;
}
function firstImage(html: string): string | null {
const tag = /
]*>/i.exec(html)?.[0];
if (!tag) return null;
const src =
/\bsrc="(https:[^"]+)"/i.exec(tag)?.[1] ??
/\bsrcset="(https:[^"\s,]+)/i.exec(tag)?.[1] ??
/\bdata-src="(https:[^"]+)"/i.exec(tag)?.[1];
return src ? decodeEntities(src) : null;
}
export type SearchPage = {
cards: ListingCard[];
nextPageUrl: string | null;
totalResults: number | null;
};
/**
* Parse a search results page (`/huurwoningen/gouda/1300-1700`).
* Splits on the listing card wrappers when present; otherwise falls back to
* one chunk per unique listing link so a markup change still yields ids.
*/
export function parseSearchPage(html: string, pageUrl: string): SearchPage {
const chunks: string[] = [];
const marker =
/<(section|li|article|div)\b[^>]*class="[^"]*\blisting-search-item\b(?!__)[^"]*"/gi;
const starts = [...html.matchAll(marker)].map((m) => m.index!);
if (starts.length) {
for (let i = 0; i < starts.length; i++) {
chunks.push(html.slice(starts[i], starts[i + 1] ?? starts[i] + 20_000));
}
} else {
const links = /href="([^"]*-te-(?:huur|koop)\/[^"]+)"/gi;
const seen = new Set();
for (const m of html.matchAll(links)) {
const ref = listingRef(m[1]);
if (!ref || seen.has(ref.sourceId)) continue;
seen.add(ref.sourceId);
chunks.push(html.slice(Math.max(0, m.index! - 3000), m.index! + 6000));
}
}
const cards: ListingCard[] = [];
const seen = new Set();
for (const chunk of chunks) {
const href = /href="([^"]*-te-(?:huur|koop)\/[^"]+)"/i.exec(chunk)?.[1];
const ref = href ? listingRef(decodeEntities(href)) : null;
if (!ref || seen.has(ref.sourceId)) continue;
seen.add(ref.sourceId);
const titleLink = /]*class="[^"]*link--title[^"]*"[^>]*>([\s\S]*?)<\/a>/i.exec(chunk);
const title =
(titleLink ? textOf(titleLink[1]) : null) ?? classText(chunk, 'listing-search-item__title');
const location = parseLocation(
classText(chunk, 'listing-search-item__sub-title') ??
classText(chunk, 'listing-search-item__location')
);
const priceText =
classText(chunk, 'listing-search-item__price') ?? /€\s*[\d.,]+[^<]{0,30}/.exec(chunk)?.[0];
const area =
classText(chunk, 'illustrated-features__item--surface-area') ??
/(\d+)\s*m(?:²|²|2)/i.exec(chunk)?.[0];
const rooms = classText(chunk, 'illustrated-features__item--number-of-rooms');
const agentBlock = classText(chunk, 'listing-search-item__info');
cards.push({
sourceId: ref.sourceId,
url: ref.url,
offer: ref.offer,
propertyType: ref.propertyType,
title: title?.slice(0, 200) ?? null,
postalCode: location.postalCode,
city: location.city ?? cityFromSlug(ref.citySlug),
neighbourhood: location.neighbourhood,
priceEur: parseEuros(priceText),
areaM2: parseInteger(area),
rooms: parseInteger(rooms),
interior: classText(chunk, 'illustrated-features__item--interior'),
agent: agentBlock?.slice(0, 160) ?? null,
imageUrl: firstImage(chunk)
});
}
const nextHref =
/]*class="[^"]*pagination__link--next[^"]*"[^>]*href="([^"]+)"/i.exec(html)?.[1] ??
/]*href="([^"]+)"[^>]*class="[^"]*pagination__link--next[^"]*"/i.exec(html)?.[1] ??
/]*rel="next"[^>]*href="([^"]+)"/i.exec(html)?.[1];
const total =
classText(html, 'search-list-header__count') ??
/(\d[\d.]*)\s+(?:huurwoningen|koopwoningen|woningen|results|resultaten)/i.exec(
textOf(html.slice(0, 200_000))
)?.[1];
return {
cards,
nextPageUrl: nextHref ? new URL(decodeEntities(nextHref), pageUrl).toString() : null,
totalResults: parseInteger(total)
};
}
/** `/huurwoningen/gouda/1300-1700` → page N url (`.../page-2`). */
export function searchPageUrl(base: string, page: number): string {
const url = new URL(base);
url.pathname = url.pathname.replace(/\/page-\d+\/?$/, '').replace(/\/$/, '');
if (page > 1) url.pathname += `/page-${page}`;
return url.toString();
}
export function cityFromSlug(slug: string): string {
return slug
.split('-')
.map((part) =>
part === 'aan' || part === 'den' || part === 'de'
? part
: part[0]?.toUpperCase() + part.slice(1)
)
.join(' ')
.replace(/^\w/, (c) => c.toUpperCase());
}
function jsonBlocks(html: string): unknown[] {
const out: unknown[] = [];
for (const m of html.matchAll(/