Compare commits
4 Commits
8450c33887
...
e1745841b1
| Author | SHA1 | Date | |
|---|---|---|---|
| e1745841b1 | |||
| fbe50790da | |||
| 423a429f56 | |||
| f1748214ce |
@@ -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
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
||||
**/__pycache__/
|
||||
|
||||
tests/cache/
|
||||
data/
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
159
src/huizenbot.py
159
src/huizenbot.py
@@ -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
|
||||
<strong>OV P1:</strong> {travel.get('ov_mark')} min</p>
|
||||
<p><strong>Fiets P2:</strong> {travel.get('fiets_michelle')} min
|
||||
<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
642
src/templates/index.html
Normal 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>m²</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
60
src/web.py
Normal 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)
|
||||
@@ -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)")
|
||||
Reference in New Issue
Block a user