168 lines
5.3 KiB
Markdown
168 lines
5.3 KiB
Markdown
# 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
|