Files
personal-website/src/lib/house-tracker/pararius.ts
Stan Runge 0846cf7d77 feat: house tracker with home worker, alerts and listing map
Collects Pararius rent/buy listings into Postgres via a worker that runs on a
residential connection, sends Discord/ntfy alerts for new listings matching
rules, and shows an admin-only map, market stats and worker log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HfF5tnChi8tkCx9svJP6D
2026-10-11 20:57:20 +00:00

436 lines
14 KiB
TypeScript

// 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<string, PropertyType> = {
appartement: 'apartment',
apartment: 'apartment',
huis: 'house',
house: 'house',
kamer: 'room',
room: 'room',
studio: 'studio'
};
const entities: Record<string, string> = {
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 (
/<title>\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(?:²|&sup2;|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
};
}