houses: nightly Kamernet + Vesteda scraper, dedupe by address, owner-only /trackers/houses
All checks were successful
Deploy production / deploy (push) Successful in 2m44s

- scripts/house-scrape.ts: polite nightly scrape (honest UA, 6-10 s apart, stops a source on 403/challenge), ingest + price history + removals, ntfy alerts on a separate topic (max 5 + summary, first run seeds)
- address_key + duplicate_of: copies on another source link to one listing, manual wins
- /projects/house-tracker -> /trackers/houses (308), owner only (404 otherwise); filters for min m2, rooms, source, Gouda region default; notes, add-by-hand form, total rent, also-on links
- scripts/house-db.sh: run the house-listing CLI as the limited house_cli role
- deploy/systemd/house-scrape.{service,timer}
This commit is contained in:
2026-10-24 14:07:33 +00:00
parent ea7d64a556
commit b51fef14fd
24 changed files with 18142 additions and 35 deletions

View File

@@ -0,0 +1,13 @@
[Unit]
Description=House tracker nightly scrape (Kamernet, Vesteda -> house.listings)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# A checkout of stan/website on main. Not the live dev tree.
WorkingDirectory=%h/dev/website-houses
# DB_URL (house_scraper role: house schema only), optional HOUSE_NTFY_URL; mode 600, never committed.
EnvironmentFile=%h/.config/house-scrape.env
ExecStart=/usr/bin/bun scripts/house-scrape.ts
TimeoutStartSec=900

View File

@@ -0,0 +1,10 @@
[Unit]
Description=House tracker scrape, once a night
[Timer]
OnCalendar=*-*-* 03:40:00 Europe/Amsterdam
RandomizedDelaySec=1200
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -1,7 +1,38 @@
# House tracker
## Nightly scrape on stan-vm (2026-10)
`scripts/house-scrape.ts` runs once a night from a systemd user timer
(`deploy/systemd/house-scrape.{service,timer}`, 03:40 Amsterdam + up to 20 min jitter) and
writes `house.listings` directly with the limited `house_scraper` role
(`~/.config/house-scrape.env`, mode 600). One request at a time, 6–10 s apart, honest
User-Agent, robots.txt-allowed paths only. A 403/429/challenge stops that source for the
night and is logged as `challenged`; nothing is retried or worked around.
| source | how | status |
| ---------------------- | ------------------------------------------------------------- | ------------------------------------ |
| Kamernet | `/huren/huurwoningen-<city>` pages, `__NEXT_DATA__` JSON | scraped (10 cities around Gouda) |
| Vesteda | `POST /api/units/search/facet` (what their site calls), 20 km | scraped; rented units count as gone |
| Pararius | Cloudflare 403 from the Hetzner IP | not scraped here; home worker below |
| huurwoningen.nl | same Cloudflare 403 (Pararius group) | skipped |
| Funda | bot-check page ("Je bent bijna op de pagina…") | skipped |
| Huurstunt | Cloudflare challenge | skipped |
| Mijndak Midden-Holland | OutSystems app behind a login, no public listing feed | skipped; use their own e-mail alerts |
Same home on two sources: `address_key` (street + number + city, normalised, see
`src/lib/house-tracker/address.ts`) links the copy to the original via `duplicate_of`; a
manual entry wins. The page shows one row with "also on …" links. Kamernet never shows house
numbers, so its listings are never merged. Alerts: new non-copy listings matching an alert
rule go to `HOUSE_NTFY_URL` (its own topic, max 5 per night plus one summary); the first run
of a source only seeds.
Manual listings: `scripts/house-listing.ts` (CLI) or the form on the page. Against prod use
`scripts/house-db.sh <command> …`, which runs the CLI as `house_cli` (SELECT/INSERT/UPDATE/
DELETE on house.listings, SELECT/INSERT on house.price_history, nothing else) from
`~/.config/house-cli.env`.
Personal tool: collects Dutch housing listings into Postgres, shows them on
`/projects/house-tracker` (admin only) and sends an alert when a new listing
`/trackers/houses` (owner only, 404 for everyone else; the old URL redirects) and sends an alert when a new listing
matches a rule. Only Pararius for now; other sources plug in the same way.
## How it fits together

View File

@@ -0,0 +1,5 @@
ALTER TABLE "house"."listings" ADD COLUMN "address_key" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "duplicate_of" integer;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "total_price_eur" integer;--> statement-breakpoint
CREATE INDEX "listings_address_idx" ON "house"."listings" ("address_key") WHERE address_key IS NOT NULL;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD CONSTRAINT "listings_duplicate_of_listings_id_fkey" FOREIGN KEY ("duplicate_of") REFERENCES "house"."listings"("id") ON DELETE SET NULL;

File diff suppressed because it is too large Load Diff

12
scripts/house-db.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Run a house-tracker script against prod with the limited house_cli role
# (house.listings + house.price_history only), without the app's credentials.
# scripts/house-db.sh list --city gouda
# scripts/house-db.sh add --street "Raam 12" --city Gouda --price 1475
# Credentials: ~/.config/house-cli.env (DB_URL=..., mode 600, readable by stan only).
set -euo pipefail
cd "$(dirname "$0")/.."
env_file="${HOUSE_CLI_ENV:-$HOME/.config/house-cli.env}"
[ -r "$env_file" ] || { echo "missing $env_file" >&2; exit 1; }
unset DATABASE_URL DB_URL
exec bun --env-file="$env_file" scripts/house-listing.ts "$@"

282
scripts/house-scrape.ts Normal file
View File

