# Immobiliensuche — Instruktionen für den nächsten Agenten

Dieses Verzeichnis (`docs-public/` im pontiswerk.eu-Workspace `agent-probe-workspace`,
Slug `agent-probe-cb7a40-agentpro`) ist die **vollständige** Ablage für dieses
Projekt: Code, Daten und die Status-Seite selbst. Alles hier ist **öffentlich
und von Suchmaschinen indexierbar** (bewusste Entscheidung des Nutzers,
2026-09-19) — deshalb: **niemals** Zugangsdaten, Tokens oder Passwörter in
dieses Verzeichnis schreiben. Siehe "Zugangsdaten" unten.

> ## ⚠ Zuerst lesen: lokale Kopie aktualisieren
>
> Der **Server (`docs-public/`) ist die maßgebliche Version**, nicht eine
> lokale Kopie. Wenn du diese Dateien schon einmal heruntergeladen hast
> (z.B. in einen Unterordner wie `realestate/`), dann **vor jeder Arbeit**:
>
> 1. `CHANGELOG.md` lesen und mit deinem Stand vergleichen (Datum des letzten
>    Eintrags). Ist der Server neuer oder du bist unsicher: **alles neu
>    herunterladen** (`list_files` rekursiv auf `docs-public`, dann jede Datei
>    per `read_file`), mindestens `INSTRUCTIONS.md`, `modules/`, `assets/`,
>    `index.html`, `data/`.
> 2. **Nie eine veraltete lokale Datei über die Server-Version schreiben.** Ein
>    alter `assets/app.js`/`index.html` würde z.B. das Filter-Panel entfernen,
>    ein altes `run_search.py` die Auswahl Wohnung/Grundstück. Vor dem
>    Hochladen die Server-Datei lesen und vergleichen.
> 3. Nach eigenen Änderungen: Eintrag oben in `CHANGELOG.md` ergänzen und
>    zusammen mit den geänderten Dateien hochladen; danach Server-Datei
>    zurücklesen und mit der lokalen vergleichen.
> 4. Zugangsdaten gehören weiterhin **nicht** hierher (siehe "Zugangsdaten").
>
> Seit dem ersten Stand neu: Ergebnis-Filter auf der Seite, korrigierte
> `bebaubar`-Erkennung (`tag_text()` in `base.py`), Suche wahlweise nur
> Wohnung/Grundstück/beides (`run_search.py --kind`), weitere Portale,
> **Datenqualität** (`quality.py`: Duplikate, Pacht/Miete, Preis-Check) und
> **automatische Läufe 2×/Tag** (`scheduled_run.py`, `data/schedule.json`).
> Wer `run_search.py`/`scheduled_run.py` benutzt: neueste Version holen.
> Details im `CHANGELOG.md`.

## Ziel & Kriterien

Laufende Suche nach zwei Objekttypen, Ergebnisse auf `index.html` (dieser
Ordner) als Kandidatenliste mit Favoriten/Ablehnen-Funktion:

**Miet-Wohnung**
- Österreich / Tirol / Bezirk Kufstein **und angrenzende Bezirke** (Schwaz,
  Kitzbühel — seit 2026-09-19 erweitert, da Kufstein allein sehr wenige
  Treffer hatte; siehe `criteria.json`: `districts`, eine Liste statt eines
  einzelnen Bezirks)
- ≥ 3 Zimmer, ≥ 80 m², ≤ 1600 €/Monat

**Grundstück**
- Österreich (ganz)
- ≤ 80.000 € (Gesamtpreis, nicht €/m²!)
- eher abgelegen / Natur / Alm; optimal mit Bach, Wald oder Quelle in der Nähe
- bebaubar

Diese Kriterien liegen strukturiert in `data/criteria.json` und werden von
`modules/run_search.py` gelesen. **Nicht hart im Code ändern** — entweder
`data/criteria.json` bearbeiten, oder über das Einstellungen-Panel auf der
Seite (siehe unten).

## Architektur

```
docs-public/
  index.html          <- die Status-/Übersichtsseite selbst
  data.js              <- von run_search.py generiert: window.REALESTATE_DATA
                          (als JS-Variable statt JSON-Datei + fetch(), damit
                          die Seite ohne CORS-Ärger von überall/file:// geht)
  assets/
    style.css
    app.js             <- Rendering, Sortierung, Ergebnis-Filter, Favoriten/Ablehnen
                          (localStorage), Einstellungen-Panel
  data/
    criteria.json       <- aktuell verwendete Suchkriterien (siehe oben)
    candidates.json      <- Rohdaten, die Quelle für data.js; wird bei jedem
                          Lauf gemerged (siehe "Wiederholte Läufe" unten)
    schedule.json        <- Zeitplan der automatischen Läufe (siehe "Automatische
                          Läufe"); öffentlich, ohne Geheimnisse
    plz_bundesland.json  <- PLZ → Bundesland (GeoNames-Postleitzahlen, CC BY 4.0,
                          https://www.geonames.org; 2447 eindeutige PLZ)
    run_log.json         <- Protokoll der letzten 60 Läufe (Treffer je Portal,
                          neu/delisted, Warnungen, Qualitäts-Zähler)
  modules/
    requirements.txt
    tests/               <- test_tagging / test_quality / test_scheduler
                          (`python3 -m unittest discover tests`, kein Netz nötig)
    run_search.py        <- Einstiegspunkt: alle Portale durchsuchen, mergen,
                          Qualitäts-Pass, candidates.json + run_log.json + data.js
    quality.py           <- Datenqualität: Duplikate, Pacht/Miete/Baurecht,
                          verdächtige Preise, HTML-Entities (siehe unten)
    scheduled_run.py     <- Cron-Einstieg: prüft data/schedule.json, holt den
                          Workspace frisch, sucht, lädt NUR Datendateien hoch
    mcp_sync.py          <- kleiner MCP-Client dafür (Token nie hier im Ordner)
    install_cron.py      <- schreibt/entfernt den Crontab-Block (lokal beim Nutzer)
    portals/
      base.py             <- Listing-Datenklasse + PortalSearcher-Interface,
                          inkl. Checkliste, was vor einem neuen Portal-Modul
                          zu prüfen ist
      bazar.py            <- bazar.at (öffentliche JSON-API)
      immoscout24.py      <- immoscout24.at (requests + __INITIAL_STATE__-Parsing)
      tipsat.py           <- immobilien.tips.at (requests + DOM-Parsing der SEO-SERPs)
      sreal.py            <- sreal.at (requests + DOM-Parsing von Suche + Details)
      raiffeisen.py       <- raiffeisen-immobilien.at (öffentliche JSON-API + Details)
      meinbezirk.py       <- meinbezirk.at (DOM-Parsing /cad/ + Meta-Description)
      immobiliennet.py    <- immobilien.net (ImmoScout24-Backend, __INITIAL_STATE__)
      willhaben.py        <- willhaben.at (requests + __NEXT_DATA__-Parsing, 2 s Delay)
  INSTRUCTIONS.md          <- diese Datei
  CHANGELOG.md             <- was sich wann geändert hat (vor der Arbeit lesen)
```

## Portal-Status (Stand 2026-09-19 — vor dem Hinzufügen eines neuen Portals neu prüfen!)

