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