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,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;