| Portal | Status | Grund |
|---|---|---|
| **bazar.at** | ✅ verwendet | Robots.txt erlaubt, keine Bot-Sperre, hat sogar eine öffentliche JSON-API (`/api/real-estate`), die `bazar.py` direkt nutzt (kein Playwright/DOM-Scraping nötig) |
| **willhaben.at** | ✅ verwendet | **Seit 2026-09-20 explizit freigegeben (User-Override).** `willhaben.py` implementiert (requests + `__NEXT_DATA__`-Parsing, 2 s Delay): Miete pro Bezirk (`/iad/immobilien/mietwohnungen/tirol/{kufstein,schwaz,kitzbuehel}` mit `PRICE_TO`), Grundstücke AT-weit (`/iad/immobilien/grundstuecke/grundstueck-angebote?PRICE_TO=80000`, `?page=N` 30/Seite). Kein Bot-Wall (kein Captcha/Turnstile, auch python-UA 200), SSR wie `immoscout24.py` — Playwright nicht nötig. Caveat: `robots.txt` verbietet Spider formal weiterhin (Policy nun overridden), Server-Filter Fläche/Zimmer nicht strikt → clientseitig nachgeprüft. |
| **immoscout24.at** | ✅ verwendet | Keine Bot-Sperre für serverseitig gerenderte Suchseiten (Recon 2026-09-19, Detail-Anleitung weiter unten). `immoscout24.py` implementiert (requests + `__INITIAL_STATE__`-Parsing, 2 s Delay): Wohnung-Mieten für die Tiroler Bezirke (Filter `primaryAreaFrom`/`numberOfRoomsFrom`/`primaryPriceTo`), Grundstücke ganz Österreich (`primaryPriceTo` — zusätzlich clientseitig gegen `price_max` geprüft, s.u.). Caveat: Captcha bleibt theoretisch möglich bei hohem Tempo → Politeness-Delay einhalten; **immobilien.net / immodirekt.at** trotzdem nicht separat implementiert. |
| derstandard.at | ❌ nicht verwenden | robots.txt sperrt `anthropic-ai`, `ClaudeBot`, `Claude-Web` namentlich |
| real.at | – nicht relevant | ist B2B-Software für Hausverwaltungen, keine Inserate-Plattform |
| wohnnet.at | – nicht relevant | ist ein Bau/Sanierung-Ratgeber-Magazin, keine strukturierte Inserate-Suche |
| **sreal.at** (s REAL, Erste-Bank-Gruppe) | ✅ verwendet | robots.txt permissiv, keine Bot-Sperre. `sreal.py` implementiert (requests + DOM-Parsing von `/de/immobilien-suche`, je 1,5 s Delay für Seiten und Details): Miete `buyingType=rent&objectType=flat` (clientseitig Bundesland Tirol + Preis-Nachprüfung — Server-Filter für Ort/Preis nicht streng), Kauf `buyingType=buy&objectType=property` ganz Österreich mit Preis-Nachprüfung. Zimmer + Beschreibung kommen von der Detailseite. Details im Abschnitt unten. |
| **immobilien.tips.at** (Subdomain von tips.at) | ✅ verwendet | robots.txt erlaubt explizit `/immobilien/*`; tips.at selbst erlaubt `anthropic-ai` im Fließtext ausdrücklich. `tipsat.py` implementiert (requests + DOM-Parsing der serverseitig gerenderten SEO-Pfade, 1,5 s Delay): Wohnungen mieten in den Tiroler Bezirken (`/mieten/wohnung/<bezirk>-bezirk?pt=&rf=&sf=`), Grundstücke ganz Österreich (`/kaufen/grundstueck/oesterreich?pt=`, Preis clientseitig nachgeprüft). Details im Abschnitt unten. |
| **raiffeisen-immobilien.at** | ✅ verwendet | robots.txt komplett offen, keine Bot-Sperre. `raiffeisen.py` implementiert (öffentliche JSON-API `/api/public/realties` wie bazar.at, 1,5 s Delay): Miete `sales_type=rent&usage_type=living&category[]=flat` pro Bezirk (`district[]`, Filter verifiziert streng), Kauf `category[]=plot` ganz Österreich mit Preis-Nachprüfung. Beschreibung fürs Tagging von der Detailseite. Details im Abschnitt unten. |
| ohne-makler.at | ❌ nicht für Such-Crawling verwenden | robots.txt sperrt explizit `/immobilie/list/` (genau die Ergebnisliste, die wir bräuchten). Einzelne Inserat-Seiten wären evtl. ok, Listen-Crawling nicht. |
| remax.at | ❌ nicht verwenden | Cloudflare-Turnstile-Sperre ("Security Verification") schon auf `robots.txt` selbst — Bot-Sperre (Recon 2026-09-19) |
| engelvoelkers.com/at | ❌ nicht für Such-Crawling verwenden | robots.txt sperrt die Suchpfade (`*/search/*`, `*/suche?*`, `*/propertysearch?*`) |
| immowelt.at | ❌ nicht verwenden | Startseite liefert 403 mit Captcha-Skript (`geo.captcha…`) — Bot-Sperre |
| immo.tt.com (Tiroler Tageszeitung) | ❌ nicht verwenden | robots.txt: `User-agent: ClaudeBot` → `Disallow: /` — Claude namentlich gesperrt |
| kleinanzeigen.at | ❌ nicht weiter geprüft | Startseite lädt Cloudflare Turnstile; `/robots.txt` liefert kein gültiges robots.txt |
| **findmyhome.at** | 🟡 geprüft, nicht implementiert | robots.txt erlaubt (nur technische Pfade gesperrt), keine Sperre (reCAPTCHA nur im Kontaktformular), serverseitiges HTML, `/immo/grundstueck` = 910 Treffer ganz AT. **Aufwand hoch:** Legacy-PHP mit undurchsichtigem Zustand (`vars=id:13;gs_e:1;…`), Preisfilter/Sortierung (`sort=sort_pr`) nicht reproduzierbar, Karten-Markup uneinheitlich (Top-Inserate doppelt, nur ~4 von 20 Karten sauber parsebar mit einfachem Split). Nur mit gezieltem Reverse-Engineering von `vars` sinnvoll. |
| immosuchmaschine.at | 🟡 geprüft, nicht implementiert | robots.txt offen; verlinkt auf immobilienscout24.at (gehört erkennbar zur ImmoScout24-Gruppe) — Überschneidung mit `immoscout24.py` zu erwarten, Mehrwert ungeprüft |
| laendleimmo.at | 🟡 geprüft, nicht implementiert | robots.txt offen, kein Wall, aber Regionalportal nur für Vorarlberg — für Tirol-Miete irrelevant, für Grundstücke nur Vorarlberger Bestand |
| kitzimmo.at, tiroler-immobilien.at | – nicht relevant | Einzelmakler-Websites (nur eigener Bestand) |
| **meinbezirk.at** (`/cad/`-Marktplatz) | ✅ verwendet | `meinbezirk.py` implementiert (requests + DOM-Parsing, 1,5 s Delay): Miete pro Bezirk (`/cad/<bezirk>/c-vermietung-wohnung`), Land AT-weit (`/cad/c-grund-verkauf?f=0&t=<max>`), ID = Slug ohne `_c`-Suffix (dedup über Regionalausgaben), Fläche/Zimmer per Text-Parse (nur sicherste Verstöße fallen), Beschreibung aus `meta[name=description]`, Lockpreis-Schutz (<10 €/m² → Preis None + `preis_unklar`, wie `bazar.py`). Bestand weiter dünn (Miete Bezirke 0, Land ~2), Details im Abschnitt unten. |
| gruenderzeit.at | – nicht relevant | keine dedizierte Immobilien-Suche mit Filtern gefunden bei kurzer Recon |
| **remax.at** (RE/MAX Österreich) | ❌ nicht verwenden | Bot-Wall: Cloudflare Turnstile-Challenge schon auf `/robots.txt` und Homepage (HTTP 298, Recon 2026-09-19) — kein Scraping, keine Umgehung (Checkliste in `base.py`) |
| **immobilien.net** (ImmoScout24-Gruppe) | ✅ verwendet | robots.txt offen, kein Captcha (nur UA-sensitives CloudFront-401 bei kargen UAs — Browser-UA wie überall verwendet). `immobiliennet.py` implementiert (React-`__INITIAL_STATE__` wie `immoscout24.py`, 2 s Delay): Miete `/mietwohnungen/tirol/<bezirk>` mit `primaryPriceTo`/`numberOfRoomsFrom`/`livingAreaFrom`, Land `/grundstuecke/oesterreich` (RENT-Anteil clientseitig raus — `transferType`-Param wird ignoriert), `?page=N` (25/Seite). Details im Abschnitt unten. |

