houses: nightly Kamernet + Vesteda scraper, dedupe by address, owner-only /trackers/houses
All checks were successful
Deploy production / deploy (push) Successful in 2m44s
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:
10
src/routes/projects/house-tracker/[...rest]/+server.ts
Normal file
10
src/routes/projects/house-tracker/[...rest]/+server.ts
Normal 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;
|
||||
10
src/routes/trackers/houses/+layout.server.ts
Normal file
10
src/routes/trackers/houses/+layout.server.ts
Normal 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 {};
|
||||
};
|
||||
@@ -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' }
|
||||
@@ -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">
|
||||
Reference in New Issue
Block a user