Compare commits

...

4 Commits

Author SHA1 Message Date
e1745841b1 restyle page, add area filters 2026-04-04 15:27:12 +02:00
fbe50790da tweaks and first real run 2026-04-04 15:23:09 +02:00
423a429f56 add dev override and web layer 2026-04-04 14:47:01 +02:00
f1748214ce drop email support 2026-04-04 14:11:07 +02:00
9 changed files with 880 additions and 120 deletions

View File

@@ -1,11 +1,5 @@
HA_WEBHOOK_URL=
SMTP_HOST=
SMTP_PORT=587
SMTP_FROM=
SMTP_TO=
SMTP_USER=
SMTP_PASSWORD=
DB_PATH=/data/huizenbot.db
APP_ENV=dev

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
**/__pycache__/
tests/cache/
data/

View File

@@ -1,10 +1,11 @@
{ pkgs ? import <nixpkgs> {} }:
{ pkgs ? import <nixpkgs> { config.allowUnfree = true; } }:
pkgs.mkShell {
packages = [
(pkgs.python3.withPackages (ps: with ps; [
httpx
beautifulsoup4
flask
lxml
]))
pkgs.claude-code
@@ -15,7 +16,6 @@ pkgs.mkShell {
set -a
source .env
set +a
echo ".env geladen"
fi
'';
}

View File

@@ -12,7 +12,7 @@ import time
import httpx
from bs4 import BeautifulSoup
import config
from config import *
from huizenbot import RawListing
log = logging.getLogger("huizenbot.ssr")
@@ -160,6 +160,8 @@ def fetch_realworks(base_url: str, makelaar: str) -> list[RawListing]:
slaapkamers=int(kk["slaapkamers"]) if kk.get("slaapkamers") else None,
energielabel=kk.get("energielabel"),
))
if APP_ENV == "dev":
break
except Exception as e:
log.warning("%s: parse fout: %s", makelaar, e)
@@ -292,6 +294,9 @@ def fetch_dewittegarantiemakelaars() -> list[RawListing]:
bouwjaar=int(bouwjaar) if bouwjaar else None,
hero_image_url=hero,
))
if APP_ENV == "dev":
break
except Exception as e:
log.warning("dewitte: parse fout: %s", e)
@@ -415,6 +420,8 @@ def fetch_wassenaar() -> list[RawListing]:
slaapkamers=int(kk["slaapkamers"]) if kk.get("slaapkamers") else None,
energielabel=kk.get("energielabel"),
))
if APP_ENV == "dev":
break
except Exception as e:
log.warning("wassenaar: parse fout: %s", e)
@@ -597,6 +604,8 @@ def fetch_dens() -> list[RawListing]:
slaapkamers=int(detail_data["slaapkamers"]) if detail_data.get("slaapkamers") else None,
energielabel=detail_data.get("energielabel"),
))
if APP_ENV == "dev":
break
except Exception as e:
log.warning("dens: parse fout: %s", e)
@@ -723,6 +732,8 @@ def fetch_3dmakelaars() -> list[RawListing]:
slaapkamers=detail_data.get("slaapkamers"),
hero_image_url=hero,
))
if APP_ENV == "dev":
break
except Exception as e:
log.warning("3dmakelaars: parse fout: %s", e)
@@ -845,6 +856,9 @@ def fetch_dupont() -> list[RawListing]:
slaapkamers=int(detail_data["slaapkamers"]) if detail_data.get("slaapkamers") else None,
energielabel=detail_data.get("energielabel"),
))
if APP_ENV == "dev":
break
except Exception as e:
log.warning("dupont: parse fout: %s", e)

View File

