// 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-') || /<title>\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 = /<img\b[^>]*>/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<string>(); 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<string>(); 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 = /<a\b[^>]*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 = /<a\b[^>]*class="[^"]*pagination__link--next[^"]*"[^>]*href="([^"]+)"/i.exec(html)?.[1] ?? /<a\b[^>]*href="([^"]+)"[^>]*class="[^"]*pagination__link--next[^"]*"/i.exec(html)?.[1] ?? /<link\b[^>]*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(/<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)) { try { out.push(JSON.parse(m[1])); } catch { // Ignore malformed blocks; other sources may still have the data. } } return out; } type LdListing = { '@type'?: string | string[]; name?: string; description?: string; image?: string | string[]; address?: { streetAddress?: string; addressLocality?: string; addressRegion?: string; postalCode?: string; }; numberOfRooms?: { value?: number }[] | { value?: number } | number; floorSize?: { value?: number }; yearBuilt?: number; numberOfBedrooms?: number; numberOfBathroomsTotal?: number; datePosted?: string; offers?: { price?: number; url?: string }; }; function ldListing(html: string): LdListing | null { for (const block of jsonBlocks(html)) { const items = Array.isArray(block) ? block : [block]; for (const item of items) { if (!item || typeof item !== 'object') continue; const type = (item as LdListing)['@type']; const types = Array.isArray(type) ? type : [type]; if (types.includes('RealEstateListing')) return item as LdListing; } } return null; } /** The flat `odp.*` analytics object Pararius embeds on listing pages. */ export function analyticsBlob(html: string): Record<string, string> { const candidates = [html, decodeEntities(html)]; for (const source of candidates) { const match = /\{[^{}]*"odp\.listing_id"[^{}]*\}/.exec(source); if (!match) continue; try { const parsed = JSON.parse(match[0]) as Record<string, unknown>; const out: Record<string, string> = {}; for (const [key, value] of Object.entries(parsed)) { if (key.startsWith('odp.') && (typeof value === 'string' || typeof value === 'number')) out[key.slice(4)] = String(value); } return out; } catch { // Fall through to the next candidate. } } return {}; } /** The "Kenmerken" definition list: `Borg → € 1.600`, `Interieur → Gestoffeerd`. */ export function featureList(html: string): Record<string, string> { const out: Record<string, string> = {}; const re = /<dt\b[^>]*class="[^"]*listing-features__term[^"]*"[^>]*>([\s\S]*?)<\/dt>\s*<dd\b[^>]*>([\s\S]*?)<\/dd>/gi; for (const m of html.matchAll(re)) { const key = textOf(m[1]).slice(0, 80); const value = textOf(m[2].replace(/<[^>]*class="[^"]*tooltip[^"]*"[\s\S]*?<\/[a-z]+>/gi, '')) .replace(/\s*Meer info$/i, '') .slice(0, 500); if (key && value && !(key in out)) out[key] = value; } return out; } const interiorWords: Record<string, string> = { shell: 'kaal', upholstered: 'gestoffeerd', furnished: 'gemeubileerd' }; const firstNumber = (value: LdListing['numberOfRooms']): number | null => { if (typeof value === 'number') return value; const item = Array.isArray(value) ? value[0] : value; return typeof item?.value === 'number' ? item.value : null; }; /** Parse a listing page. Returns null when the page is not a listing. */ export function parseDetailPage(html: string, fallbackUrl?: string): ListingDetail | null { const canonical = /<link\b[^>]*rel="canonical"[^>]*href="([^"]+)"/i.exec(html)?.[1] ?? /<link\b[^>]*href="([^"]+)"[^>]*rel="canonical"/i.exec(html)?.[1]; const ref = listingRef(canonical ?? '') ?? listingRef(fallbackUrl ?? ''); if (!ref) return null; const ld = ldListing(html); const odp = analyticsBlob(html); const features = featureList(html); const summaryTitle = classText(html, 'listing-detail-summary__title'); const location = parseLocation(classText(html, 'listing-detail-summary__location')); const lat = Number( /data-latitude="(-?[\d.]+)"/.exec(html)?.[1] ?? /"latitude"\s*:\s*(-?[\d.]+)/.exec(html)?.[1] ); const lng = Number( /data-longitude="(-?[\d.]+)"/.exec(html)?.[1] ?? /"longitude"\s*:\s*(-?[\d.]+)/.exec(html)?.[1] ); const offeredAt = odp.offered_since && !Number.isNaN(Date.parse(odp.offered_since)) ? new Date(odp.offered_since).toISOString() : ld?.datePosted ? new Date(`${ld.datePosted}T00:00:00Z`).toISOString() : null; const price = (odp.price_pcm ? Number(odp.price_pcm) : null) ?? (typeof ld?.offers?.price === 'number' ? ld.offers.price : null) ?? parseEuros(features['Huurprijs'] ?? features['Vraagprijs'] ?? features['Koopprijs']); const image = Array.isArray(ld?.image) ? ld.image[0] : ld?.image; const typeFromOdp = odp.property ? typeWords[odp.property] : undefined; const interior = (odp.interior ? (interiorWords[odp.interior] ?? odp.interior) : null) ?? features['Interieur']?.toLowerCase() ?? null; const street = ld?.address?.streetAddress ?? null; const title = summaryTitle?.replace(/^Te (huur|koop):\s*/i, '') ?? (ld?.name ? `${ld.name}` : null); return { sourceId: ref.sourceId, url: ref.url, offer: odp.offer === 'buy' || ref.offer === 'buy' ? 'buy' : 'rent', propertyType: typeFromOdp ?? ref.propertyType, externalId: odp.listing_id ?? null, title: title?.slice(0, 200) ?? null, street, postalCode: ld?.address?.postalCode ? ld.address.postalCode.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') : location.postalCode, city: ld?.address?.addressLocality ?? location.city ?? cityFromSlug(ref.citySlug), neighbourhood: ld?.address?.addressRegion ?? location.neighbourhood, priceEur: Number.isFinite(price) ? price : null, areaM2: parseInteger(odp.floor_space) ?? (typeof ld?.floorSize?.value === 'number' ? ld.floorSize.value : null) ?? parseInteger(features['Woonoppervlakte']), rooms: parseInteger(odp.rooms) ?? firstNumber(ld?.numberOfRooms) ?? parseInteger(features['Aantal kamers']), bedrooms: parseInteger(odp.bedrooms) ?? ld?.numberOfBedrooms ?? parseInteger(features['Aantal slaapkamers']), bathrooms: ld?.numberOfBathroomsTotal ?? parseInteger(features['Aantal badkamers']), constructionYear: parseInteger(odp.construction_year) ?? ld?.yearBuilt ?? parseInteger(features['Bouwjaar']), energyLabel: odp.energy_rating ?? features['Energielabel'] ?? null, interior, depositEur: parseEuros(features['Borg']), availableFrom: features['Beschikbaar'] ?? null, contractType: features['Contractvorm'] ?? features['Huurperiode'] ?? null, agent: classText(html, 'agent-card__title'), imageUrl: image ?? null, lat: Number.isFinite(lat) && lat !== 0 ? lat : null, lng: Number.isFinite(lng) && lng !== 0 ? lng : null, offeredAt, description: ld?.description?.slice(0, 6000) ?? null, features }; }