Bevor ein neues Portal ergänzt wird: `curl -A "Mozilla/5.0" https://<site>/robots.txt`
prüfen, und die Seite einmal mit Playwright headless laden — erscheint ein
Roboter-Check/Captcha, das Portal nicht verwenden (siehe `base.py`-Docstring
für die volle Checkliste). Bei immoscout24.at ist das 2026-09-19 geprüft worden:
kein Captcha für die serverseitig gerenderten Suchseiten (siehe Abschnitt unten).

## immoscout24.at — wie die Suche funktioniert (Recon 2026-09-19)

Kanonische Domain: `https://www.immobilienscout24.at` (nicht `immoscout24.at`).
robots.txt ist permissiv (`Allow: /`); AI-Agents wie `Claude-User`,
`OAI-SearchBot` sind namentlich gelistet.

**Daten liegen direkt im serverseitig gerenderten HTML** — kein XHR, kein
Playwright nötig für die Ergebnislisten. Es gibt ein
`<script>window.__INITIAL_STATE__={...}</script>` im HTML; die Treffer stehen
unter `reduxAsyncConnect.pageData.results.hits` (15 pro Seite), die
Ergebnissumme unter `searchUI.totalHits`, die aktiven Filter unter
`reduxAsyncConnect.pageData.params` (dort stehen auch die exakten
Parameternamen nach dem Rendern der Filter).

JSON-Parsing-Rezept (das State-JSON enthält `undefined`-Literale):
```python
import json
m = "window.__INITIAL_STATE__="
i = html.index(m); start = i + len(m); end = html.index("</script>", start)
s = html[start:end].replace(":undefined", ":null").replace(",undefined", ",null")
state, _ = json.JSONDecoder().raw_decode(s)
```

**URL-Schema:** `/regional/{area}/{bezirk?}/{kategorie}` mit Paginierung
`/seite-{n}`.
- Bereiche: `oesterreich`, `tirol`, `salzburg`, `kaernten`, ... (`wien` ist
  `/regional/wien/wien/immobilien`).
- Tiroler Bezirke (Area-Slugs): `kufstein`, `schwaz`, `kitzbuehel` —
  z.B. `/regional/tirol/kufstein/immobilien` (region-Codes im State:
  007=Tirol, 007005=Kufstein, 007009=Schwaz, 007004=Kitzbühel).
- Kategorien: `wohnung-mieten`, `wohnung-kaufen`, `haus-mieten`,
  `haus-kaufen`, `grundstuecke` (Land), `wohnungen`, `immobilien` (alles).

**Verifizierte Query-Parameter (AT!):**
- Wohnung mieten Tirol/Bezirke:
  `.../regional/tirol/kufstein/wohnung-mieten?primaryAreaFrom=80&numberOfRoomsFrom=3&primaryPriceTo=1600`
  → **21 Treffer** (Kufstein allein, gefiltert). Ungefiltert: Tirol 651,
  Kufstein 140, Schwaz 464, Kitzbühel 2323, ganz Österreich 12616.
  Kategorie `wohnung-mieten` setzt `transferType=RENT`, `estateType=APARTMENT`.
- Grundstücke ganz Österreich: `/regional/oesterreich/grundstuecke` → 5725
  Treffer (`estateType=PROPERTY`, `transferType=BUY`). Preis limitieren:
  `?primaryPriceTo=80000` → 612 Treffer; Fläche `?plotAreaFrom=...`.
  Achtung: Grundstücke z.T. als Projekt/Makler mit `primaryPrice=0` bzw.
  `mainKeyFacts`-Bereichen ("798 – 2.095 m²") — bei `primaryPrice=0` Plausi-Check.

**Treffer-Felder (pro Hit):** `exposeId`, `links.absoluteURL`,
`headline`, `addressString`, `primaryPrice`, `primaryArea`, `numberOfRooms`,
`location.lat/lon`, `meta.transferType/estateType`, `dateCreated`,
`isPrivate`, `primaryPictureImageProps.src`.

Beispiel-URLs, HTML-Snapshots und gefüllte State-Dumps liegen in
`private/recon/is24-immoscout/` im Workspace (nicht öffentlich):
`is24-RECON-NOTES.md`, `is24_wm_plain.html` (+ `_state.json`),
`is24_land_plain.html` (+ `_state.json`), `is24_tirol.html` (+ `_state.json`).

Das DE-Pfad-Schema `/wohnung/mieten/<ort>` funktioniert auf der AT-Seite
**nicht** (liefert react-404). `immoscout24.py` ist auf dieser Basis
implementiert: `requests` + State-Parsing, 2 s Delay, fehlende Seite / 404 als
Ende der Paginierung. Wichtig dabei: `primaryPriceTo` wird vom Server **nicht
streng** gefiltert (12 von 372 Grundstücken lagen bei einem Lauf über
80.000 €) — daher prüft `immoscout24.search_land` den Preis zusätzlich
clientseitig. `totalHits` im State ist außerdem oft zu hoch (612 gelistet,
nur 372 tatsächlich paginierbar) — Abbruch über `nextURL`/leere Seite, nicht
über `totalHits`, ist korrekt. Paginierungs-URL ist `/.../seite-{n}?query`
(Query **hinter** `/seite-{n}`).

## immobilien.tips.at — wie die Suche funktioniert (Recon 2026-09-19)

**Ergebnislisten sind serverseitig gerendertes HTML** — kein JS nötig. Kein
Bot-Check angetroffen. Engine „cm" (classmarkets-Stil), aggregiert auch
Fremd-Feeds (`immo.sn.at`, `wim_ioon_realestate`, s-real), Detail-URLs bleiben
kanonisch auf `immobilien.tips.at`.