@@ -1,5 +1,5 @@
"""
config.py — vul aan met je eigen waarden. Secrets via environment variables.
config.py — Secrets via environment variables.
"""
import os
@@ -10,16 +10,46 @@ MICHELLE_WERK_9292 = "vlaardingen/"+MICHELLE_WERK_POSTCODE
HA_WEBHOOK_URL = os.environ.get("HA_WEBHOOK_URL", "")
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_FROM = os.environ.get("SMTP_FROM", "")
SMTP_TO = os.environ.get("SMTP_TO", "")
SMTP_USER = os.environ.get("SMTP_USER", "")
USER_AGENT = "Huizenbot/1.0 (+mark@kalsbeek.dev) persoonlijk gebruik"
DB_PATH = os.environ.get("DB_PATH", "/data/huizenbot.db")
FIETS_SNELHEID_FACTOR = 1.27
MAX_PRICE = 300_000
MAX_PRICE = 300_000 # coarse pre-filter in adapters only
MIN_AREA = 65 # Sq meters
# Fine price filter: max mortgage per energy label group * 0.9
# Labels not in this map fall back to the most conservative tier.
_LABEL_DISCOUNT = 0.9
MAX_PRIJS_PER_LABEL: dict[str, int] = {
"EFG": int(286_942 * _LABEL_DISCOUNT),
"CD": int(291_942 * _LABEL_DISCOUNT),
"AB": int(296_942 * _LABEL_DISCOUNT),
"A+": int(306_942 * _LABEL_DISCOUNT),
}
_MAX_PRIJS_ONBEKEND = MAX_PRIJS_PER_LABEL["EFG"] # conservative fallback
def max_prijs_voor_label(label: str | None) -> int:
"""Return the max allowed price for a given energy label (or None/unknown)."""
if not label:
return _MAX_PRIJS_ONBEKEND
l = label.strip().upper()
if l in ("A+++", "A++", "A+"):
return MAX_PRIJS_PER_LABEL["A+"]
if l in ("A", "B"):
return MAX_PRIJS_PER_LABEL["AB"]
if l in ("C", "D"):
return MAX_PRIJS_PER_LABEL["CD"]
if l in ("E", "F", "G"):
return MAX_PRIJS_PER_LABEL["EFG"]
return _MAX_PRIJS_ONBEKEND
# Travel time limits (None travel time → pass, with warning)
MAX_OV_MINUTEN_MARK = 50
MAX_OV_MINUTEN_MICHELLE = 50
MAX_FIETS_MINUTEN_MARK = 35
# No fiets limit for michelle
APP_ENV = os.environ.get("APP_ENV", "dev")

View File

