first setup, travel works, bjornd api works

This commit is contained in:
2026-04-03 13:50:28 +02:00
commit 26d9d936f4
19 changed files with 1152 additions and 0 deletions

167
huizenbot-spec.md Normal file
View File

@@ -0,0 +1,167 @@
# 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