**URL-Schema (SEO-Pfade, das ist was `tipsat.py` nutzt):**
`/{mieten|kaufen}/{typ}/{ort}?pt=&rf=&sf=&page=N` — Filter werden
serverseitig **streng** angewendet (verifiziert):
- Mietwohnungen: `/mieten/wohnung/kufstein-bezirk` (auch `schwaz-bezirk`,
  `kitzbuehel-bezirk`, `tirol`), mit `?pt=<preis_max>&rf=<zimmer_min>&sf=<fläche_min>`
- Grundstücke: `/kaufen/grundstueck/oesterreich?pt=<preis_max>`, `?page=N`
  (20/Page); `pt=80000` → 57 Treffer, alle mit Preis ≤ 80.000 € (Spot-Check)
- Weitere Typen: `haus`, `grundstueck`, `gewerbe`, `anlageobjekt`; Regionen
  als Pfad (`oesterreich`, `salzburg`, ...), Formular-Tokens (`t`) u.a.
  `apartment:rental:living`, `plot:sale:living`
- **Nicht** senden: `l=`/`a=` (stehen nur im Tracking-`data-href` der Karten
  und geben 404 auf den SEO-Pfaden). `/suchergebnisse` ist eine JS-Shell,
  nicht scrapen.

**Karten-DOM:** `div.card.mb-3.item-wrap.js-serp-item` (pro Karte u.a.
`data-id`, `data-title`, `data-price` („€ 45.000,00"), `data-rooms`,
`data-space`, `data-address`); Titel/URL in `a.js-item-title-link`
(`href` = `/immobilien/<slug>-<ID>`, ID = Endstück); Beschreibung in
`span.js-show-more-item-sm`; Trefferzahl in `data-totalitems` (fehlt bei
0 Treffern).

**Caveats:** Miet-Abdeckung in den Tiroler Ziel-Bezirken praktisch null
(Recon: 1 Treffer Tirol-weit nach Filter, Bezirke 0–2) — Hauptnutzen sind
Grundstücke. Landpreise teils pro m² oder „auf Anfrage" (Preis=None);
„Baurechtsgrundstück"-Inserate mit ~1.000 € sind Monats-Baurechtszins, kein
Kaufpreis — auf der Karte prüfen. Für Grundstücke setzt `tipsat.py`
`rooms=None` (manche Karten tragen `data-rooms`-Reste).

## sreal.at — wie die Suche funktioniert (Recon 2026-09-19)

Bank-Maklernetz (Erste-Gruppe, justimmo-Backend — teilt Bestand mit anderen
Portalen, dasselbe Inserat taucht z.B. auch auf immobilien.tips.at auf;
portalübergreifende Duplikate sind ok, Merge läuft pro Portal-ID).
Serverseitig gerendertes HTML, kein JS nötig.

**Suche:** `GET /de/immobilien-suche` mit (kodiert wie das Formular,
`objectType` indiziert):
`f[buyingType]=rent|buy`, `f[objectType][0]=flat|property`,
`f[price][max]`, `f[rooms][min]`, `f[area][min]`, `p=N` (20/Seite).
Trefferzahl als „N Objekte". `/de/kauf/angebot/1` ist nur eine Landingpage,
nicht die Ergebnisliste.

**Karten** (`div.realty-item-container`, Klasse enthält Bundesland +
`rentable|buyable`, ggf. `investment`): Detail-Link
`/de/immobilie/<id>[/slug]`, Titel, „PLZ Ort", Info-Paare
(Grund-/Wohn-/Nutzfläche, Kaufpreis/Bruttomiete). Weder Zimmer noch
Beschreibung auf der Karte.

**Details** pro Inserat (1 Request, 1,5 s Delay): `ul.realty-detail-main-data`
(Label/Wert-Paare, u.a. Zimmer), `div.realty-detail-description` (voller
Exposé-Text fürs Tagging). `sreal.py` holt Details für alle Land-Treffer
(~30) und alle Tirol-Miet-Treffer.

**Caveats:** `f[location_or_id][]` ignoriert Freitext („Tirol" ändert nichts)
— Miete wird clientseitig auf Bundesland-Klasse `Tirol` gefiltert
(Karten kennen nur „PLZ Ort", keinen Bezirk). Miet-Server-Filter nicht
streng (1.650-€-Karte bei max=1600; Büros rutschen durch `flat`) — Preis
immer clientseitig nachprüfen. Miet-Abdeckung Tirol aktuell null
(27 gefilterte Flats österreichweit, keine in Tirol).

## raiffeisen-immobilien.at — wie die Suche funktioniert (Recon 2026-09-19)

Maklernetz (Raiffeisen-Gruppe). robots.txt leer, kein Bot-Check. Eigene
Vue-Komponente `<realty-search>`, dahinter eine **öffentliche JSON-API**
(`<meta name="api-url">`, wie bazar.at) — kein DOM-Scraping für Listen nötig.

**Suche:** `GET /api/public/realties` mit `sales_type=rent|buy`,
`usage_type=living`, `category[]=flat|house|plot|asset|commercial|other`
(plot = Grundstücke), `price_to`, `rooms_from`, `area_from`,
`district[]=Kufstein` (Namen aus `/api/public/location/autocomplete`),
`page=N` (24/Seite). Antwort: `{"count": "84 Immobilien and 3 Projekte
gefunden", "records": "<html>", "pagination": "<html>"}`.

**Karten** (in `records`): Link `/de/immobilien/<deal>/<plz>/<ort>/<slug>.<id>`
(ID = numerisches Ende), „PLZ Ort", Titel, `dt`/`dd`-Paare (Grund-/Wohnfläche,
Kaufpreis oder **Kaufpreis/m²**, ggf. Zimmer). Weder Beschreibung noch
Bezirk auf der Karte (`district` bleibt None).

**Details** pro Land-Treffer (1 Request, 1,5 s Delay): lange `<p>`-Absätze
(ohne Cookie-Hinweis) als Exposé-Text fürs Tagging.

**Caveats:** „Projekte" (Bauträger) werden mitgezählt, aber übersprungen —
nur Einzel-Inserate. Per-m²-Preise sind hier explizit gelabelt
(`Kaufpreis/m²`, z.B. 350 €/m² × 821 m² ≈ 287 k€): `raiffeisen.py` streicht
nur beweisbar Über-Cap (Mindestfläche × m²-Preis > `price_max`), Rest bleibt
mit Preis None + Tag `preis_pro_m2` sichtbar (Spanne in der Beschreibung).
Miet-Abdeckung Ziel-Bezirke null (Filter verifiziert streng: Wien 21/21,
Kufstein/Tirol 0).

## meinbezirk.at — wie die Suche funktioniert (Recon 2026-09-19)

RegionalMedien-Austria-Kleinanzeigen (`/cad/`). robots.txt permissiv
(nur /build/, /resources/ gesperrt), kein Bot-Check. Serverseitig
gerendertes HTML, kein JS nötig.

**Suche:** `/cad/<region>/<kategorie>` mit `?f=<min>&t=<max>` (Preis „von/
bis", verifiziert streng) — z.B. `/cad/kufstein/c-vermietung-wohnung`,
`/cad/c-grund-verkauf?f=0&t=80000` (AT-weit). Sortierung per
`?sortField=price&sortDirection=asc` (ungenutzt). Keine Paginierung
(`page=` wird ignoriert, alles auf einer Seite). Leere Bezirke liefern
200 mit 0 Artikeln (kein 404).

**Karten** (`article.advertListItem`): Titel-Link
`/cad/<region>/<kat>/<slug>_c<id>`, Teaser-`p` (3 Zeilen), `div.price`
(„530,00 €" + Festpreis/VB), `div.meta-color` (PLZ Ort), Datum. Dasselbe
Inserat erscheint in mehreren Regionalausgaben (×3 beobachtet) — native ID
ist der Slug OHNE `_c`-Suffix (dedup). Keine strukturierte Fläche/Zimmer:
`meinbezirk.py` parst sie per Regex aus Titel+Teaser (`26m²`, `3 Zimmer`),
unbekannt bleibt None; Mindest-Verstöße fallen nur bei sicherem Parse.

**Details:** Volltext steht als `<meta name="description">`
(og:description identisch) — kein DOM-Parsing nötig. `meinbezirk.py` holt
Details nur für Land-Treffer (wenige).

**Caveats:** Bestand minimal (Miete Ziel-Bezirke 0 — 1× 60 m² in Tirol fällt
durch `area_min`; Land ~2, beide Lockpreise). Lockpreis-Schutz wie
`bazar.py`: Land <10 €/m² Gesamt → Preis None + `preis_unklar` (Ferlach
„Eur 120"/730 m², Matrei 600 €/361 m²).

## immobilien.net — wie die Suche funktioniert (Recon 2026-09-19)

ImmoScout24-Gruppe (Bilder von pictures.immobilienscout24.de, gleiche
React-State-Technik wie `immoscout24.py`), aber eigener Bestand
(Kufstein gefiltert 9 vs 21, Land 569 vs 612). robots.txt offen
(`disallow:` leer), kein Captcha/Turnstile. Nur: karge UAs bekommen
CloudFront-401 auf `/` — mit Browser-UA (Standard aller Module) 200.

**Suche:** `/mietwohnungen/tirol/<kufstein|schwaz|kitzbuehel>` mit
`primaryPriceTo`/`numberOfRoomsFrom`/`livingAreaFrom`;
`/grundstuecke/oesterreich?primaryPriceTo=<max>`; `?page=N` (25/Seite,
hinter dem Limit 200 mit 0 Hits). State: `properties.hits` /
`properties.totalHits`; JSON mit `undefined`-Literalen (gleiches
Parsing-Rezept wie is24).

**Treffer-Felder:** `exposeId`, `headline`, `addressString`,
`links.toExpose` (relativ), `type.estateType/transferType`,
`keyFacts[]` (Miete (Netto)/Gesamtmiete/Zimmer/Wohnfläche/
Grundstücksfläche/Kaufpreis), `sampleDescriptionWithoutHtml` (Tagging).

**Caveats:** `/grundstuecke/` mischt KAUF und MIETE (Baugrund zum Mieten,
Mobilheimplätze) und `&transferType=BUY` wird ignoriert —
`immobiliennet.py` filtert clientseitig auf BUY. Landkarten oft ohne Preis
(nur Fläche) → Preis None bleibt sichtbar; vorhandene Preise werden
clientseitig nachgeprüft. Miete: Preis = Gesamtmiete, sonst Netto.
Abdeckung Ziel-Bezirke: kufstein 9, schwaz 1, kitzbuehel 4 (gefiltert).

## willhaben.at — wie die Suche funktioniert (Recon 2026-09-20, freigegeben 2026-09-20)

Marktplatz (willhaben, seit 2006). robots.txt verbietet formal Spider (`expressively forbidden`), aber **per User-Entscheidung 2026-09-20 freigegeben** (vorher ❌). Kein Bot-Wall: weder Captcha/Turnstile noch Datadome/Kasada/Akamai — `curl -A python-requests/2.31.0` liefert ebenfalls 200 + volles SSR, 5× 200 in Folge, kein Rate-Limit. Deshalb **kein Playwright** — plain `requests` wie `immoscout24.py`.

**Stack:** Next.js SSR, Daten in `<script id="__NEXT_DATA__">` (JSON, ~380 kB). Parsing wie `immoscout24.py` aber ohne `undefined`-Normalisierung:
```python
import re, json
m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
data = json.loads(m.group(1))
sr = data['props']['pageProps']['searchResult']  # is404 -> 404-Seite
ads = sr['advertSummaryList']['advertSummary']  # 30/Seite
attrs = {a['name']: a['values'] for a in ad['attributes']['attribute']}
```

**URL-Schema + verifizierte Query-Parameter:**
- Mietwohnungen: `/iad/immobilien/mietwohnungen/tirol/{kufstein,schwaz,kitzbuehel}` → 52/42/83 Treffer (Kufstein/Schwaz/Kitzbühel, ungefiltert). Filter `PRICE_TO` wird server-seitig respektiert (`?PRICE_TO=1600` → 37 statt 52), aber Fläche/Zimmer (`ESTATE_SIZE/LIVING_AREA_FROM`, `NO_OF_ROOMS_BUCKET=3X3`) nicht strikt — `willhaben.py` sendet nur `PRICE_TO` und prüft Fläche/Zimmer clientseitig (wie `immoscout24.py`).
- Grundstücke: `/iad/immobilien/grundstuecke/grundstueck-angebote` → 7804 AT-weit, `?PRICE_TO=80000` → 1094 (verifiziert, alle geprüften ≤ 80 000 €), `?page=N` (30/Seite, getestet bis Seite 2 → 30 weitere, keine Cursor nötig).
- Paginierung: `?page=N` reicht (Next.js SSR liefert neue `__NEXT_DATA__` pro Seite); `pagingLinksList.contextLink` enthält `sfId`+`publishedDateTo`-Cursor für API, wird für HTML nicht benötigt.

**Treffer-Felder:** `id`, `attributes.attribute` mit `HEADING`, `PRICE`, `ESTATE_SIZE/LIVING_AREA`, `NUMBER_OF_ROOMS`, `POSTCODE`, `LOCATION`, `DISTRICT`, `COUNTRY`, `SEO_URL` (relativ → `https://www.willhaben.at/iad/<SEO_URL>`), `BODY_DYN` (Beschreibung für `tag_text()`), `COORDINATES`.

`willhaben.py` (2 s Delay, `WillhabenSearcher`): Miete pro Bezirk, Land AT-weit, Preis clientseitig nachgeprüft, Tags via `tag_text()` + Heuristik `preis_pro_m2` (< 1000 € bei > 200 m²).

## Wiederholte Läufe (erneut suchen)

**Vor jedem Lauf den Nutzer fragen, was gesucht werden soll:** nur
Wohnungen, nur Grundstücke oder beides (Grundstücke dauern deutlich länger,
~900 Treffer). Die Antwort als `--kind` übergeben. Ohne `--kind` zeigt ein
interaktives Terminal ein Auswahlmenü; ein nicht-interaktiver Lauf (Agent)
bricht mit Fehler ab, statt zu raten.

```bash
cd docs-public/modules
python3 -m venv ../../agent-env   # falls noch keine venv existiert
../../agent-env/bin/pip install -r requirements.txt
../../agent-env/bin/python3 run_search.py --kind wohnung       # nur Miet-Wohnungen
../../agent-env/bin/python3 run_search.py --kind grundstueck   # nur Grundstücke
../../agent-env/bin/python3 run_search.py --kind beide         # beides
```

Bei einem Lauf nur einer Art bleiben die Kandidaten der anderen Art
unverändert — sie werden **nicht** als "nicht mehr online" markiert.
Dasselbe gilt pro Portal: `--portal <name>` (wiederholbar) beschränkt den
Lauf auf einzelne Portale, z.B.
`run_search.py --kind beide --portal meinbezirk.at`. Ein Portal-Einzellauf
markiert **nie** Kandidaten anderer Portale als delisted (`merge_candidates`
prüft `source` gegen die gesuchten Portale) — verifiziert per Scope-Test.

Das schreibt `data/candidates.json` und `data.js` neu. Bestehende Kandidaten
werden per `id` erkannt und behalten ihren `status` (favorite/rejected/viewed)
und `notes` über Läufe hinweg. Kandidaten, die nicht mehr in den
Suchergebnissen auftauchen, werden **nicht gelöscht**, sondern mit
`"delisted": true` markiert und auf der Seite mit "nicht mehr online"
gekennzeichnet — so bleiben eigene Notizen/Favoriten erhalten, auch wenn ein
Inserat offline geht.

Danach die geänderten Dateien per MCP `write_file` zurück nach
`docs-public/` im Workspace schreiben (siehe "Zugangsdaten" unten).

## Einstellungen-Panel auf der Seite

Die Seite hat ein "⚙ Such-Kriterien"-Panel, vorausgefüllt aus
`criteria.json`. **Wichtig zu verstehen:** `docs-public` ist statisches
Hosting ohne Schreib-API für anonyme Besucher. "Im Browser speichern" legt
die neuen Kriterien nur in `localStorage` des jeweiligen Browsers ab und
zeigt sie als JSON zum Kopieren an — es läuft dadurch **keine** neue Suche
und `criteria.json` auf dem Server ändert sich **nicht** automatisch. Der
Workflow ist: Nutzer ändert Werte → kopiert das JSON → gibt es einem Agenten
→ Agent überschreibt `data/criteria.json` per MCP `write_file` → Agent
führt `run_search.py` erneut aus.

## Ergebnis-Filter auf der Seite (seit 2026-09-19)

Zusätzlich zum Such-Kriterien-Panel (das steuert, *was gesucht wird*) gibt es
ein **Filter-Panel**, das die **bereits gefundenen** Inserate im Browser
eingrenzt. Rein clientseitig (`assets/app.js`, `matchesFilters()`), es wird
nichts nachgeladen und kein neuer Suchlauf ausgelöst.

- Filter gelten **pro Tab** (Miet-Wohnungen / Grundstücke), weil Miet- und
  Kaufpreise nicht vergleichbar sind. Gespeichert in `localStorage`
  (`realestate_filters_v1`), pro Browser.
- Volltext (alle Wörter müssen in Titel/Ort/Bezirk/Beschreibung vorkommen),
  Preis von/bis, Fläche von/bis, Ort/Bezirk (mit Vorschlagsliste aus den
  Daten), Portal-Chips, Merkmal-Chips (Tags, "alle" oder "mindestens eines"),
  Zimmer mindestens (nur Wohnung).
- Schalter: "Nicht mehr online ausblenden", "Unklare/verdächtige Preise
  ausblenden" (Tags `preis_unklar`/`preis_verdaechtig`), "Duplikate ausblenden",
  "Pacht/Miete/Baurecht ausblenden" (Tag `kein_kauf`, nur Grundstücke) — alle
  Standard **an**, ausgeblendete Mengen stehen als Zähler neben der Trefferzahl
  ("ausgeblendet: 166 Duplikate, 30 Pacht/…"). Dazu "Nur mit Preisangabe",
  "Nur mit Flächenangabe" und "Nur neu seit letztem Lauf" (Standard aus).
- **Parsing bleibt offen, eingeschränkt wird clientseitig:** Portal-Module und
  `quality.py` werfen nichts weg (Pacht-/Miet-/Baurecht-Inserate, Duplikate,
  seltsame Preise), sie **markieren** nur (Tags, `duplicate_of`, `also_on`).
  Was sichtbar ist, entscheiden allein die Schalter/Chips auf der Seite;
  die einzelnen Tags `pacht`/`miete`/`baurecht`/`mobil` sind auch als Chips filterbar.
- **Sortierung** (Auswahlfeld in der Werkzeugleiste, **pro Tab** getrennt und im
  Browser gemerkt, `realestate_sort_v1`): Relevanz (nur Grundstücke; 2 × gewünschte +
  1 × bevorzugte Merkmale aus `criteria.json`, dann Preis), **Neu hinzugefügt**
  (= `first_seen`), Zuerst gefunden, **Zuletzt gesehen** (= `scraped_at`, wird bei
  jedem Lauf erneuert, in dem das Inserat noch da ist — alte Werte = lange nicht
  mehr gesehen), Preis, €/m², Fläche, Zimmer (nur Wohnungen), Ort A–Z (Bundesland,
  dann Ort), Titel A–Z. **Unbekannte Werte stehen immer am Ende**, egal in welche
  Richtung; Gleichstand → günstiger zuerst. Standard: Wohnungen Preis aufsteigend,
  Grundstücke Relevanz. Neue Sortierung = Eintrag in `SORTS` + `SORT_ORDER` in
  `assets/app.js`. Jede Karte zeigt "hinzugefügt vor …", "aktualisiert vor …", €/m²
  und (Grundstücke) "Merkmal-Treffer"; der zur Sortierung passende Wert ist fett.
- Grundstücke zusätzlich: Bundesland-Auswahl (mit Anzahl, "unbekannt" separat),
  Schalter "Ausland & andere Objekte ausblenden", Chips für die Grundstücksarten.
- Karten: Badge "neu" (`first_seen` ≥ Start des letzten Laufs), "auch auf:"-Links
  zu denselben Inseraten bei anderen Portalen, Kopfzeile mit letztem Lauf,
  Anzahl neuer Inserate, Auto-Update-Zeiten und Warnungen.
- Bewusste Regel: eine Grenze (z.B. Preis bis 80.000) schließt nur Inserate aus,
  deren Wert **bekannt** und außerhalb liegt. Inserate ohne Preis/Fläche
  bleiben sichtbar, bis "Nur mit Preis-/Flächenangabe" aktiv ist.
- Portal- und Merkmal-Chips werden aus `data.js` erzeugt — ein neues Portal
  oder ein neuer Tag taucht automatisch auf, ohne Änderung an HTML/JS.
- Liste zeigt 100 Treffer, "Weitere anzeigen" lädt jeweils 100 nach.
- Alle aus Portalen stammenden Strings werden vor dem Einfügen in HTML
  escaped (`esc()`); das gilt auch für neue Felder.

Tag-Erkennung: alle Portal-Module taggen über `tag_text()` in
`modules/portals/base.py` (nicht mehr per Substring im Modul). Regeln:
verneinte Erwähnungen ("kein Bauland", "KEIN BAUGRUNDSTÜCK", "ohne Wald",
"nicht bebaubar") zählen nicht; für `bebaubar` zählen außerdem bloß mögliche
Fälle nicht ("mögliches Baugrundstück", "Möglichkeit, in Bauland zu widmen",
"als Bauland vorgesehen"); "unbebaubar" trifft nicht, Komposita wie
"Wohnbaugrund" schon. Die schwachen Keywords `bebaut` (traf "unbebaut") und
`bebauungs` (traf "kein Bebauungsplan") sind entfernt. Für alle anderen Tags
gilt ein **Wortanfang-Match** ("bach" trifft nicht "Kirchbach"/"Rohrbach",
"quelle" nicht "Einkommensquellen", "alm" nicht "Palme"/"Walm") — echte
Komposita stehen deshalb ausdrücklich in `EXTRA_KEYWORDS` (`base.py`:
"bewaldet", "Mischwald", "Wildbach", "Bachlauf" …) und werden für jede
Keyword-Map automatisch ergänzt; `quality.py` trägt sie bei Altdaten nach
(nur hinzufügen, nie entfernen). Neue Tags/Keywords in `LAND_KEYWORDS` der
Module bzw. `EXTRA_KEYWORDS` ergänzen; Fall-Tests: `tests/test_tagging.py`.
Weiterhin gilt: Tags sind Keyword-Signale, kein Beweis — z.B. "Bauland-Agrar"
oder teilweise Widmung ("Grünland, davon 2000 m² Bauland") bleiben `bebaubar`.

## Datenqualität (`modules/quality.py`, seit 2026-09-19)

Läuft in `run_search.py` nach dem Mergen, ist idempotent (jeder Lauf berechnet
alles neu, Regel-Änderungen bereinigen also auch Altdaten). Audit-Ausgangslage
(1103 aktive Kandidaten): 356 Inserate in 164 Duplikat-Gruppen (130 davon
portalübergreifend), 289 Grundstücke ohne Preis, 32 Preise < 1.000 €, 8 Titel
mit `&amp;`, Pacht-/Miet-Inserate im Kauf-Bestand.

- **Text:** HTML-Entities in Titel/Beschreibung/Ort werden aufgelöst.
- **Angebotsart-Tags** (Grundstücke): `pacht`, `miete`, `baurecht`
  (Baurechtsgrund/-zins, "kein Eigentum"), `mobil` (Mobilheim/Wohnmobil) und
  der Sammel-Tag `kein_kauf`. "Baugrundstück **mit** Baurecht" = Baugenehmigung
  und zählt bewusst *nicht*. Verneinung ("nicht zu verpachten") wird beachtet.
- **`preis_verdaechtig`:** Grundstück < 1.000 € oder < 1 €/m² (meist €/m²- oder
  "ab"-Preis), Miete < 250 €. Preis bleibt unverändert, nur markiert.
- **Duplikate:** Schlüssel PLZ (bzw. Ortstext) + Fläche + Preis, nur wenn alle
  drei bekannt sind. Portalübergreifend genügt das; innerhalb desselben Portals
  muss zusätzlich der normalisierte Titel gleich sein (sonst könnten es
  verschiedene Parzellen eines Projekts sein). **Stufe 2:** gleiche PLZ + Fläche +
  Titel bei verschiedenen Portalen, wenn auf einer Seite der Preis fehlt
  (immobilien.net liefert bei Land keine Preise, listet aber viele
  immoscout24-Inserate erneut: 357 Treffer). Kanonisch ist der Kandidat mit
  Preis, dann mit dem frühesten `first_seen`; die anderen bekommen `duplicate_of`, der
  kanonische `also_on` (Liste Portal+URL). Favoriten/Status hängen an der
  `id` und bleiben erhalten.
- **Grundstück-Regeln (seit 2026-09-20)** — alles Markierungen, nichts wird verworfen:
  - `ausland`: Portal meldet Land ≠ AUT (bazar: `countryCode`), ein Land als Ort
    ("Ungarn"), 5-stellige PLZ, oder Titel "… in Ungarn" / "Slowenien - …".
    Bewusst **nicht** "nahe der Grenze zu Ungarn". `anderes_objekt`: strikte
    Phrasenliste im Titel (Wohnung, Penthouse, Hotel, Garage, Wohnmobil …) **und**
    kein Grundstückswort — "Baugrund für Ihr Einfamilienhaus" bleibt Grundstück.
    Beide hängen an einem Schalter "Ausland & andere Objekte ausblenden".
  - Grundstücksart als Tags `gruenland`, `landwirtschaft`, `garten`, `freizeit`
    (zusätzlich zu `bebaubar`/`wald`), als Chips filterbar. Garten/Freizeit und die
    Acker/Wiese-Wörter zählen nur im **Titel** (in Beschreibungen steht "grüne
    Wiese"/"mit Garten" bei fast jedem Baugrund); Grünland/Landwirtschaftlich auch
    in der Beschreibung.
  - `bundesland`: Wert des Portals (bazar liefert `subCountry`), sonst Lookup über
    die PLZ in `data/plz_bundesland.json` (`bundesland_src: "plz"`, wird jeden Lauf
    neu berechnet). Validierung gegen bazar: 326 von 327 PLZ identisch (99 %).
    Mehrdeutige PLZ (54, v.a. Burgenland/NÖ-Grenze) bleiben unbekannt. Abdeckung
    aktuell ~94 %. `Listing` hat dafür optionale Felder `bundesland`, `country`
    — andere Portale können sie setzen, müssen aber nicht.
  - `preis_verdaechtig` zusätzlich für **bebaubare** Grundstücke < 5 €/m² (Anteile,
    Pacht, €/m²-Angaben; Acker/Wald dürfen billig sein).
  - `pacht`/`miete` erkennen jetzt auch "zur Pacht", "Pacht Baugrundstück", "zum Mieten".
  - Ursache "Preis auf Anfrage" bei bazar (~270 Inserate) ist **kein** Parse-Fehler:
    die API liefert dort selbst `price: 0` + `priceOnRequest: true` (meist gewerbliche
    Makler). Nicht versuchen, das zu "reparieren".
- **Wohnungen** (73 aktiv, 2026-09-20 geprüft): Preis, Fläche, Zimmer ausnahmslos
  innerhalb der Kriterien, keine Auffälligkeiten; nur immobilien.net liefert bei
  Wohnungen keine Fläche. Keine zusätzlichen Regeln nötig.
- **Nicht gemacht (bewusst):** Preis/Fläche aus Freitext ziehen. Test an den
  289 Inseraten ohne Preis: Treffer waren "Mindestgebot", Jahresmiete oder
  Einzelparzellen-Preise — öfter falsch als richtig. Unbekannt bleibt unbekannt.
- **Lauf-Robustheit:** Schlägt die Suche eines Portals fehl oder liefert sie
  < 30 % des bisherigen Bestands (bei ≥ 10 bisherigen), werden dessen
  Kandidaten **nicht** als "nicht mehr online" markiert; Warnung steht in
  `run_log.json` und im Seitenkopf. `first_seen` wird beim ersten Auftauchen
  gesetzt und nie überschrieben.

## Automatische Läufe (Cron, seit 2026-09-19)

Dreimal täglich (Standard 07:30, 12:00 und 18:30 Europe/Vienna), konfigurierbar in
**`data/schedule.json`** (auf dem Server ändern, kein Crontab-Eingriff nötig):

```json
{"enabled": true, "times": ["07:30", "12:00", "18:30"], "timezone": "Europe/Vienna",
 "kind": "beide", "portals": null, "min_hours_between_runs": 3, "upload": true}
```
`kind`: wohnung | grundstueck | beide. `portals`: Liste von Portalnamen oder
`null` = alle. `enabled: false` pausiert. Beliebig viele `times`.

**Wie es läuft:** ein fester Crontab-"Tick" (alle 30 min) startet
`scheduled_run.py --tick`. Der Tick liest nur `schedule.json` + `run_log.json`
und entscheidet, ob ein Lauf **fällig** ist (Sollzeit seit letztem Lauf
verstrichen, Mindestabstand, max. ein Versuch pro Stunde) — war der Rechner aus,
wird beim nächsten Tick nachgeholt. Ein Lauf lädt den **ganzen Workspace frisch
herunter** (immer neueste Portal-Module, egal welcher Agent sie geschrieben hat),
führt `run_search.py --kind …` aus und lädt **nur** `data/candidates.json`,
`data.js`, `data/run_log.json` hoch (jede Datei wird zurückgelesen und verglichen).
Hat sich `candidates.json`/`run_log.json` auf dem Server während des Laufs
verändert (z.B. ein Agent hat parallel gesucht), wird der Lauf **verworfen**
und später neu versucht. Sperrdatei verhindert parallele Läufe.

**Einrichten (lokal beim Nutzer, braucht das Token — das liegt NICHT hier):**
```bash
cd modules
../../agent-env/bin/python install_cron.py --creds /pfad/workspace_credentials.json           # Vorschau
../../agent-env/bin/python install_cron.py --creds /pfad/workspace_credentials.json --install
../../agent-env/bin/python install_cron.py --remove                                            # entfernen
../../agent-env/bin/python scheduled_run.py --now --creds /pfad/… [--kind wohnung] [--dry-run]   # sofort, ohne Zeitplan
```
**Achtung:** Cron führt die **lokalen Kopien** von `scheduled_run.py` und
`mcp_sync.py` aus (alles andere holt jeder Lauf frisch vom Server). Wer diese
zwei Dateien ändert, muss sie auf dem Rechner mit dem Crontab nachziehen.
Seit 2026-09-19 22:45 läuft der Cron auf dem Rechner des Nutzers (erster
echter Lauf: 7 Portale, 5,5 min, ohne Fehler).
Log: `~/.cache/pontiswerk/cron.log`. Nur der Block zwischen den
`BEGIN/END pontiswerk-realestate`-Markern wird verwaltet. Voraussetzung: der
Rechner ist an (WSL: `cron` muss laufen — `sudo service cron start`).

## Favoriten / Ablehnen / Gesehen

Ebenfalls nur `localStorage`-basiert (pro Browser, nicht Server-seitig
synchronisiert) — Klick auf ★/✕/👁 auf einer Karte. Falls der Nutzer diese
Markierungen dauerhaft im JSON haben möchte (geräteübergreifend), kann ein
Agent auf Anfrage `localStorage`-Werte abfragen (Nutzer kopiert sie raus) und
in `candidates.json` als `status`/`notes` zurückschreiben.

## Neues Portal-Modul hinzufügen

1. `robots.txt` + Bot-Check prüfen (siehe Tabelle oben / `base.py`).
2. `modules/portals/<name>.py` anlegen, `PortalSearcher` aus `base.py`
   implementieren (`search_rentals` und/oder `search_land`), `Listing`-Objekte
   zurückgeben.
3. In `run_search.py` zur `PORTALS`-Liste hinzufügen.
4. **Offen parsen:** nichts wegwerfen, was nur "unerwünscht" aussieht (Pacht/
   Miete/Baurecht, Duplikate, seltsame Preise) — nur Suchkriterien aus
   `criteria.json` (Preisgrenze, Fläche, Bezirk) dürfen im Modul filtern.
   Alles andere markiert `quality.py` und die Seite blendet es client-seitig aus.
5. Bevorzugt eine öffentliche JSON-API des Portals nutzen (wie bei bazar.at) —
   robuster als HTML-Scraping. Wenn keine API existiert und das Portal
   automatisierten Zugriff erlaubt, Playwright verwenden (Interface ist dafür
   offen, siehe `base.py`-Docstring).

## Bekannte Einschränkungen

- 8 Portale aktuell implementiert: `bazar.at`, `immoscout24.at` und `willhaben.at` (Wohnungen
  Tiroler Bezirke + Grundstücke ganz Österreich), `immobilien.tips.at`
  (Wohnungen Bezirke mit geringer Abdeckung + Grundstücke), `sreal.at`
  (Wohnungen Tirol 0 Treffer + Grundstücke ~30) und
  `raiffeisen-immobilien.at` (Wohnungen Bezirke 0 Treffer + Grundstücke ~84)
  und `meinbezirk.at` (Wohnungen Bezirke 0 + Grundstücke ~2) sowie
  `immobilien.net` (ImmoScout24-Gruppe: Wohnungen Bezirke 14 + Land ~569).
  Abdeckung bleibt begrenzt (z.B. keine willhaben.at-Inserate, da off-limits,
  kein remax.at wegen Bot-Wall).
- bazar.at mischt bei Grundstücken €/m²- und Gesamtpreise im selben Feld;
  `bazar.py` erkennt das heuristisch (kleiner Preis + große Fläche →
  wahrscheinlich €/m², wird umgerechnet und mit Tag
  `preis_pro_m2_umgerechnet` markiert). Grenzfälle ohne Flächenangabe werden
  als `preis_unklar` markiert statt geraten — auf der Karte prüfen, nicht
  blind vertrauen.
- Die "Bach/Wald/Quelle/Alm/abgelegen"-Kriterien sind Keyword-Matching auf
  Titel+Beschreibung, kein echtes Geo-Feature — Tags sind ein Signal, kein
  Beweis.
- Automatische Läufe (Cron) laufen nur, solange der Rechner des Nutzers an ist
  und `cron` läuft; verpasste Läufe werden beim nächsten Tick nachgeholt (siehe
  "Automatische Läufe"). Kein Cloud-Scheduler, weil der Upload das Token braucht.
- Preis auf Anfrage / ohne Preis: ~290 Grundstücke (überwiegend bazar.at) haben
  keinen Preis und passen nur deshalb durch die Preisgrenze, weil er unbekannt
  ist — "Nur mit Preisangabe" nutzen.

## Zugangsdaten (wichtig!)

**In diesem öffentlichen Verzeichnis liegen absichtlich keine Zugangsdaten.**
Um diesen Workspace per MCP zu beschreiben (z.B. um einen neuen Suchlauf
hochzuladen), braucht ein Agent:
- MCP-Endpoint: `https://mcp.pontiswerk.eu/mcp` (öffentlich, kein Geheimnis)
- Ein Bearer-Token für den Workspace `agent-probe-workspace` — das hat **nur
  der menschliche Nutzer** (lokal gespeichert, nicht hier). Der Nutzer muss
  es dem Agenten in der jeweiligen Sitzung geben, oder sich selbst über
  `https://pontiswerk.eu/dashboard` einloggen (Wallet `0x8a7C3eA1ce8133464969F223781CD82c9e017a63`,
  Handle `agent-probe-cb7a40`) und ein neues Token erstellen.