@@ -6,13 +6,10 @@ import hashlib
import json
import logging
import os
import smtplib
import sqlite3
import time
from dataclasses import dataclass, field
from datetime import datetime, date
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Callable, Any
import httpx
@@ -97,6 +94,7 @@ CREATE TABLE IF NOT EXISTS woningen (
def get_db(path: str) -> sqlite3.Connection:
log.info(f"Opening db at path {path}")
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
@@ -234,7 +232,7 @@ def _next_weekday_morning() -> str:
return d.strftime("%Y%m%dT083000")
def bereken_reistijden(postcode: str | None) -> dict[str, int]:
def bereken_reistijden(postcode: str | None, stad: str | None) -> dict[str, int]:
"""Bereken alle reistijden voor een woning postcode. Geeft lege dict bij falen."""
if not postcode:
return {}
@@ -243,16 +241,20 @@ def bereken_reistijden(postcode: str | None) -> dict[str, int]:
if not woning_coords:
return {}
werk1 = geocode(config.MARK_WERK_POSTCODE)
werk2 = geocode(config.MICHELLE_WERK_POSTCODE)
werk1_coords = geocode(config.MARK_WERK_POSTCODE)
werk2_coords = geocode(config.MICHELLE_WERK_POSTCODE)
# 9292 expects "cityname/postcode" strings (lowercase city)
stad_lower = (stad or "").strip().lower()
woning_9292 = f"{stad_lower}/{postcode}" if stad_lower else postcode
result = {}
if werk1:
result["fiets_mark"] = fiets_minuten(woning_coords, werk1)
result["ov_mark"] = ov_minuten(woning_coords, werk1)
if werk2:
result["fiets_michelle"] = fiets_minuten(woning_coords, werk2)
result["ov_michelle"] = ov_minuten(woning_coords, werk2)
if werk1_coords:
result["fiets_mark"] = fiets_minuten(woning_coords, werk1_coords)
result["ov_mark"] = ov_minuten(woning_9292, config.MARK_WERK_9292)
if werk2_coords:
result["fiets_michelle"] = fiets_minuten(woning_coords, werk2_coords)
result["ov_michelle"] = ov_minuten(woning_9292, config.MICHELLE_WERK_9292)
return result
@@ -285,45 +287,66 @@ def notify_ha(listing: RawListing, travel: dict[str,int]) -> None:
log.info("HA notificatie verstuurd voor %s", listing.adres)
except Exception as e:
log.error("HA webhook fout: %s", e)
notify_email(listing, travel) # fallback
# ---------------------------------------------------------------------------
# Filtering
# ---------------------------------------------------------------------------
def notify_email(listing: RawListing, travel: dict[str,int]) -> None:
"""Stuur HTML email als fallback."""
if not config.SMTP_HOST:
return
subject = f"Nieuwe woning: {listing.adres}, {listing.stad} — €{listing.prijs:,}"
html = f"""
<html><body>
<h2>{listing.adres}, {listing.stad}</h2>
<p><strong>Prijs:</strong> €{listing.prijs:,}</p>
<p><strong>Status:</strong> {listing.status}</p>
<p><strong>Fiets P1:</strong> {travel.get('fiets_mark')} min &nbsp;
<strong>OV P1:</strong> {travel.get('ov_mark')} min</p>
<p><strong>Fiets P2:</strong> {travel.get('fiets_michelle')} min &nbsp;
<strong>OV P2:</strong> {travel.get('ov_michelle')} min</p>
{"<img src='" + listing.hero_image_url + "' width='600'>" if listing.hero_image_url else ""}
<p><a href="{listing.url}">Bekijk listing</a></p>
</body></html>
def _check_filters(listing: RawListing, travel: dict[str, int]) -> bool:
"""
Returns True if the listing passes all filters and should trigger a notification.
Always errs on the side of notifying when data is missing (logs a warning).
"""
passed = True
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = config.SMTP_FROM
msg["To"] = config.SMTP_TO
msg.attach(MIMEText(html, "html"))
# --- Price filter ---
if listing.prijs is not None:
max_p = config.max_prijs_voor_label(listing.energielabel)
if listing.prijs > max_p:
log.info(
"Gefilterd op prijs: %s%d > €%d (label: %s)",
listing.adres, listing.prijs, max_p, listing.energielabel or "onbekend",
)
passed = False
# --- Area filter ---
if listing.woonoppervlak is not None and listing.woonoppervlak < config.MIN_AREA:
log.info(f"Gefilterd op oppervlakte: {listing.woonoppervlak} < {config.MIN_AREA}")
passed = False
try:
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as s:
if config.SMTP_USER:
s.starttls()
s.login(config.SMTP_USER, os.environ.get("SMTP_PASSWORD", ""))
s.send_message(msg)
log.info("Email verstuurd voor %s", listing.adres)
except Exception as e:
log.error("Email fout: %s", e)
# --- OV filter ---
ov_mark = travel.get("ov_mark")
ov_michelle = travel.get("ov_michelle")
if ov_mark is None:
log.warning(
"OV reistijd mark ONBEKEND voor %s — notificatie wordt toch verstuurd",
listing.adres,
)
elif ov_mark > config.MAX_OV_MINUTEN_MARK:
log.info("Gefilterd op OV mark: %s %dmin > %dmin", listing.adres, ov_mark, config.MAX_OV_MINUTEN_MARK)
passed = False
if ov_michelle is None:
log.warning(
"OV reistijd michelle ONBEKEND voor %s — notificatie wordt toch verstuurd",
listing.adres,
)
elif ov_michelle > config.MAX_OV_MINUTEN_MICHELLE:
log.info("Gefilterd op OV michelle: %s %dmin > %dmin", listing.adres, ov_michelle, config.MAX_OV_MINUTEN_MICHELLE)
passed = False
# --- Fiets filter (mark only) ---
fiets_mark = travel.get("fiets_mark")
if fiets_mark is None:
log.warning(
"Fiets reistijd mark ONBEKEND voor %s — notificatie wordt toch verstuurd",
listing.adres,
)
elif fiets_mark > config.MAX_FIETS_MINUTEN_MARK:
log.info("Gefilterd op fiets mark: %s %dmin > %dmin", listing.adres, fiets_mark, config.MAX_FIETS_MINUTEN_MARK)
passed = False
return passed
# ---------------------------------------------------------------------------
@@ -333,42 +356,64 @@ def notify_email(listing: RawListing, travel: dict[str,int]) -> None:
Scraper = Callable[[], list[RawListing]]
def run(scrapers: list[Scraper], db_path: str) -> None:
conn = get_db(db_path)
total_new = 0
for scraper in scrapers:
def _run_scraper(scraper: Scraper) -> tuple[str, list[RawListing]]:
name = scraper.__name__
log.info("Scraper starten: %s", name)
try:
listings = scraper()
log.info("Scraper %s: %d listings opgehaald", name, len(listings))
return name, listings
except Exception as e:
log.error("Scraper %s gefaald: %s", name, e)
continue
return name, []
log.info("Scraper %s: %d listings opgehaald", name, len(listings))
for listing in listings:
def run(scrapers: dict[str,Scraper], db_path: str) -> None:
import concurrent.futures
conn = get_db(db_path)
total_new = 0
total_notified = 0
# Phase 1: run all scrapers concurrently (each hits a different domain)
all_listings: list[RawListing] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(scrapers)) as pool:
futures = {pool.submit(_run_scraper, s): s for s in scrapers.values()}
for future in concurrent.futures.as_completed(futures):
_name, listings = future.result()
all_listings.extend(listings)
log.info("Alle scrapers klaar. %d listings totaal opgehaald.", len(all_listings))
# Phase 2: sequential travel calculation + upsert + filtered notify
for listing in all_listings:
travel = {}
try:
# Check of het een nieuwe woning is vóór upsert
lid = listing_id(listing.url)
is_existing = conn.execute(
"SELECT id FROM woningen WHERE id = ?", (lid,)
).fetchone() is not None
if not is_existing:
travel = bereken_reistijden(listing.postcode)
travel = bereken_reistijden(listing.postcode, listing.stad)
is_new = upsert(conn, listing, travel)
if is_new:
total_new += 1
log.info("Nieuwe woning: %s (%s)", listing.adres, listing.url)
if _check_filters(listing, travel):
total_notified += 1
notify_ha(listing, travel)
else:
log.info("Geen notificatie voor %s (gefilterd)", listing.adres)
except Exception as e:
log.error("Fout bij verwerken %s: %s", listing.url, e)
log.info("Run klaar. %d nieuwe woningen gevonden.", total_new)
log.info(
"Run klaar. %d nieuwe woningen, %d notificaties verstuurd.",
total_new, total_notified,
)
conn.close()

642
src/templates/index.html Normal file
View File

@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Huizenbot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f5f0eb;
--surface: #fdf9f5;
--surface2: #ede8e2;
--border: #ddd6cc;
--accent: #6a9e78;
--accent-dim: #4f7a5c;
--text: #2e2a25;
--text-dim: #7a7068;
--text-dimmer: #aaa098;
--red: #c0524a;
--orange: #c07c3a;
--radius: 10px;
--font-ui: 'Syne', sans-serif;
--font-mono: 'DM Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
min-height: 100vh;
}
/* ── Header ── */
header {
padding: 1.25rem 1rem 0;
display: flex;
align-items: baseline;
gap: 0.75rem;
}
header h1 {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--accent);
}
#count {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-dim);
}
/* ── Filters ── */
#filters {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.35rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.3rem 0.6rem;
}
.filter-group label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-dim);
letter-spacing: 0.04em;
white-space: nowrap;
text-transform: uppercase;
}
.filter-group input[type=number] {
background: transparent;
border: none;
color: var(--accent);
font-family: var(--font-mono);
font-size: 0.8rem;
width: 3.2rem;
outline: none;
text-align: right;
}
.filter-group input[type=number]::-webkit-inner-spin-button { opacity: 0.3; }
.filter-group select {
background: transparent;
border: none;
color: var(--text);
font-family: var(--font-ui);
font-size: 0.75rem;
font-weight: 600;
outline: none;
cursor: pointer;
}
.filter-group select option { background: var(--surface2); }
#filter-reset {
background: none;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-dimmer);
font-family: var(--font-ui);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.3rem 0.7rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
#filter-reset:hover { color: var(--text); border-color: var(--text-dim); }
/* ── Card list ── */
#listings {
padding: 0.75rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 900px;
margin: 0 auto;
}
#empty {
text-align: center;
color: var(--text-dimmer);
font-family: var(--font-mono);
font-size: 0.85rem;
padding: 4rem 1rem;
display: none;
}
/* ── Card ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color 0.15s;
}
.card:hover { border-color: #c5bdb4; }
.card-compact {
display: grid;
grid-template-columns: 1fr 2fr;
min-height: 110px;
cursor: pointer;
user-select: none;
}
/* Image */
.card-img {
position: relative;
background: var(--surface2);
overflow: hidden;
}
.card-img img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.card:hover .card-img img { transform: scale(1.03); }
.card-img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dimmer);
font-size: 1.5rem;
}
.card-source {
position: absolute;
bottom: 0.4rem;
left: 0.4rem;
background: rgba(255,255,255,0.75);
backdrop-filter: blur(4px);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.6rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
/* Data section */
.card-data {
padding: 0.7rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.card-adres {
font-size: 0.85rem;
font-weight: 700;
line-height: 1.3;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.card-stad {
font-size: 0.7rem;
color: var(--text-dim);
font-weight: 400;
}
/* Link chip — always clickable, does NOT expand card */
.card-link {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.6rem;
padding: 0.2rem 0.45rem;
text-decoration: none;
transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.card-link:hover {
color: var(--accent);
border-color: var(--accent-dim);
background: rgba(106,158,120,0.08);
}
.card-link svg { flex-shrink: 0; }
.card-prijs {
font-size: 1rem;
font-weight: 800;
color: var(--accent);
letter-spacing: -0.02em;
font-family: var(--font-mono);
}
.card-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.2rem 0.5rem;
margin-top: 0.1rem;
}
.card-meta-item {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.68rem;
color: var(--text-dim);
font-family: var(--font-mono);
white-space: nowrap;
}
.card-meta-item .icon { font-size: 0.75rem; }
.card-meta-item .val { color: var(--text); font-weight: 500; }
.card-meta-item.warn .val { color: var(--orange); }
.card-meta-item.ok .val { color: var(--accent); }
/* Expand toggle indicator */
.card-toggle {
align-self: flex-end;
color: var(--text-dimmer);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-top: auto;
}
/* ── Expanded panel ── */
.card-expanded {
display: none;
border-top: 1px solid var(--border);
padding: 0.9rem 1rem;
background: var(--surface2);
}
.card.open .card-expanded { display: block; }
.card.open .card-toggle { color: var(--accent-dim); }
.expanded-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem 1rem;
margin-bottom: 0.75rem;
}
.expanded-field {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.expanded-field .ef-label {
font-size: 0.62rem;
font-weight: 600;
color: var(--text-dimmer);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.expanded-field .ef-val {
font-size: 0.8rem;
color: var(--text);
font-family: var(--font-mono);
}
.extra-section {
border-top: 1px solid var(--border);
padding-top: 0.6rem;
margin-top: 0.25rem;
}
.extra-section h4 {
font-size: 0.62rem;
font-weight: 600;
color: var(--text-dimmer);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 0.4rem;
}
.extra-kv {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.extra-kv-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.2rem 0.5rem;
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-dim);
}
.extra-kv-item .ek { color: var(--text-dimmer); }
.extra-kv-item .ev { color: var(--text); margin-left: 0.3rem; }
/* ── No results ── */
#empty { display: none; }
#empty.visible { display: block; }
</style>
</head>
<body>
<header>
<h1>Huizenbot</h1>
<span id="count"></span>
</header>
<div id="filters">
<div class="filter-group">
<label>OV Mark ≤</label>
<input type="number" id="f-ov-mark" value="45" min="0" max="120">
<label>min</label>
</div>
<div class="filter-group">
<label>OV Michelle ≤</label>
<input type="number" id="f-ov-michelle" value="45" min="0" max="120">
<label>min</label>
</div>
<div class="filter-group">
<label>Fiets Mark ≤</label>
<input type="number" id="f-fiets-mark" value="40" min="0" max="90">
<label>min</label>
</div>
<div class="filter-group">
<label>Max prijs</label>
<input type="number" id="f-prijs" value="300000" min="0" step="5000">
</div>
<div class="filter-group">
<label>Min opp.</label>
<input type="number" id="f-opp" value="65" min="0" max="300">
<label></label>
</div>
<div class="filter-group">
<label>Sorteer</label>
<select id="f-sort">
<option value="first_seen_desc">Nieuwste eerst</option>
<option value="first_seen_asc">Oudste eerst</option>
<option value="prijs_asc">Prijs ↑</option>
<option value="prijs_desc">Prijs ↓</option>
<option value="ov_mark_asc">OV Mark ↑</option>
<option value="fiets_mark_asc">Fiets Mark ↑</option>
<option value="opp_asc">Opp. ↑</option>
<option value="opp_desc">Opp. ↓</option>
</select>
</div>
<button id="filter-reset">Reset</button>
</div>
<div id="listings"></div>
<div id="empty">Geen woningen gevonden met deze filters.</div>
<script>
const LISTINGS = {{ listings_json | safe }};
const DEFAULTS = {
'f-ov-mark': 45,
'f-ov-michelle': 45,
'f-fiets-mark': 40,
'f-prijs': 300000,
'f-opp': 65,
'f-sort': 'first_seen_desc',
};
// ── Helpers ──
function fmt_prijs(p) {
if (!p) return '—';
return '€\u202f' + p.toLocaleString('nl-NL');
}
function fmt_min(m) {
if (m == null) return '—';
return m + ' min';
}
function travel_class(val, warn, good) {
if (val == null) return '';
if (val <= good) return 'ok';
if (val <= warn) return '';
return 'warn';
}
function fmt_date(iso) {
if (!iso) return '—';
return iso.slice(0, 10);
}
function fmt_extra_val(v) {
if (v === null || v === undefined) return null;
if (typeof v === 'boolean') return v ? 'ja' : 'nee';
if (Array.isArray(v)) {
if (v.length === 0) return null;
// photos array: just show count
return v.length + ' foto\'s';
}
if (typeof v === 'object') return JSON.stringify(v).slice(0, 60);
const s = String(v);
if (s === '' || s === 'null') return null;
// truncate long description
return s.length > 120 ? s.slice(0, 120) + '…' : s;
}
function ef(label, val) {
if (val == null || val === '' || val === 'null') return '';
return `<div class="expanded-field">
<span class="ef-label">${label}</span>
<span class="ef-val">${val}</span>
</div>`;
}
// ── Card renderer ──
function render_card(l) {
const img = l.hero_image_url
? `<img src="${l.hero_image_url}" alt="${l.adres || ''}" loading="lazy">`
: `<div class="card-img-placeholder">🏠</div>`;
const ovM = travel_class(l.ov_mark, 45, 30);
const ovMi = travel_class(l.ov_michelle, 45, 30);
const fM = travel_class(l.fiets_mark, 40, 25);
const fMi = travel_class(l.fiets_michelle, 50, 35);
const extra_items = Object.entries(l.extra || {})
.map(([k, v]) => {
const fv = fmt_extra_val(v);
if (fv === null) return '';
return `<span class="extra-kv-item"><span class="ek">${k}</span><span class="ev">${fv}</span></span>`;
}).join('');
const extra_section = extra_items
? `<div class="extra-section"><h4>Extra</h4><div class="extra-kv">${extra_items}</div></div>`
: '';
return `
<div class="card" data-id="${l.id}">
<div class="card-compact">
<div class="card-img">
${img}
<span class="card-source">${l.source_makelaar}</span>
</div>
<div class="card-data">
<div class="card-header">
<div>
<div class="card-adres">${l.adres || '—'}</div>
<div class="card-stad">${l.stad || ''} ${l.postcode || ''}</div>
</div>
<a class="card-link" href="${l.url}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 3H2a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V7M8 1h3m0 0v3m0-3L5 7"/></svg>
link
</a>
</div>
<div class="card-prijs">${fmt_prijs(l.prijs)}</div>
<div class="card-meta">
<div class="card-meta-item ${ovM}">
<span class="icon">🚌</span><span>Mark</span><span class="val">${fmt_min(l.ov_mark)}</span>
</div>
<div class="card-meta-item ${ovMi}">
<span class="icon">🚌</span><span>Michelle</span><span class="val">${fmt_min(l.ov_michelle)}</span>
</div>
<div class="card-meta-item ${fM}">
<span class="icon">🚲</span><span>Mark</span><span class="val">${fmt_min(l.fiets_mark)}</span>
</div>
<div class="card-meta-item ${fMi}">
<span class="icon">🚲</span><span>Michelle</span><span class="val">${fmt_min(l.fiets_michelle)}</span>
</div>
${l.woonoppervlak ? `<div class="card-meta-item"><span class="icon">📐</span><span class="val">${l.woonoppervlak} m²</span></div>` : ''}
${l.kamers ? `<div class="card-meta-item"><span class="icon">🚪</span><span class="val">${l.kamers} kamers</span></div>` : ''}
</div>
<div class="card-toggle">meer ↓</div>
</div>
</div>
<div class="card-expanded">
<div class="expanded-grid">
${ef('Eerste gezien', fmt_date(l.first_seen))}
${ef('Datum aanmelding', l.datum_aanmelding ? fmt_date(l.datum_aanmelding) : null)}
${ef('Woningtype', l.woningtype)}
${ef('Bouwjaar', l.bouwjaar)}
${ef('Woonoppervlak', l.woonoppervlak ? l.woonoppervlak + ' m²' : null)}
${ef('Perceeloppervlak', l.perceeloppervlak ? l.perceeloppervlak + ' m²' : null)}
${ef('Kamers', l.kamers)}
${ef('Slaapkamers', l.slaapkamers)}
${ef('Energielabel', l.energielabel)}
${ef('Postcode', l.postcode)}
${ef('OV Mark', fmt_min(l.ov_mark))}
${ef('OV Michelle', fmt_min(l.ov_michelle))}
${ef('Fiets Mark', fmt_min(l.fiets_mark))}
${ef('Fiets Michelle', fmt_min(l.fiets_michelle))}
</div>
${extra_section}
</div>
</div>`;
}
// ── Filter + sort + render ──
function get_filters() {
return {
ov_mark: parseInt(document.getElementById('f-ov-mark').value) || Infinity,
ov_michelle: parseInt(document.getElementById('f-ov-michelle').value) || Infinity,
fiets_mark: parseInt(document.getElementById('f-fiets-mark').value) || Infinity,
prijs: parseInt(document.getElementById('f-prijs').value) || Infinity,
opp: parseInt(document.getElementById('f-opp').value) || 0,
sort: document.getElementById('f-sort').value,
};
}
const SORT_FNS = {
first_seen_desc: (a, b) => (b.first_seen || '').localeCompare(a.first_seen || ''),
first_seen_asc: (a, b) => (a.first_seen || '').localeCompare(b.first_seen || ''),
prijs_asc: (a, b) => (a.prijs || 0) - (b.prijs || 0),
prijs_desc: (a, b) => (b.prijs || 0) - (a.prijs || 0),
ov_mark_asc: (a, b) => (a.ov_mark ?? 999) - (b.ov_mark ?? 999),
fiets_mark_asc: (a, b) => (a.fiets_mark ?? 999) - (b.fiets_mark ?? 999),
opp_asc: (a, b) => (a.woonoppervlak ?? 0) - (b.woonoppervlak ?? 0),
opp_desc: (a, b) => (b.woonoppervlak ?? 0) - (a.woonoppervlak ?? 0),
};
function apply() {
const f = get_filters();
let filtered = LISTINGS.filter(l => {
if (f.ov_mark < Infinity && (l.ov_mark == null || l.ov_mark > f.ov_mark)) return false;
if (f.ov_michelle < Infinity && (l.ov_michelle == null || l.ov_michelle > f.ov_michelle)) return false;
if (f.fiets_mark < Infinity && (l.fiets_mark == null || l.fiets_mark > f.fiets_mark)) return false;
if (l.prijs != null && l.prijs > f.prijs) return false;
if (f.opp > 0 && (l.woonoppervlak == null || l.woonoppervlak < f.opp)) return false;
return true;
});
filtered.sort(SORT_FNS[f.sort] || SORT_FNS.first_seen_desc);
const container = document.getElementById('listings');
const empty = document.getElementById('empty');
const count = document.getElementById('count');
// Preserve open state
const open_ids = new Set(
[...container.querySelectorAll('.card.open')].map(el => el.dataset.id)
);
container.innerHTML = filtered.map(render_card).join('');
count.textContent = filtered.length + ' / ' + LISTINGS.length + ' woningen';
// Restore open state
open_ids.forEach(id => {
const el = container.querySelector(`.card[data-id="${id}"]`);
if (el) el.classList.add('open');
});
// Toggle on compact click
container.querySelectorAll('.card-compact').forEach(compact => {
compact.addEventListener('click', () => {
compact.closest('.card').classList.toggle('open');
const toggle = compact.querySelector('.card-toggle');
const isOpen = compact.closest('.card').classList.contains('open');
toggle.textContent = isOpen ? 'minder ↑' : 'meer ↓';
});
});
empty.classList.toggle('visible', filtered.length === 0);
}
// ── Init ──
document.querySelectorAll('#filters input, #filters select').forEach(el => {
el.addEventListener('input', apply);
});
document.getElementById('filter-reset').addEventListener('click', () => {
Object.entries(DEFAULTS).forEach(([id, val]) => {
document.getElementById(id).value = val;
});
apply();
});
apply();
</script>
</body>
</html>

60
src/web.py Normal file
View File

@@ -0,0 +1,60 @@
"""
web.py — huizenbot web interface
Single route: query SQLite, SSR listings into index.html template.
"""
import json
import sqlite3
import os
from flask import Flask, render_template, g
DB_PATH = os.environ.get("DB_PATH", "/data/huizenbot.db")
app = Flask(__name__)
def get_db():
if "db" not in g:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
g.db = conn
return g.db
@app.teardown_appcontext
def close_db(e=None):
db = g.pop("db", None)
if db is not None:
db.close()
@app.route("/")
def index():
conn = get_db()
rows = conn.execute("""
SELECT
id, url, source_makelaar, first_seen, last_seen, datum_aanmelding,
status, adres, postcode, stad,
prijs, woningtype, woonoppervlak, perceeloppervlak,
kamers, slaapkamers, bouwjaar, energielabel,
hero_image_url,
fiets_mark, fiets_michelle, ov_mark, ov_michelle,
extra
FROM woningen
WHERE status = 'beschikbaar'
ORDER BY first_seen DESC
""").fetchall()
listings = []
for row in rows:
d = dict(row)
try:
d["extra"] = json.loads(d["extra"]) if d["extra"] else {}
except Exception:
d["extra"] = {}
listings.append(d)
return render_template("index.html", listings_json=json.dumps(listings, ensure_ascii=False))
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)

View File

@@ -1,26 +0,0 @@
import sys
sys.path.insert(0, "../src")
from huizenbot import notify_email, RawListing
TEST_LISTING = RawListing(
url="https://example.com/test-woning",
source_makelaar="test",
adres="Teststraat 1",
stad="Delft",
postcode="2613AA",
prijs=350000,
hero_image_url=None,
)
TEST_TRAVEL = {
"fiets_mark": 20,
"fiets_michelle": 35,
"ov_mark": 30,
"ov_michelle": 45,
}
if __name__ == "__main__":
print("=== Email ===")
notify_email(TEST_LISTING, TEST_TRAVEL)
print(" verstuurd (check je inbox)")