@@ -0,0 +1,282 @@
// Nightly house scraper, run on stan-vm by a systemd user timer (deploy/systemd/house-scrape.*).
// Fetches the sources that allow it, writes house.listings directly and pushes an ntfy alert
// for new listings that match an alert rule. See docs/house-tracker.md.
//
// bun --env-file=$HOME/.config/house-scrape.env scripts/house-scrape.ts [--dry-run] [--source kamernet]
//
// Politeness: one request at a time, 6-10 s apart, an honest User-Agent, robots.txt-allowed
// paths only. A 403/429/challenge stops that source for the night; nothing is retried or
// worked around. Funda, Pararius, huurwoningen.nl and Huurstunt challenge this server's
// (datacenter) IP, so they are not scraped from here.
import postgres from 'postgres';
import { alertMessage, matchesRule, type AlertRule } from '../src/lib/house-tracker/alerts';
import { citySlug } from '../src/lib/house-tracker/address';
import { dedupe, ingestListings } from '../src/lib/house-tracker/ingest';
import { kamernetSearchUrl, parseKamernetSearch } from '../src/lib/house-tracker/kamernet';
import { isChallenge } from '../src/lib/house-tracker/pararius';
import type { ScrapedListing } from '../src/lib/house-tracker/scraped';
import {
parseVestedaSearch,
VESTEDA_SEARCH_URL,
vestedaSearchBody
} from '../src/lib/house-tracker/vesteda';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const only = args.includes('--source') ? args[args.indexOf('--source') + 1] : null;
const USER_AGENT =
'stanrunge.dev-house-tracker/1.0 (personal use, one run per night; stan@stanrunge.dev)';
const KAMERNET_CITIES = (
process.env.HOUSE_KAMERNET_CITIES ??
'gouda,waddinxveen,reeuwijk,bodegraven,boskoop,schoonhoven,oudewater,moordrecht,zevenhuizen,nieuwerkerk-aan-den-ijssel'
)
.split(',')
.map((c) => c.trim())
.filter(Boolean);
const KAMERNET_MAX_PAGES = 5;
// Gouda centre; Vesteda's own search uses km radius around a point.
const VESTEDA = {
lat: 52.0115,
lng: 4.7105,
radiusKm: Number(process.env.HOUSE_VESTEDA_RADIUS_KM ?? 20),
place: 'Gouda'
};
const MAX_ALERTS = 5;
const dbUrl = process.env.DB_URL ?? process.env.DATABASE_URL;
if (!dbUrl && !dryRun) throw new Error('DB_URL is not set');
const sql = dbUrl ? postgres(dbUrl, { max: 1, connect_timeout: 10, onnotice: () => {} }) : null;
class Blocked extends Error {
constructor(
public status: number,
public url: string
) {
super(`blocked (${status}) on ${url}`);
}
}
let lastRequest = 0;
async function politeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const wait = lastRequest + 6000 + Math.random() * 4000 - Date.now();
if (wait > 0) await Bun.sleep(wait);
lastRequest = Date.now();
const response = await fetch(url, {
...init,
redirect: 'follow',
signal: AbortSignal.timeout(30_000),
headers: {
'user-agent': USER_AGENT,
'accept-language': 'nl-NL,nl;q=0.9,en;q=0.5',
...(init.headers ?? {})
}
});
return response;
}
async function logRun(
source: string,
url: string,
status: string,
extra: { httpStatus?: number; found?: number; durationMs?: number; message?: string } = {}
) {
console.log(
`${source} ${status}${extra.httpStatus ? ` ${extra.httpStatus}` : ''} ${extra.found ?? ''} ${url}${extra.message ? ` (${extra.message})` : ''}`
);
if (!sql || dryRun) return;
await sql`INSERT INTO house.worker_runs (source, lane, url, status, http_status, found, duration_ms, message)
VALUES (${source}, 'nightly', ${url}, ${status}, ${extra.httpStatus ?? null}, ${extra.found ?? null},
${extra.durationMs ?? null}, ${extra.message?.slice(0, 500) ?? null})`;
}
type SourceResult = { listings: ScrapedListing[]; completed: boolean };
async function scrapeKamernet(): Promise<SourceResult> {
const listings: ScrapedListing[] = [];
let completed = true;
for (const city of KAMERNET_CITIES) {
for (let page = 1; page <= KAMERNET_MAX_PAGES; page++) {
const url = kamernetSearchUrl(city, page);
const started = Date.now();
const response = await politeFetch(url, { headers: { accept: 'text/html' } });
const html = await response.text();
if (isChallenge(html, response.status)) {
await logRun('kamernet', url, 'challenged', { httpStatus: response.status });
throw new Blocked(response.status, url);
}
if (response.status === 404) {
// Kamernet has no page for some small places; that is not an error.
await logRun('kamernet', url, 'ok', {
httpStatus: 404,
found: 0,
message: 'no such city page'
});
break;
}
if (!response.ok) {
await logRun('kamernet', url, 'http-error', { httpStatus: response.status });
completed = false;
break;
}
try {
const result = parseKamernetSearch(html);
// Kamernet widens the search to nearby places; keep each city's own listings once.
listings.push(...result.listings);
await logRun('kamernet', url, 'ok', {
httpStatus: 200,
found: result.listings.length,
durationMs: Date.now() - started
});
const pages = result.total && result.perPage ? Math.ceil(result.total / result.perPage) : 1;
if (page >= pages) break;
} catch (error) {
await logRun('kamernet', url, 'parse-error', { httpStatus: 200, message: String(error) });
completed = false;
break;
}
}
}
return { listings, completed };
}
async function scrapeVesteda(): Promise<SourceResult> {
const started = Date.now();
const response = await politeFetch(VESTEDA_SEARCH_URL, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(vestedaSearchBody(VESTEDA))
});
const text = await response.text();
if (isChallenge(text, response.status)) {
await logRun('vesteda', VESTEDA_SEARCH_URL, 'challenged', { httpStatus: response.status });
throw new Blocked(response.status, VESTEDA_SEARCH_URL);
}
if (!response.ok) {
await logRun('vesteda', VESTEDA_SEARCH_URL, 'http-error', { httpStatus: response.status });
return { listings: [], completed: false };
}
try {
const listings = parseVestedaSearch(JSON.parse(text));
await logRun('vesteda', VESTEDA_SEARCH_URL, 'ok', {
httpStatus: 200,
found: listings.length,
durationMs: Date.now() - started
});
return { listings, completed: true };
} catch (error) {
await logRun('vesteda', VESTEDA_SEARCH_URL, 'parse-error', {
httpStatus: 200,
message: String(error)
});
return { listings: [], completed: false };
}
}
async function ntfy(title: string, body: string, click: string) {
const target = process.env.HOUSE_NTFY_URL;
if (!target) return false;
const url = new URL(target);
const response = await fetch(url.origin, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(process.env.HOUSE_NTFY_TOKEN
? { authorization: `Bearer ${process.env.HOUSE_NTFY_TOKEN}` }
: {})
},
body: JSON.stringify({
topic: url.pathname.replace(/^\/+|\/+$/g, ''),
title,
message: body,
click,
tags: ['house'],
priority: 3
}),
signal: AbortSignal.timeout(10_000)
}).catch(() => null);
return Boolean(response?.ok);
}
const sources: Record<string, () => Promise<SourceResult>> = {
kamernet: scrapeKamernet,
vesteda: scrapeVesteda
};
const fresh: { id: number; source: string; listing: ScrapedListing }[] = [];
let failures = 0;
try {
for (const [name, scrape] of Object.entries(sources)) {
if (only && only !== name) continue;
const runStartedAt = new Date();
let result: SourceResult;
try {
result = await scrape();
} catch (error) {
failures++;
console.error(
`${name}: ${error instanceof Error ? error.message : error}; skipped until tomorrow`
);
continue;
}
if (dryRun || !sql) {
for (const l of result.listings)
console.log(
` ${l.sourceId} €${l.priceEur ?? '?'} ${l.areaM2 ?? '?'}m² ${l.title} (${l.city}) ${l.url}`
);
continue;
}
// An empty result from a "complete" run is more likely a broken page than an empty market.
const completed = result.completed && result.listings.length > 0;
const ingest = await ingestListings(sql, name, result.listings, { runStartedAt, completed });
console.log(
`${name}: ${result.listings.length} seen, ${ingest.inserted.length} new${ingest.seeded ? ' (first run: seeded, no alerts)' : ''}, ${ingest.updated} updated, ${ingest.removed} gone`
);
if (!ingest.seeded) for (const row of ingest.inserted) fresh.push({ ...row, source: name });
}
if (sql && !dryRun) {
const { keyed, copies } = await dedupe(sql);
console.log(`dedupe: ${keyed} new address keys, ${copies} listings are copies of another`);
const rules =
(await sql`SELECT id, name, enabled, offer, min_price AS "minPrice", max_price AS "maxPrice",
min_area AS "minArea", min_bedrooms AS "minBedrooms", cities, property_types AS "propertyTypes"
FROM house.alert_rules WHERE enabled`) as unknown as AlertRule[];
const copyIds = new Set(
fresh.length
? (
await sql<
{ id: number }[]
>`SELECT id FROM house.listings WHERE duplicate_of IS NOT NULL AND id IN ${sql(fresh.map((f) => f.id))}`
).map((r) => r.id)
: []
);
const matches = fresh.flatMap((f) => {
if (copyIds.has(f.id)) return [];
const listing = { ...f.listing, citySlug: citySlug(f.listing.city), source: f.source };
const rule = rules.find((r) => matchesRule(listing, r));
return rule ? [{ ...f, message: alertMessage(listing, rule) }] : [];
});
let sent = 0;
for (const match of matches.slice(0, MAX_ALERTS)) {
if (await ntfy(match.message.title, match.message.body, match.message.url)) {
sent++;
await sql`UPDATE house.listings SET alerted_at = now() WHERE id = ${match.id}`;
}
}
if (matches.length > MAX_ALERTS)
await ntfy(
`🏠 ${matches.length - MAX_ALERTS} more new listings`,
'See the house tracker.',
'https://stanrunge.dev/trackers/houses?status='
);
console.log(
`alerts: ${matches.length} new matches, ${sent} sent${process.env.HOUSE_NTFY_URL ? '' : ' (HOUSE_NTFY_URL not set)'}`
);
}
} finally {
await sql?.end();
}
// Non-zero so systemd shows the unit as failed when a source broke or blocked us.
if (failures > 0) process.exitCode = 1;

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { addressKey, citySlug, normalizeHouseNumber, splitAddress } from './address';
describe('address keys', () => {
it('matches abbreviations and number formats across sources', () => {
const manual = splitAddress('Burg. van Dijkesingel 214-B');
expect(manual).toEqual({ street: 'Burg. van Dijkesingel', houseNumber: '214-B' });
const a = addressKey(manual.street, manual.houseNumber, 'Gouda');
const b = addressKey('Burgemeester Van Dijkesingel', '214 B', 'GOUDA');
expect(a).toBe('burgemeester van dijkesingel|214b|gouda');
expect(b).toBe(a);
});
it('keeps unit numbers apart', () => {
expect(normalizeHouseNumber('1-206')).toBe('1-206');
expect(normalizeHouseNumber('16')).toBe('16');
expect(normalizeHouseNumber('16a')).toBe('16a');
expect(splitAddress('Noothoven van Goorstraat 1-206').houseNumber).toBe('1-206');
});
it('has no key without a house number', () => {
expect(addressKey('Raam', null, 'Gouda')).toBeNull();
expect(splitAddress('Raam')).toEqual({ street: 'Raam', houseNumber: null });
});
it('slugs city names', () => {
expect(citySlug('Nieuwerkerk a/d IJssel')).toBe('nieuwerkerk-aan-den-ijssel');
expect(citySlug('Capelle Aan Den Ijssel')).toBe('capelle-aan-den-ijssel');
expect(citySlug('Alphen aan den Rijn')).toBe('alphen-aan-den-rijn');
});
});

