# Huizenbot — Project Spec ## Doel Periodiek scrapen van makelaarswebsites in Delft en Schiedam, nieuwe woningen opslaan in SQLite, en pushnotificaties sturen via Home Assistant (+ optioneel email). Draait als één Docker container op homelab met cron. ## Projectstructuur ``` huizenbot/ ├── db.py # schema, migraties, upsert helpers ├── notify.py # HA webhook + email ├── travel.py # OSRM (fiets) + Navitia (OV) clients ├── base.py # AbstractScraper, orchestratie, run loop ├── adapters/ # één file per makelaar │ ├── bjornd.py │ └── ... ├── main.py # entry point (aangeroepen door cron) ├── config.py # locaties, credentials, endpoints └── Dockerfile ``` Nieuwe makelaar toevoegen = nieuwe file in `adapters/` die `AbstractScraper` implementeert. De runner pikt hem automatisch op via de base class registry. ## Database SQLite, één file gemount als Docker volume. ```sql CREATE TABLE woningen ( id TEXT PRIMARY KEY, -- sha256(url) url TEXT UNIQUE NOT NULL, source_makelaar TEXT NOT NULL, first_seen TEXT NOT NULL, -- ISO8601 last_seen TEXT NOT NULL, -- ISO8601, geüpdatet elke run datum_aanmelding TEXT, -- datum van makelaar zelf, indien beschikbaar status TEXT NOT NULL DEFAULT 'beschikbaar', -- enum: beschikbaar | onder_bod | verkocht -- locatie adres TEXT, postcode TEXT, stad TEXT, -- woning prijs INTEGER, -- euros, geen float woningtype TEXT, -- appartement | tussenwoning | hoekwoning | vrijstaand | ... woonoppervlak INTEGER, -- m2 perceeloppervlak INTEGER, -- m2, NULL voor appartementen kamers INTEGER, slaapkamers INTEGER, bouwjaar INTEGER, energielabel TEXT, -- media hero_image_url TEXT, -- reistijd in minuten (berekend bij first_seen, niet opnieuw) fiets_persoon1 INTEGER, fiets_persoon2 INTEGER, ov_persoon1 INTEGER, ov_persoon2 INTEGER, -- makelaar-specifieke velden die niet in het schema passen extra TEXT -- JSON (sqlite 3.38+ json_extract() werkt hierop) ); ``` Upsert strategie: `INSERT OR IGNORE` op `id` voor nieuwe woningen, daarna `UPDATE last_seen` en `status` op elke run. Reistijd wordt alleen berekend bij `first_seen`. ## Scraper architectuur ### AbstractScraper (base.py) Elke adapter erft hiervan en implementeert één methode: ```python def fetch_listings(self) -> list[RawListing]: ... ``` `RawListing` is een dataclass met exact de velden uit het schema (allemaal optioneel behalve `url`). De base class regelt: - deduplicatie / upsert naar DB - reistijdberekening aanroepen voor nieuwe woningen - notificatie triggeren voor nieuwe woningen - logging ### Twee adapter-smaken **API-based** (JSON response, bijv. makelaars met een interne REST API): - Doet een `httpx` request, parsed JSON direct naar `RawListing` **SSR-based** (HTML scraping): - Doet een `httpx` request met nette `User-Agent` header - Parsed HTML via `BeautifulSoup` Beide smaken zijn gewone subclasses — geen aparte base per smaak, het verschil zit alleen in de implementatie van `fetch_listings`. ## Reistijd (travel.py) Twee backends, beiden aangeroepen bij `first_seen` van een woning. **Fiets — OSRM** (publieke instance, geen key nodig): ``` http://router.project-osrm.org/route/v1/cycling/{lon},{lat};{lon},{lat}?overview=false ``` **OV — Navitia.io** (gratis tier, API key nodig): ``` https://api.navitia.io/v1/coverage/nl/journeys?from=...&to=...&datetime=... ``` Voor OV wordt een vaste reistijd op een doordeweekse ochtend (bijv. 08:30) gebruikt als referentie — niet real-time. Invoer zijn twee postcodes uit `config.py` (werklocatie persoon 1 en 2). Postcode → coördinaten via de Nominatim geocoder (OSM, geen key nodig, respecteer 1 req/s limiet). ## Notificaties (notify.py) ### Home Assistant Webhook call naar HA met een payload die de notification service aanroept. Bevat: - adres + stad - prijs - status - hero image URL (als `image` in de notification) - reistijden persoon 1 en 2 (fiets + OV) - directe link naar de listing ### Email (optioneel, fallback) Plain HTML mail via SMTP met dezelfde info. Handig als HA buiten bereik is. ## Configuratie (config.py) ```python PERSOON1_WERK_POSTCODE = "2600AA" PERSOON2_WERK_POSTCODE = "3000AA" HA_WEBHOOK_URL = "https://ha.jouwdomain.nl/api/webhook/huizenbot" SMTP_HOST = "..." SMTP_FROM = "..." SMTP_TO = "..." USER_AGENT = "Huizenbot/1.0 (+jouw@email.nl)" SCRAPE_INTERVAL_HOURS = 3 # alleen informatief, cron regelt de scheduling ``` Secrets (API keys etc.) via environment variables, niet in config.py. ## Docker & cron Één container, cron runt `main.py` elke 3 uur tussen 08:00 en 20:00: ```cron 0 8,11,14,17,20 * * * python /app/main.py >> /var/log/huizenbot.log 2>&1 ``` SQLite DB en logfile als named volumes gemount. ## Ethische scraping - `User-Agent` met contactinfo (zie config) - Één request tegelijk per domein - Respecteer `Retry-After` bij 429-responses - Geen nachtelijke runs - Alleen persoonlijk gebruik