View File

@@ -0,0 +1,104 @@
// Address normalisation for de-duplicating the same home across sources. Pure, no imports
// from #lib or $app: the nightly scraper runs this with plain bun.
const abbreviations: [RegExp, string][] = [
[/\bburg\b\.?/g, 'burgemeester'],
[/\bmr\b\.?/g, 'meester'],
[/\bdr\b\.?/g, 'dokter'],
[/\bprof\b\.?/g, 'professor'],
[/\bst\b\.?/g, 'sint'],
[/\bpr\b\.?/g, 'prins'],
[/\bkon\b\.?/g, 'koningin'],
[/\bv\b\.?/g, 'van']
];
function plain(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/** "Nieuwerkerk a/d IJssel" -> "nieuwerkerk-aan-den-ijssel", "Capelle Aan Den Ijssel" -> "capelle-aan-den-ijssel". */
export function citySlug(city: string | null | undefined): string | null {
if (!city) return null;
const slug = plain(city)
.replace(/\ba\s*\/\s*d\b/g, 'aan den')
.replace(/\ba\s*d\b(?=\s)/g, 'aan den')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || null;
}
export function normalizeStreet(street: string): string {
let s = plain(street).replace(/[.,'’]/g, ' ');
for (const [pattern, full] of abbreviations) s = s.replace(pattern, full);
return s
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** "214-B", "214 b", "214B" -> "214b"; "1-206" stays "1-206" (number + unit). */
export function normalizeHouseNumber(value: string): string | null {
const s = plain(value).replace(/\s+/g, '');
const match = /^(\d+)(?:[-/]?([a-z]{1,3}|\d{1,4}))?/.exec(s);
if (!match) return null;
const [, number, addition] = match;
if (!addition) return number;
return /^\d+$/.test(addition) ? `${number}-${addition}` : `${number}${addition}`;
}
/** Split "Burg. van Dijkesingel 214-B" into street and number. */
export function splitAddress(address: string): { street: string; houseNumber: string | null } {
const match = /^(.*?\D)\s+(\d+(?:\s*[-/]?\s*[a-z0-9]{1,4})?)\s*$/i.exec(address.trim());
if (!match) return { street: address.trim(), houseNumber: null };
return { street: match[1].trim(), houseNumber: match[2].replace(/\s+/g, '') };
}
/**
* Key for "is this the same home": street + house number (+ addition) + city. Null when the
* number is unknown, because "Raam, Gouda" alone can be dozens of different homes.
*/
export function addressKey(
street: string | null | undefined,
houseNumber: string | null | undefined,
city: string | null | undefined
): string | null {
if (!street || !houseNumber || !city) return null;
const number = normalizeHouseNumber(houseNumber);
const slug = citySlug(city);
if (!number || !slug) return null;
return `${normalizeStreet(street)}|${number}|${slug}`;
}
/**
* Marks copies: for every address key, the manual entry (else the oldest row) is the
* original; all other rows with that key point at it. Rows whose key went away are reset.
* Two statements, run one after the other.
*/
export const DEDUPE_SQL = [
`WITH canon AS (
SELECT DISTINCT ON (address_key) address_key, id
FROM house.listings
WHERE address_key IS NOT NULL
ORDER BY address_key, (source = 'manual') DESC, id
)
UPDATE house.listings l
SET duplicate_of = CASE WHEN l.id = canon.id THEN NULL ELSE canon.id END
FROM canon
WHERE l.address_key = canon.address_key
AND l.duplicate_of IS DISTINCT FROM (CASE WHEN l.id = canon.id THEN NULL ELSE canon.id END)`,
`UPDATE house.listings SET duplicate_of = NULL
WHERE duplicate_of IS NOT NULL AND address_key IS NULL`
] as const;
/** Address key from free text such as a street column ("Raam 12") or a manual title. */
export function addressKeyFromText(
text: string | null | undefined,
city: string | null | undefined
) {
if (!text) return null;
const { street, houseNumber } = splitAddress(text.replace(/,.*$/, ''));
return addressKey(street, houseNumber, city);
}

View File

@@ -0,0 +1 @@
<!DOCTYPE html><html lang="nl"><head><title>Huurwoningen Gouda | Kamernet</title></head><body><div id="__next"></div><script id="__NEXT_DATA__" type="application/json">{"props": {"pageProps": {"targetPageProps": {"allFilters": {"pageNo": 1, "listingsPerPage": 17, "citySlug": "gouda"}, "findListingsResponse": {"listings": [{"thumbnailUrl": "https://resources.kamernet.nl/image/a63a8074-912f-4d12-8adf-898e90fced74", "fullPreviewImageUrl": "https://resources.kamernet.nl/image/9cb196e6-987f-495d-b91b-47b174e901b9", "resizedFullPreviewImageUrl": "https://resources.kamernet.nl/image/9cb196e6-987f-495d-b91b-47b174e901b9/resize/422-225", "listingId": 2413752, "isTopAdvert": false, "isReactForFree": false, "furnishingId": 1, "availabilityStartDate": "2026-11-23T00:00:00", "availabilityEndDate": null, "street": "Burgemeester Martenssingel", "streetSlug": "burgemeester-martenssingel", "city": "Gouda", "citySlug": "gouda", "isFavorite": false, "surfaceArea": 26, "listingType": 4, "totalRentalPrice": 1000, "utilitiesIncluded": true, "isNewAdvert": true, "studentHouseId": null, "isStudentHouseAdvert": false, "cityId": 315}, {"thumbnailUrl": "https://resources.kamernet.nl/image/49d41e67-0e28-44ae-89e3-957bcd51db17", "fullPreviewImageUrl": "https://resources.kamernet.nl/image/756e1505-708c-43ac-a722-90231c322e33", "resizedFullPreviewImageUrl": "https://resources.kamernet.nl/image/756e1505-708c-43ac-a722-90231c322e33/resize/422-225", "listingId": 2413119, "isTopAdvert": false, "isReactForFree": false, "furnishingId": 4, "availabilityStartDate": "2026-10-22T00:00:00", "availabilityEndDate": "2027-01-31T00:00:00", "street": "Schoutendreef", "streetSlug": "schoutendreef", "city": "Bodegraven", "citySlug": "bodegraven", "isFavorite": false, "surfaceArea": 12, "listingType": 1, "totalRentalPrice": 450, "utilitiesIncluded": true, "isNewAdvert": false, "studentHouseId": null, "isStudentHouseAdvert": false, "cityId": 278}, {"thumbnailUrl": "https://resources.kamernet.nl/image/24aa5dad-1ad4-42af-abd7-6b1a5b244244", "fullPreviewImageUrl": "https://resources.kamernet.nl/image/434d606f-605c-4528-b940-758c9542eb2c", "resizedFullPreviewImageUrl": "https://resources.kamernet.nl/image/434d606f-605c-4528-b940-758c9542eb2c/resize/422-225", "listingId": 2413418, "isTopAdvert": false, "isReactForFree": false, "furnishingId": 1, "availabilityStartDate": "2026-10-22T00:00:00", "availabilityEndDate": "2027-05-01T00:00:00", "street": "H.J.A.M. Schaepmanstraat", "streetSlug": "hjam-schaepmanstraat", "city": "Gouda", "citySlug": "gouda", "isFavorite": false, "surfaceArea": 16, "listingType": 1, "totalRentalPrice": 700, "utilitiesIncluded": true, "isNewAdvert": false, "studentHouseId": null, "isStudentHouseAdvert": false, "cityId": 315}, {"thumbnailUrl": "https://resources.kamernet.nl/image/8b39f2c3-e997-446e-b308-ccfeefa27ad9", "fullPreviewImageUrl": "https://resources.kamernet.nl/image/865d50a0-47be-4248-99a4-d9c646f6f535", "resizedFullPreviewImageUrl": "https://resources.kamernet.nl/image/865d50a0-47be-4248-99a4-d9c646f6f535/resize/422-225", "listingId": 2413374, "isTopAdvert": false, "isReactForFree": false, "furnishingId": 4, "availabilityStartDate": "2026-11-01T00:00:00", "availabilityEndDate": "2027-11-01T00:00:00", "street": "P.C. Bothstraat", "streetSlug": "pc-bothstraat", "city": "Gouda", "citySlug": "gouda", "isFavorite": false, "surfaceArea": 15, "listingType": 1, "totalRentalPrice": 700, "utilitiesIncluded": true, "isNewAdvert": false, "studentHouseId": null, "isStudentHouseAdvert": false, "cityId": 315}], "total": 9}}}}, "page": "/for-rent/[searchCategoryAndCity]", "query": {"searchCategoryAndCity": "huurwoningen-gouda"}}</script></body></html>

View File

@@ -0,0 +1,190 @@
{
"results": {
"objects": [
{
"id": 180703,
"code": null,
"vgoNumber": "VGO0039253",
"street": "Burgemeester Van Dijkesingel",
"houseNumber": "133",
"houseNumberAddition": null,
"postalCode": "2809RB",
"city": "Gouda",
"complex": "Westergouwe",
"complexId": 1084,
"size": 122,
"priceUnformatted": 1840.0,
"price": "\u20ac 1840,-",
"numberOfBedRooms": 3,
"latitude": 52.00515,
"longitude": 4.676554,
"imageSmall": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/bb/bb163ed427176aa2a2ee67e787ceac15/o/01.jpg?rmode=crop&width=285&height=138&hmac=aa7d7722e40664b531084c35436529bdf4e6fb42097d5e9b88703e3bf26da2e3",
"imageBig": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/bb/bb163ed427176aa2a2ee67e787ceac15/o/01.jpg?rmode=crop&width=570&height=276&hmac=1f9433d024630b48be34533abecbde50374f09a29c6ab100d3ddea791c54cedc",
"subImages": null,
"district": null,
"location": "Gouda, Zuid-west",
"status": 1,
"alt": "01.jpg",
"url": "/nl/westergouwe/burgemeester-van-dijkesingel-133-gouda-180703",
"upcomingeventdate": null,
"suitedForHomeSharers": false,
"ageFrom": 0,
"onlySixtyFivePlus": false,
"prioritizeKeyProfessions": false,
"onlyMiddleRent": false,
"entitytypeid": 1,
"entitysubtypeid": 1,
"entitysubtypelabel": "Eengezinswoning",
"entityguid": "8bb3e01c0f666d83179a4155704249bd",
"complexguid": "679caab4522de3f9df0d9a2938dd45c2",
"complexTopComplex": false,
"complexPeriodicallyAvailable": false,
"complexPriceMin": 0,
"complexPriceMax": 0,
"complexManuallyAdded": false,
"priorityArrangement": null,
"isExclusive": null,
"features": null
},
{
"id": 50503,
"code": null,
"vgoNumber": "VGO0041768",
"street": "Jozefhof",
"houseNumber": "123",
"houseNumberAddition": null,
"postalCode": "2805PX",
"city": "Gouda",
"complex": "Goudshof",
"complexId": 1111,
"size": 81,
"priceUnformatted": 1350.0,
"price": "\u20ac 1350,-",
"numberOfBedRooms": 2,
"latitude": 52.01952,
"longitude": 4.714698,
"imageSmall": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/b5/b54175d6c8e11196a40647b89b6c7062/o/jozefhof-305-02.jpg?rmode=crop&width=285&height=138&hmac=44381f8dbcb1165d08d16065c8dcac889c632d299bb4c70eeb9b1bf9d9f54a1c",
"imageBig": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/b5/b54175d6c8e11196a40647b89b6c7062/o/jozefhof-305-02.jpg?rmode=crop&width=570&height=276&hmac=78bef49237059606f93a676383c124d75518575265ce291188811b9a7e53f255",
"subImages": null,
"district": null,
"location": "Gouda, Zuid-west",
"status": 3,
"alt": "jozefhof-305-02.jpg",
"url": "/nl/goudshof/jozefhof-123-gouda-50503",
"upcomingeventdate": null,
"suitedForHomeSharers": false,
"ageFrom": 0,
"onlySixtyFivePlus": false,
"prioritizeKeyProfessions": false,
"onlyMiddleRent": false,
"entitytypeid": 1,
"entitysubtypeid": 2,
"entitysubtypelabel": "Appartement",
"entityguid": "cb39d0883a5e9bed32c505ab3b2ebdc9",
"complexguid": "cd43201a0ce690e02f6c0ed5c4e62329",
"complexTopComplex": false,
"complexPeriodicallyAvailable": false,
"complexPriceMin": 0,
"complexPriceMax": 0,
"complexManuallyAdded": false,
"priorityArrangement": null,
"isExclusive": null,
"features": null
},
{
"id": 186080,
"code": null,
"vgoNumber": "VGO0043608",
"street": "Grote Beer",
"houseNumber": "13",
"houseNumberAddition": "A-17",
"postalCode": "3067TR",
"city": "Rotterdam",
"complex": "Grote Beer",
"complexId": 3992,
"size": 89,
"priceUnformatted": 1715.0,
"price": "\u20ac 1715,-",
"numberOfBedRooms": 2,
"latitude": 51.949135,
"longitude": 4.555593,
"imageSmall": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/e7/e7daf84af0fee447527762f43aaa9d5e/o/01.jpg?rmode=crop&width=285&height=138&hmac=9e1210e6efa1673aac5e9d8afc05d70bee952644f6f12f31137d7baccda7c7e0",
"imageBig": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/e7/e7daf84af0fee447527762f43aaa9d5e/o/01.jpg?rmode=crop&width=570&height=276&hmac=b184dd1372d52b98dfd14f0517e11acaf737d3ed388a84a1763ee3e7d929d3a9",
"subImages": null,
"district": null,
"location": "Rotterdam, Zuid-west",
"status": 3,
"alt": "01.jpg",
"url": "/nl/huurwoningen-rotterdam/grote-beer/grote-beer-13a-17-rotterdam-186080",
"upcomingeventdate": null,
"suitedForHomeSharers": false,
"ageFrom": 0,
"onlySixtyFivePlus": false,
"prioritizeKeyProfessions": false,
"onlyMiddleRent": false,
"entitytypeid": 1,
"entitysubtypeid": 2,
"entitysubtypelabel": "Appartement",
"entityguid": "d804fa9630e12d81157f02c2cfe3cb6d",
"complexguid": "eee8500ca94a555eb096ad955a5211f2",
"complexTopComplex": false,
"complexPeriodicallyAvailable": false,
"complexPriceMin": 0,
"complexPriceMax": 0,
"complexManuallyAdded": false,
"priorityArrangement": null,
"isExclusive": null,
"features": null
},
{
"id": 38568,
"code": null,
"vgoNumber": "VGO0022549",
"street": "Wijde Wormer",
"houseNumber": "258",
"houseNumberAddition": null,
"postalCode": "2905TZ",
"city": "Capelle Aan Den Ijssel",
"complex": "Wijde Wormer",
"complexId": 789,
"size": 89,
"priceUnformatted": 1335.0,
"price": "\u20ac 1335,-",
"numberOfBedRooms": 1,
"latitude": 51.94391,
"longitude": 4.603301,
"imageSmall": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/b6/b6ad9be5e74e91259171847e77998922/o/15766-wijde-wormer-capelle-ad-ijssel-1-.jpg?rmode=crop&width=285&height=138&hmac=103847240a4e830421ae06bdb3736d71e840581af9687d14280055bd5f52abcd",
"imageBig": "https://vst-web-prod-cdn-remote-media-endpoint-a4eufkf7anbjhkab.z03.azurefd.net/verhuuroffice.nl/media/b6/b6ad9be5e74e91259171847e77998922/o/15766-wijde-wormer-capelle-ad-ijssel-1-.jpg?rmode=crop&width=570&height=276&hmac=277f47bc35200032906b35cb42d979768c35d3c861903cc559dab2e4bc91519f",
"subImages": null,
"district": null,
"location": "Capelle Aan Den Ijssel, Zuid-west",
"status": 2,
"alt": "15766-wijde-wormer-capelle-ad-ijssel-1-.jpg",
"url": "/nl/huurwoningen-capelle-aan-den-ijssel/wijde-wormer/wijde-wormer-258-capelle-aan-den-ijssel-38568",
"upcomingeventdate": null,
"suitedForHomeSharers": false,
"ageFrom": 0,
"onlySixtyFivePlus": false,
"prioritizeKeyProfessions": false,
"onlyMiddleRent": false,
"entitytypeid": 1,
"entitysubtypeid": 2,
"entitysubtypelabel": "Appartement",
"entityguid": "04266b6d1e2dec652c42344ea580f1e3",
"complexguid": "42b912d58daf28809cac51ae25db6dd4",
"complexTopComplex": false,
"complexPeriodicallyAvailable": false,
"complexPriceMin": 0,
"complexPriceMax": 0,
"complexManuallyAdded": false,
"priorityArrangement": false,
"isExclusive": null,
"features": null
}
],
"objectsCount": 4,
"complexes": [],
"newComplexes": []
},
"count": 4
}

View File

@@ -0,0 +1,124 @@
// Writes scraped listings into house.listings with plain postgres.js, for the nightly
// scraper on stan-vm (scripts/house-scrape.ts). No #lib / $app imports.
import type postgres from 'postgres';
import { addressKey, addressKeyFromText, citySlug, DEDUPE_SQL } from './address';
import type { ScrapedListing } from './scraped';
type Sql = postgres.Sql;
export type IngestResult = {
seeded: boolean;
inserted: { id: number; listing: ScrapedListing }[];
updated: number;
removed: number;
};
export async function ingestListings(
sql: Sql,
source: string,
listings: ScrapedListing[],
opts: { runStartedAt: Date; completed: boolean }
): Promise<IngestResult> {
// The same listing can show up on two city pages; keep the first.
const unique = [...new Map(listings.map((l) => [l.sourceId, l])).values()];
return sql.begin(async (tx) => {
const [any] = await tx`SELECT 1 FROM house.listings WHERE source = ${source} LIMIT 1`;
const seeded = !any;
const ids = unique.map((l) => l.sourceId);
const existing = ids.length
? await tx<{ id: number; source_id: string; price_eur: number | null }[]>`
SELECT id, source_id, price_eur FROM house.listings
WHERE source = ${source} AND source_id IN ${tx(ids)}`
: [];
const known = new Map(existing.map((row) => [row.source_id, row]));
const inserted: IngestResult['inserted'] = [];
let updated = 0;
for (const l of unique) {
const columns = {
url: l.url,
title: l.title,
street: l.houseNumber ? `${l.street ?? ''} ${l.houseNumber}`.trim() : l.street,
postal_code: l.postalCode,
city: l.city,
city_slug: citySlug(l.city),
price_eur: l.priceEur,
total_price_eur: l.totalPriceEur,
area_m2: l.areaM2,
rooms: l.rooms,
bedrooms: l.bedrooms,
energy_label: l.energyLabel,
available_from: l.availableFrom,
interior: l.interior,
agent: l.agent,
image_url: l.imageUrl,
lat: l.lat,
lng: l.lng,
features: tx.json(l.features),
address_key: addressKey(l.street, l.houseNumber, l.city)
};
const previous = known.get(l.sourceId);
if (previous) {
await tx`UPDATE house.listings SET ${tx(columns)}, last_seen_at = now(), removed_at = NULL
WHERE id = ${previous.id}`;
if (l.priceEur != null && l.priceEur !== previous.price_eur)
await tx`INSERT INTO house.price_history (listing_id, price_eur) VALUES (${previous.id}, ${l.priceEur})`;
updated++;
continue;
}
const [row] = await tx<{ id: number }[]>`INSERT INTO house.listings ${tx({
...columns,
source,
source_id: l.sourceId,
offer: l.offer,
property_type: l.propertyType,
first_seen_lane: seeded ? 'seed' : 'nightly',
// Everything the source offers is already in the search result.
detail_fetched_at: new Date()
})} ON CONFLICT (source, source_id) DO NOTHING RETURNING id`;
if (!row) continue;
if (l.priceEur != null)
await tx`INSERT INTO house.price_history (listing_id, price_eur) VALUES (${row.id}, ${l.priceEur})`;
inserted.push({ id: row.id, listing: l });
}
let removed = 0;
if (opts.completed) {
const gone = await tx`UPDATE house.listings SET removed_at = now()
WHERE source = ${source} AND removed_at IS NULL AND last_seen_at < ${opts.runStartedAt}
RETURNING id`;
removed = gone.length;
}
return { seeded, inserted, updated, removed };
});
}
/**
* Fills address_key for rows written elsewhere (manual CLI, Pararius worker) from their
* street or title, then marks cross-source copies. Safe to run any time.
*/
export async function dedupe(sql: Sql): Promise<{ keyed: number; copies: number }> {
const rows = await sql<
{
id: number;
source: string;
street: string | null;
title: string | null;
city: string | null;
}[]
>`SELECT id, source, street, title, city FROM house.listings
WHERE address_key IS NULL AND (street IS NOT NULL OR source = 'manual')`;
let keyed = 0;
for (const row of rows) {
const key =
addressKeyFromText(row.street, row.city) ??
(row.source === 'manual' ? addressKeyFromText(row.title, row.city) : null);
if (!key) continue;
await sql`UPDATE house.listings SET address_key = ${key} WHERE id = ${row.id}`;
keyed++;
}
for (const statement of DEDUPE_SQL) await sql.unsafe(statement);
const [{ copies }] = await sql<{ copies: number }[]>`
SELECT count(*)::int AS copies FROM house.listings WHERE duplicate_of IS NOT NULL`;
return { keyed, copies };
}

View File

@@ -0,0 +1,81 @@
// Kamernet search pages (kamernet.nl/huren/huurwoningen-<city>). The page is server-rendered
// Next.js; the listings sit in the __NEXT_DATA__ JSON, so no HTML scraping is needed.
// /huren/ is allowed in Kamernet's robots.txt.
import { int, str, type ScrapedListing, type SearchPage } from './scraped';
import type { PropertyType } from './types';
export const KAMERNET_ORIGIN = 'https://kamernet.nl';
// listingType -> (URL word, our type). Seen on the site: 1 kamer, 2 appartement, 4 studio.
const types: Record<number, [string, PropertyType]> = {
1: ['kamer', 'room'],
2: ['appartement', 'apartment'],
4: ['studio', 'studio'],
8: ['anti-kraak', 'other']
};
const furnishing: Record<number, string> = { 1: 'kaal', 2: 'gestoffeerd', 4: 'gemeubileerd' };
export function kamernetSearchUrl(citySlug: string, page = 1): string {
const url = `${KAMERNET_ORIGIN}/huren/huurwoningen-${citySlug}`;
return page > 1 ? `${url}?pageNo=${page}` : url;
}
type RawListing = Record<string, unknown>;
export function parseKamernetSearch(html: string): SearchPage {
const match = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(html);
if (!match) throw new Error('kamernet: no __NEXT_DATA__ on the page');
const data = JSON.parse(match[1]);
const props = data?.props?.pageProps?.targetPageProps;
const response = props?.findListingsResponse;
if (!response || !Array.isArray(response.listings))
throw new Error('kamernet: findListingsResponse.listings missing');
return {
listings: (response.listings as RawListing[]).map(toListing).filter((l) => l != null),
total: int(response.total),
perPage: int(props?.allFilters?.listingsPerPage)
};
}
function toListing(raw: RawListing): ScrapedListing | null {
const id = int(raw.listingId);
const citySlug = str(raw.citySlug, 80);
const streetSlug = str(raw.streetSlug, 120);
if (!id || !citySlug) return null;
const [word, propertyType] = types[Number(raw.listingType)] ?? ['huurwoning', 'other'];
const url = streetSlug
? `${KAMERNET_ORIGIN}/huren/${word}-${citySlug}/${streetSlug}/${word}-${id}`
: `${KAMERNET_ORIGIN}/huren/huurwoningen-${citySlug}`;
const street = str(raw.street);
const price = int(raw.totalRentalPrice);
const start = str(raw.availabilityStartDate, 40)?.slice(0, 10) ?? null;
const end = str(raw.availabilityEndDate, 40)?.slice(0, 10) ?? null;
const features: Record<string, string> = {};
if (end) features['available until'] = end;
if (raw.utilitiesIncluded === true) features['utilities'] = 'included';
if (raw.isStudentHouseAdvert === true) features['student house'] = 'yes';
return {
sourceId: String(id),
url,
offer: 'rent',
propertyType,
title: [word[0].toUpperCase() + word.slice(1), street].filter(Boolean).join(' '),
street,
houseNumber: null, // Kamernet never shows house numbers on search pages.
postalCode: null,
city: str(raw.city, 80),
priceEur: price,
totalPriceEur: raw.utilitiesIncluded === true ? price : null,
areaM2: int(raw.surfaceArea),
rooms: propertyType === 'room' || propertyType === 'studio' ? 1 : null,
bedrooms: null,
energyLabel: null,
availableFrom: start,
interior: furnishing[Number(raw.furnishingId)] ?? null,
agent: null,
imageUrl: str(raw.resizedFullPreviewImageUrl, 1000) ?? str(raw.thumbnailUrl, 1000),
lat: null,
lng: null,
features
};
}

View File

@@ -0,0 +1,43 @@
// What the nightly scraper (scripts/house-scrape.ts) stores per listing. Pure types and
// helpers, no #lib / $app imports.
import type { PropertyType } from './types';
export type ScrapedListing = {
sourceId: string;
url: string;
offer: 'rent' | 'buy';
propertyType: PropertyType;
title: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
priceEur: number | null;
/** Rent including service costs / utilities, when the source says so. */
totalPriceEur: number | null;
areaM2: number | null;
rooms: number | null;
bedrooms: number | null;
energyLabel: string | null;
availableFrom: string | null;
interior: string | null;
agent: string | null;
imageUrl: string | null;
lat: number | null;
lng: number | null;
features: Record<string, string>;
};
export type SearchPage = {
listings: ScrapedListing[];
total: number | null;
perPage: number | null;
};
export const int = (value: unknown): number | null => {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
};
export const str = (value: unknown, max = 200): string | null =>
typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null;

View File

@@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { kamernetSearchUrl, parseKamernetSearch } from './kamernet';
import { parseVestedaSearch } from './vesteda';
const fixture = (name: string) =>
readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8');
describe('kamernet', () => {
it('reads listings from __NEXT_DATA__', () => {
const page = parseKamernetSearch(fixture('kamernet-search-gouda.html'));
expect(page.total).toBe(9);
expect(page.perPage).toBe(17);
expect(page.listings).toHaveLength(4);
const [studio, room] = page.listings;
expect(studio).toMatchObject({
sourceId: '2413752',
url: 'https://kamernet.nl/huren/studio-gouda/burgemeester-martenssingel/studio-2413752',
propertyType: 'studio',
city: 'Gouda',
priceEur: 1000,
totalPriceEur: 1000,
areaM2: 26,
interior: 'kaal',
availableFrom: '2026-11-23'
});
expect(room).toMatchObject({
propertyType: 'room',
city: 'Bodegraven',
interior: 'gemeubileerd'
});
expect(room.features['available until']).toBe('2027-01-31');
});
it('builds paged search urls', () => {
expect(kamernetSearchUrl('gouda')).toBe('https://kamernet.nl/huren/huurwoningen-gouda');
expect(kamernetSearchUrl('gouda', 2)).toBe(
'https://kamernet.nl/huren/huurwoningen-gouda?pageNo=2'
);
});
it('fails loudly on a page without data', () => {
expect(() => parseKamernetSearch('<html></html>')).toThrow(/__NEXT_DATA__/);
});
});
describe('vesteda', () => {
it('keeps available units with full addresses, skips rented ones', () => {
const listings = parseVestedaSearch(JSON.parse(fixture('vesteda-search-gouda.json')));
// Jozefhof (3) and Wijde Wormer (2) are rented.
expect(listings.map((l) => l.sourceId)).toEqual(['180703']);
expect(listings[0]).toMatchObject({
url: 'https://www.vesteda.com/nl/westergouwe/burgemeester-van-dijkesingel-133-gouda-180703',
street: 'Burgemeester Van Dijkesingel',
houseNumber: '133',
postalCode: '2809 RB',
city: 'Gouda',
priceEur: 1840,
areaM2: 122,
bedrooms: 3,
rooms: 4,
propertyType: 'house',
agent: 'Vesteda'
});
});
});

View File

@@ -0,0 +1,94 @@
// Vesteda (large rental landlord, has Goudshof and Westergouwe in Gouda). Their own site
// search calls POST /api/units/search/facet with a JSON filter; robots.txt allows all.
import { int, str, type ScrapedListing } from './scraped';
import type { PropertyType } from './types';
export const VESTEDA_ORIGIN = 'https://www.vesteda.com';
export const VESTEDA_SEARCH_URL = `${VESTEDA_ORIGIN}/api/units/search/facet`;
/** Same body the site sends: a point + radius (km), rent range. */
export function vestedaSearchBody(opts: {
lat: number;
lng: number;
radiusKm: number;
place: string;
}) {
return {
filters: [],
latitude: opts.lat,
longitude: opts.lng,
place: opts.place,
placeType: 1,
radius: opts.radiusKm,
sorting: 0,
priceFrom: 500,
priceTo: 9999,
language: 'nl'
};
}
// UnitStatus in their app.js: ForRent 1, Rented 2, RentedWithReservation 3, Reserved 4, New 5.
const statuses: Record<number, string> = {
1: 'for rent',
2: 'rented',
3: 'rented with reservation',
4: 'reserved',
5: 'new'
};
/** Rented units are shown on their site but can't be rented, so they count as gone. */
export const VESTEDA_AVAILABLE = new Set([1, 4, 5]);
function propertyType(label: string | null): PropertyType {
const l = (label ?? '').toLowerCase();
if (/eengezins|woning|huis|villa|herenhuis/.test(l)) return 'house';
if (/studio/.test(l)) return 'studio';
return 'apartment';
}
export function parseVestedaSearch(json: unknown): ScrapedListing[] {
const objects = (json as { results?: { objects?: unknown } })?.results?.objects;
if (!Array.isArray(objects)) throw new Error('vesteda: results.objects missing');
const out: ScrapedListing[] = [];
for (const raw of objects as Record<string, unknown>[]) {
const id = int(raw.id);
const path = str(raw.url, 400);
if (!id || !path || !VESTEDA_AVAILABLE.has(Number(raw.status))) continue;
const street = str(raw.street);
const number = [str(raw.houseNumber, 10), str(raw.houseNumberAddition, 10)]
.filter(Boolean)
.join('-');
const bedrooms = int(raw.numberOfBedRooms);
const label = str(raw.entitysubtypelabel, 60);
const features: Record<string, string> = { status: statuses[Number(raw.status)] ?? '?' };
if (str(raw.complex)) features.complex = str(raw.complex)!;
if (label) features.type = label;
if (raw.onlyMiddleRent === true) features['middle rent'] = 'only';
if (raw.onlySixtyFivePlus === true) features['65+'] = 'only';
if (str(raw.upcomingeventdate, 40)) features['open house'] = str(raw.upcomingeventdate, 40)!;
out.push({
sourceId: String(id),
url: new URL(path, VESTEDA_ORIGIN).toString(),
offer: 'rent',
propertyType: propertyType(label),
title: [street, number].filter(Boolean).join(' ') || null,
street,
houseNumber: number || null,
postalCode: str(raw.postalCode, 10)?.replace(/^(\d{4})\s*([A-Z]{2})$/i, '$1 $2') ?? null,
city: str(raw.city, 80),
priceEur: int(raw.priceUnformatted),
totalPriceEur: null,
areaM2: int(raw.size),
rooms: bedrooms != null ? bedrooms + 1 : null,
bedrooms,
energyLabel: null,
availableFrom: null,
interior: null,
agent: 'Vesteda',
imageUrl: str(raw.imageBig, 1000) ?? str(raw.imageSmall, 1000),
lat: typeof raw.latitude === 'number' ? raw.latitude : null,
lng: typeof raw.longitude === 'number' ? raw.longitude : null,
features
});
}
return out;
}

View File

@@ -72,6 +72,13 @@ const allTrackers = [
external: false,
private: true
},
{
name: 'houses',
subtitle: 'private: rentals around Gouda, applications and alerts',
href: '/trackers/houses',
external: false,
private: true
},
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
@@ -86,13 +93,7 @@ const allTrackers = [
external: false,
private: false
},
{
name: 'houses',
subtitle: 'Dutch rental and sale listings, with alerts',
href: '/projects/house-tracker',
external: false,
private: false
},
{
name: 'skins',
subtitle: 'Counter-Strike skin market prices across time',

View File

@@ -10,6 +10,7 @@ import {
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { houseSchema } from './schemas';
import { sql } from 'drizzle-orm';
import { id, timestamptz } from './helpers';
@@ -55,7 +56,17 @@ export const houseListings = houseSchema.table(
removedAt: timestamptz('removed_at'),
alertedAt: timestamptz('alerted_at'),
status: text('status'),
notes: text('notes')
notes: text('notes'),
// Normalised street + number + city ("burgemeester van dijkesingel|214b|gouda"), only
// when the source gives a house number. Same key on another source = same home.
addressKey: text('address_key'),
// Set on the newer copy of a home that another source (or a manual entry) already has.
// The page shows the original and links the copies; status and notes live there.
duplicateOf: integer('duplicate_of').references((): AnyPgColumn => houseListings.id, {
onDelete: 'set null'
}),
// Monthly rent including service costs, when a source shows it separately.
totalPriceEur: integer('total_price_eur')
},
(table) => [
check('listings_offer_check', sql`offer IN ('rent', 'buy')`),
@@ -67,7 +78,10 @@ export const houseListings = houseSchema.table(
index('listings_city_idx').on(table.source, table.offer, table.citySlug),
index('listings_details_idx')
.on(table.firstSeenAt.desc().nullsFirst())
.where(sql`detail_fetched_at IS NULL`)
.where(sql`detail_fetched_at IS NULL`),
index('listings_address_idx')
.on(table.addressKey)
.where(sql`address_key IS NOT NULL`)
]
);

View File

@@ -21,6 +21,8 @@ describe('owner gate', () => {
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
expect(isOwnerOnlyPath('/trackers/houses')).toBe(true);
expect(isOwnerWritePath('/trackers/houses')).toBe(true);
});
it('gates the book reader, files and APIs but not the catalogue', () => {

View File

@@ -23,6 +23,7 @@ export const ownerOnlyPrefixes = [
'/trackers/health',
'/trackers/tasks',
'/trackers/groceries',
'/trackers/houses',
'/trackers/books/files',
'/lists/db'
] as const;
@@ -41,6 +42,7 @@ export const ownerWritePrefixes = [
'/trackers/health',
'/trackers/tasks',
'/trackers/groceries',
'/trackers/houses',
'/trackers/books'
] as const;

View File

@@ -0,0 +1,10 @@
import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// The house tracker page moved to /trackers/houses (owner only). The worker API under
// /projects/house-tracker/api/worker/* stays where it is (more specific routes win).
export const fallback: RequestHandler = ({ params, url }) => {
if (params.rest) error(404, 'Not Found');
redirect(308, `/trackers/houses${url.search}`);
};
export const GET = fallback;

View File

@@ -0,0 +1,10 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only, like tasks and health: everyone else gets a plain 404. Form actions skip
// this load, so +page.server.ts checks again and hooks.server.ts blocks the prefix too.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -1,26 +1,73 @@
import { fail, redirect } from '@sveltejs/kit';
import { and, asc, desc, eq, gte, ilike, isNull, lte, or, sql, type SQL } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import {
and,
asc,
desc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
or,
sql,
type SQL
} from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { houseAlertRules, houseListings, houseWorkerRuns } from '#lib/server/db/schema/index.js';
import { requireAdmin } from '#lib/server/authorization.js';
import { requireOwner } from '#lib/server/owner.js';
import { sendAlert } from '#lib/server/house-tracker.js';
import { normalizeCity } from '#lib/house-tracker/alerts.js';
import { listingStatuses, propertyTypes } from '#lib/house-tracker/types.js';
import { dedupe } from '#lib/house-tracker/ingest.js';
import {
inferOffer,
inferPropertyType,
keyFromAddress,
keyFromUrl,
manualListingSchema,
normalizeKeys,
toColumns
} from '#lib/house-tracker/manual.js';
import type { Actions, PageServerLoad } from './$types';
// The area Stan and Juhee are looking in (same list as the "Gouda area rentals" alert rule).
const REGION = [
'gouda',
'waddinxveen',
'reeuwijk',
'moordrecht',
'nieuwerkerk-aan-den-ijssel',
'zevenhuizen',
'bodegraven',
'boskoop',
'oudewater',
'schoonhoven',
'haastrecht'
] as const;
const DEFAULTS = { city: 'region', max: '1800', area: '35' } as const;
const int = (value: string | null) => {
const n = Number(value);
return value && Number.isInteger(n) && n >= 0 && n < 100_000_000 ? n : null;
};
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) redirect(303, '/login?returnTo=/projects/house-tracker');
requireAdmin(event);
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
const params = event.url.searchParams;
// Defaults apply only when a filter is absent from the URL; submitting the form with a
// field left empty means "no limit".
const given = (name: string, fallback: string) => params.get(name) ?? fallback;
const offer = params.get('offer') === 'buy' ? 'buy' : 'rent';
const city = (params.get('city') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '');
const city = given('city', DEFAULTS.city)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
const min = int(params.get('min'));
const max = int(params.get('max'));
const max = int(given('max', DEFAULTS.max));
const minArea = int(given('area', DEFAULTS.area));
const minRooms = int(params.get('rooms'));
const source = (params.get('source') ?? '').replace(/[^a-z0-9-]/g, '');
const showRemoved = params.get('removed') === '1';
const status = params.get('status') ?? '';
const q = (params.get('q') ?? '').trim().slice(0, 80);
@@ -28,8 +75,18 @@ export const load: PageServerLoad = async (event) => {
? (params.get('sort') as string)
: 'new';
const where: (SQL | undefined)[] = [eq(houseListings.offer, offer)];
if (city) where.push(eq(houseListings.citySlug, city));
const where: (SQL | undefined)[] = [
eq(houseListings.offer, offer),
// Copies of the same home on another source are listed under the original.
isNull(houseListings.duplicateOf)
];
if (city === 'region') where.push(inArray(houseListings.citySlug, [...REGION]));
else if (city) where.push(eq(houseListings.citySlug, city));
if (minArea != null)
where.push(or(isNull(houseListings.areaM2), gte(houseListings.areaM2, minArea)));
if (minRooms != null)
where.push(or(isNull(houseListings.rooms), gte(houseListings.rooms, minRooms)));
if (source) where.push(eq(houseListings.source, source));
if (min != null) where.push(gte(houseListings.priceEur, min));
if (max != null) where.push(lte(houseListings.priceEur, max));
if (!showRemoved) where.push(isNull(houseListings.removedAt));
@@ -67,6 +124,9 @@ export const load: PageServerLoad = async (event) => {
neighbourhood: houseListings.neighbourhood,
postalCode: houseListings.postalCode,
priceEur: houseListings.priceEur,
totalPriceEur: houseListings.totalPriceEur,
street: houseListings.street,
features: houseListings.features,
areaM2: houseListings.areaM2,
rooms: houseListings.rooms,
bedrooms: houseListings.bedrooms,
@@ -120,9 +180,36 @@ export const load: PageServerLoad = async (event) => {
AS median_detect_minutes`)
]);
const ids = listings.map((row) => row.id);
const [copies, sources] = await Promise.all([
ids.length
? db
.select({
id: houseListings.id,
duplicateOf: houseListings.duplicateOf,
source: houseListings.source,
url: houseListings.url,
priceEur: houseListings.priceEur,
removedAt: houseListings.removedAt
})
.from(houseListings)
.where(inArray(houseListings.duplicateOf, ids))
: [],
db.execute(sql`SELECT source, count(*) FILTER (WHERE removed_at IS NULL)::int AS active,
max(last_seen_at) AS last_seen FROM house.listings GROUP BY source ORDER BY active DESC`)
]);
const copiesOf = new Map<number, typeof copies>();
for (const copy of copies) {
const list = copiesOf.get(copy.duplicateOf!) ?? [];
list.push(copy);
copiesOf.set(copy.duplicateOf!, list);
}
return {
filters: { offer, city, min, max, showRemoved, status, q, sort },
listings,
filters: { offer, city, min, max, minArea, minRooms, source, showRemoved, status, q, sort },
defaults: DEFAULTS,
listings: listings.map((row) => ({ ...row, copies: copiesOf.get(row.id) ?? [] })),
sources: sources as unknown as { source: string; active: number; last_seen: string | Date }[],
cities: cities as unknown as { slug: string; name: string | null; active: number }[],
stats: stats as unknown as {
city: string;
@@ -155,7 +242,7 @@ const nullableInt = (value: FormDataEntryValue | null) => {
export const actions: Actions = {
status: async (event) => {
requireAdmin(event);
requireOwner(event);
const form = await event.request.formData();
const id = Number(form.get('id'));
const status = String(form.get('status') ?? '');
@@ -171,8 +258,65 @@ export const actions: Actions = {
.where(eq(houseListings.id, id));
return { ok: true };
},
addManual: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const raw: Record<string, unknown> = {};
for (const name of [
'url',
'street',
'city',
'price',
'area',
'rooms',
'status',
'notes',
'title'
]) {
const value = String(form.get(name) ?? '').trim();
if (value) raw[name] = value;
}
const parsed = manualListingSchema.safeParse(normalizeKeys(raw));
if (!parsed.success)
return fail(400, { message: 'Check the fields: ' + parsed.error.issues[0]?.message });
const listing = parsed.data;
if (!listing.url && !listing.street) return fail(400, { message: 'Give a link or an address' });
let key;
try {
key = listing.url ? keyFromUrl(listing.url) : keyFromAddress(listing);
} catch (error) {
return fail(400, { message: error instanceof Error ? error.message : 'Invalid listing' });
}
const hint = `${key.url} ${listing.title ?? ''}`;
const columns = toColumns(listing);
if (listing.url) columns.url = key.url;
if (!columns.city_slug && key.citySlug) columns.city_slug = key.citySlug;
if (!columns.title) columns.title = listing.street ?? null;
const client = db.$client;
const [existing] = await client`SELECT id FROM house.listings
WHERE source = ${key.source} AND source_id = ${key.sourceId}`;
if (existing) {
await client`UPDATE house.listings SET ${client(columns)}, removed_at = NULL WHERE id = ${existing.id}`;
} else {
const [row] = await client`INSERT INTO house.listings ${client({
source: key.source,
source_id: key.sourceId,
url: key.url,
offer: listing.offer ?? key.offer ?? inferOffer(hint) ?? 'rent',
property_type:
listing.propertyType ?? key.propertyType ?? inferPropertyType(hint) ?? 'other',
first_seen_lane: 'manual',
detail_fetched_at: new Date(),
...columns
})} RETURNING id`;
if (listing.priceEur != null)
await client`INSERT INTO house.price_history (listing_id, price_eur) VALUES (${row.id}, ${listing.priceEur})`;
}
await dedupe(client);
return { message: existing ? 'Updated the existing listing' : 'Added' };
},
saveRule: async (event) => {
requireAdmin(event);
requireOwner(event);
const form = await event.request.formData();
const id = Number(form.get('id') || 0);
const name = String(form.get('name') ?? '')
@@ -213,18 +357,18 @@ export const actions: Actions = {
return { ok: true };
},
deleteRule: async (event) => {
requireAdmin(event);
requireOwner(event);
const id = Number((await event.request.formData()).get('id'));
if (!Number.isInteger(id) || id <= 0) return fail(400, { message: 'Invalid rule' });
await db.delete(houseAlertRules).where(eq(houseAlertRules.id, id));
return { ok: true };
},
testAlert: async (event) => {
requireAdmin(event);
requireOwner(event);
const sent = await sendAlert({
title: '🏠 House tracker test',
body: 'If you can read this, alerts work.',
url: `${event.url.origin}/projects/house-tracker`
url: `${event.url.origin}/trackers/houses`
});
return sent
? { message: 'Test alert sent' }

View File

@@ -56,7 +56,7 @@
data.health.last_challenge &&
(!data.health.last_ok || new Date(data.health.last_challenge) > new Date(data.health.last_ok))
? 'challenged'
: workerAge < 75 * 60_000
: workerAge < 26 * 3600_000
? 'healthy'
: 'quiet'
);
@@ -64,14 +64,14 @@
</script>
<svelte:head>
<title>house tracker</title>
<title>houses</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6">
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight">house tracker</h1>
<h1 class="text-4xl font-semibold tracking-tight">houses</h1>
<p class="mt-1 text-sm text-muted-foreground">
{data.health.total} listings collected · {data.health.alerts_week}
{data.health.alerts_week === 1 ? 'alert' : 'alerts'} this week
@@ -92,11 +92,11 @@
]}
></span>
{#if workerState === 'challenged'}
worker was challenged {relativeTime(data.health.last_challenge!, now)}, backing off
a source blocked the scraper {relativeTime(data.health.last_challenge!, now)} (see the log)
{:else if data.health.last_ok}
worker last synced {relativeTime(data.health.last_ok, now)}
scraper last ran {relativeTime(data.health.last_ok, now)}
{:else}
worker has not synced yet
scraper has not run yet
{/if}
</div>
</div>
@@ -107,6 +107,7 @@
<option value="buy">buy</option>
</NativeSelect>
<NativeSelect name="city" value={data.filters.city} aria-label="City">
<option value="region">Gouda region</option>
<option value="">all cities</option>
{#each data.cities as city (city.slug)}
<option value={city.slug}>{city.name ?? city.slug} ({city.active})</option>
@@ -114,6 +115,26 @@
</NativeSelect>
<Input name="min" type="number" min="0" placeholder="min €" value={data.filters.min ?? ''} />
<Input name="max" type="number" min="0" placeholder="max €" value={data.filters.max ?? ''} />
<Input
name="area"
type="number"
min="0"
placeholder="min m²"
value={data.filters.minArea ?? ''}
/>
<Input
name="rooms"
type="number"
min="0"
placeholder="min rooms"
value={data.filters.minRooms ?? ''}
/>
<NativeSelect name="source" value={data.filters.source} aria-label="Source">
<option value="">all sources</option>
{#each data.sources as row (row.source)}
<option value={row.source}>{row.source} ({row.active})</option>
{/each}
</NativeSelect>
<Input name="q" placeholder="street, area, agent" value={data.filters.q} class="col-span-2" />
<NativeSelect name="sort" value={data.filters.sort} aria-label="Sort">
<option value="new">newest</option>
@@ -176,7 +197,16 @@
{listing.title ?? 'Listing'}
<ExternalLink class="size-3.5 text-muted-foreground" />
</a>
<span class="font-semibold tabular-nums">{price(listing.priceEur)}</span>
<span class="font-semibold tabular-nums">
{price(listing.priceEur)}
{#if listing.totalPriceEur && listing.totalPriceEur !== listing.priceEur}
<span class="text-xs font-normal text-muted-foreground"
>({euro.format(listing.totalPriceEur)} total)</span
>
{:else if listing.totalPriceEur}
<span class="text-xs font-normal text-muted-foreground">incl. utilities</span>
{/if}
</span>
</div>
<div class="mt-0.5 text-sm text-muted-foreground">
{[
@@ -215,7 +245,38 @@
>{/if}
{#if listing.agent}<span>{listing.agent}</span>{/if}
<span>{listing.source}</span>
{#each listing.copies as copy (copy.id)}
<a href={copy.url} target="_blank" rel="noreferrer" class="underline"
>also on {copy.source}{copy.priceEur != null && copy.priceEur !== listing.priceEur
? ` (${price(copy.priceEur)})`
: ''}{copy.removedAt ? ', gone' : ''}</a
>
{/each}
</div>
<details class="mt-1 text-xs" open={Boolean(listing.notes)}>
<summary class="cursor-pointer text-muted-foreground">
{listing.notes ? 'notes' : 'add notes'}
</summary>
<form
method="POST"
action="?/status"
use:enhance={() =>
async ({ update }) =>
update({ reset: false, refreshAll: false })}
class="mt-1 flex gap-2"
>
<input type="hidden" name="id" value={listing.id} />
<input type="hidden" name="status" value={listing.status ?? ''} />
<textarea
name="notes"
rows="2"
maxlength="2000"
class="min-h-8 flex-1 rounded-md border border-input bg-transparent px-2 py-1 text-xs"
placeholder="viewing Sat 14:00, asked about pets…">{listing.notes ?? ''}</textarea
>
<Button type="submit" size="sm" variant="outline">save</Button>
</form>
</details>
</div>
<form
method="POST"
@@ -233,7 +294,7 @@
class="h-8 text-xs"
onchange={(event) => (event.currentTarget as HTMLSelectElement).form?.requestSubmit()}
>
<option value="">—</option>
<option value="">new</option>
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
</NativeSelect>
<noscript><Button type="submit" size="sm" variant="outline">save</Button></noscript>
@@ -241,7 +302,7 @@
</li>
{:else}
<li class="p-6 text-center text-sm text-muted-foreground">
No listings yet. Start the home worker (see docs/house-tracker.md).
No listings match. Clear some filters, or wait for tonight's scrape.
</li>
{/each}
</ul>
@@ -251,6 +312,32 @@
</p>
{/if}
<h2 class="mt-12 mb-3 text-2xl font-semibold">add a listing by hand</h2>
<form
method="POST"
action="?/addManual"
use:enhance
class="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4 lg:grid-cols-8"
>
<Input name="url" type="url" placeholder="link (optional)" class="col-span-2" />
<Input name="street" placeholder="street + number" class="col-span-2" />
<Input name="city" placeholder="city" value="Gouda" />
<Input name="price" type="number" min="0" placeholder="€ / month" />
<Input name="area" type="number" min="0" placeholder="m²" />
<Input name="rooms" type="number" min="0" placeholder="rooms" />
<NativeSelect name="status" aria-label="Status">
<option value="">new</option>
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
</NativeSelect>
<Input name="notes" placeholder="notes" class="col-span-2 sm:col-span-3 lg:col-span-6" />
<Button type="submit">add</Button>
</form>
<p class="mt-1 text-xs text-muted-foreground">
Same address or link as an existing listing updates it. Scraped copies of the same address are
linked to it automatically.
</p>
{#if form?.message}<p class="mt-2 text-sm">{form.message}</p>{/if}
<h2 class="mt-12 mb-3 text-2xl font-semibold">market ({data.filters.offer})</h2>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-sm">