Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,15 @@ setting has one home, and a second copy is a second answer waiting to disagree w

**Colours that are legitimately literal**: scrims and hairlines drawn over listing photography,
which stays photography in both themes; `#000` used as a mask stencil; white on the accent, which
is dark red either way. The map basemap is the light OpenFreeMap style in both themes, so map
overlays follow the page rather than inverting.
is dark red either way; and MapLibre paint and marker colours (`ui/src/components/map/overlayLayers.js`,
`darkBasemapPaint.js`, `markerColors.js`), because a map layer or marker takes a colour string and
cannot read a custom property. Anything drawn in HTML around the map (legend, badges) still uses
the tokens.

**The map follows the theme.** The vector basemap is OpenFreeMap's `bright` style in the light theme
and its `dark` style in the dark theme; satellite imagery is the same in both (`isDarkBasemap` in
`ui/src/components/map/Map.jsx`). The overlays carry one paint set per basemap (`OVERLAY_PAINT.light`
and `.dark`), and the canvas is dimmed on a bright basemap and lifted on the dark one.

**Tracking.** Switching theme fires `CHANGE_THEME_DARK` or `CHANGE_THEME_LIGHT`. A tracking event
carries a feature name and nothing else (`trackPoi` sends one string), so any value worth reporting
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

# Fredy 🏡 - Your Self-Hosted Real Estate Finder for Europe

**Fredy** watches **24 real estate portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹 for you (e.g. Immoscout,
**Fredy** watches **26 real estate portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹 for you (e.g. Immoscout,
Kleinanzeigen etc), drops duplicates across platforms, and notifies you via **Slack, Telegram,
Email, ntfy, Discord and more** the moment a new listing appears. Searches are managed from a Web
UI, and you never see the same listing twice.
Expand Down Expand Up @@ -96,8 +96,9 @@ Fredy is in the [Unraid](https://unraid.net/) community store.

## ✨ What you get

- 🏠 **24 portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹: ImmoScout24, Immowelt, Kleinanzeigen, WG-Gesucht,
willhaben, Flatfox, idealista, Subito and [16 more](doc/providers.md)
- 🏠 **26 portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹: ImmoScout24 (Germany and Austria), Immowelt,
Kleinanzeigen, WG-Gesucht, willhaben, Flatfox, idealista, Subito and
[18 more](doc/providers.md)
- ⚡ **Instant notifications**: Slack, Telegram, Email (SMTP, SendGrid, Mailjet, Resend), ntfy,
Discord, Mattermost, Pushover, Apprise and more
- 🔄 **Deduplication across platforms**: the same flat advertised on ImmoScout, Immowelt and
Expand Down Expand Up @@ -234,7 +235,7 @@ class node_debug,node_mcp toneMint

| Topic | What is in there |
|---|---|
| [Providers & scraping](doc/providers.md) | All 24 providers, the Immoscout / idealista / Casa.it specifics, and residential proxies for when a VPS gets blocked |
| [Providers & scraping](doc/providers.md) | All 26 providers, the Immoscout / idealista / Casa.it specifics, and residential proxies for when a VPS gets blocked |
| [Scam detection](doc/scam-detection.md) | The signals, their weights, the languages, and how to overrule Fredy |
| [Financing calculator](doc/financing.md) | Rent and Annuitätendarlehen, Kaufnebenkosten, Restschuld, the 35 % rule |
| [Travel time & public transport](doc/travel-time.md) | Addresses and place types, estimated vs exact, route drawing, departure boards, operator settings |
Expand Down
53 changes: 51 additions & 2 deletions doc/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ platform into Fredy.
> Always make sure the search results are sorted by **date**, so Fredy picks up the newest listings
> first.

## The 24 built-in providers
## The 26 built-in providers

**🇩🇪 Germany**

Expand All @@ -20,7 +20,8 @@ platform into Fredy.
| InBerlinWohnen | Kleinanzeigen | Sparkasse Immobilien |
| McMakler | Wg gesucht | |

**🇦🇹 Austria** · willhaben
**🇩🇪 Germany · 🇦🇹 Austria · 🇨🇭 Switzerland** · BETTERHOMES
**🇦🇹 Austria** · willhaben · Immoscout Österreich
**🇨🇭 Switzerland** · Flatfox
**🇪🇸 Spain · 🇮🇹 Italy · 🇵🇹 Portugal** · idealista
**🇮🇹 Italy** · Subito · Tecnocasa · Tecnorete · Casa.it
Expand Down Expand Up @@ -51,6 +52,39 @@ Worth knowing:
- If a search URL cannot be mapped at all, the job fails with `Real estate type not found: <path>`.
Please open an issue with the URL, it is a one line fix.

## Immoscout Österreich

`immobilienscout24.at` is a separate provider (`immoscoutAt`), not a country setting on the German
one. The two websites share nothing you can see: Austria has its own URL scheme
(`/regional/<bundesland>/<gemeinde>/<slug>`), its own filter parameters and its own listing ids.
What they do share is the index behind them, so Fredy reads Austria through the same reverse
engineered mobile API and both providers are thin descriptors over one client.

Paste the search URL from `immobilienscout24.at` as usual. Flats, houses and plots are covered, with
the site's filters for price, living space and room count, whether they sit in the path
(`wohnung-bis-1100-euro-mieten`) or in the query string (`?primaryPriceTo=1100`). Paging and sorting
in the path (`/seite-3`, `/aktualitaet`) are ignored rather than refused - Fredy walks and sorts on
its own.

Three limits, all of them the API's rather than Fredy's:

- **Districts are widened.** The Austrian part of the index files areas by Bundesland and Gemeinde
and no deeper, so a Viennese district URL searches all of Vienna and says so in the log. Narrow
the job down with a map area filter if that is too wide.
- **Renting and buying cannot be searched at once.** The API answers a request for both with the
first of the two and reports nothing about it, so the site's plural pages (`wohnungen`,
`einfamilienhaeuser`, `3-zimmer-wohnungen`, ...) are refused with the single-deal alternative
named in the message. `immobilien`, which spans four types, does work.
- **Commercial searches are not supported**, the same as on the German site.

Listings link to `immobilienscout24.de/expose/<id>`. That is not a mistake: an Austrian advert is
served by the German index under a German id, and the API states that page as the advert's own
share link.

**Switzerland is not covered by this provider.** `immoscout24.ch` belongs to a different company and
runs a different platform, with no listings in this index at all. Switzerland is served by Flatfox
and BETTERHOMES.

## idealista

Uses the mobile APIs for idealista.com, idealista.it and idealista.pt, and the search URL determines
Expand All @@ -70,6 +104,21 @@ translated into API requests use the job's browser; the fallback reads up to twe
when a page provides no valid results. See the
[provider documentation](../reverse-engineered-casa.md) for supported endpoints and filters.

## BETTERHOMES

One brokerage on three domains - `betterhomes.de`, `betterhomes.at` and `betterhomes.ch` - and a
search url from any of them works. Its results page fills itself from a JSON endpoint, which is
what Fredy asks as well, so this provider needs no browser and costs one request per run.

Paste the search url as usual: every filter it carries is passed on untouched, so anything the
portal offers works whether or not Fredy has heard of it. The exact street is never published on a
BETTERHOMES advert, so a listing is located by its postcode and district. The coordinates the
portal itself shows are its town's centre, so they are only used for an advert without any address.

A rent is stored as the Nettomiete, like every other provider's, because the affordability check
adds the Nebenkosten itself; the Bruttomiete is only the fallback for an advert that states no net
figure.

## Countries and the map

**Every provider declares the countries it covers**, and the job form puts the matching flag in
Expand Down
142 changes: 142 additions & 0 deletions docs/plans/immoscout-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# ImmoScout: leichter bauen, ohne Features zu verlieren

Stand nach dem Österreich-Umbau. Erster Teil beschreibt, was jetzt da ist und warum. Zweiter Teil
sind Vorschläge, die noch nicht umgesetzt sind, mit Aufwand und Risiko.

## Wo das Gewicht liegt

Gemessen, nicht geschätzt (`wc -l`):

| Datei | LOC | Art |
|---|---:|---|
| `lib/services/immoscout/mobileApi.js` | 526 | Engine: Requests, Parser, Probes, Provider-Factory |
| `lib/services/immoscout/at-paths.js` | 452 | AT-Tabellen |
| `lib/services/immoscout/web-paths.js` | 253 | DE-Tabellen |
| `lib/services/immoscout/immoscout-web-translator.js` | 249 | URL-Zusammenbau, beide Länder |
| `lib/services/immoscout/param-support.js` | 201 | Parameter × Typ Matrix |
| `lib/services/immoscout/shape.js` | 82 | Polyline-Dekodierung |
| `lib/services/immoscout/real-estate-types.js` | 62 | Vokabular |
| `lib/provider/immoscoutAt.js` | 38 | Deskriptor |
| `lib/provider/immoscout.js` | 26 | Deskriptor |
| **Summe** | **1889** | |

Vorher (nur Deutschland): 1190 LOC, davon 457 im Provider selbst.

Österreich hat **604 Zeilen** gekostet (`at-paths.js` + `immoscoutAt.js` + 114 Zeilen Translator).
Ein zweiter Provider per Copy-Paste hätte rund 900 gekostet **und** eine zweite Kopie des
526-Zeilen-Clients erzeugt, die bei jedem API-Wechsel mitgezogen werden müsste.

Entscheidend für alles Weitere: **rund 900 der 1889 Zeilen sind handgepflegte Tabellen**
(`at-paths`, `web-paths`, `param-support`). Das ist der Teil, der verrottet, wenn ImmoScout eine
Seite ändert, und der Teil, den niemand vollständig bekommt.

## Was jetzt steht: Deskriptor über Engine

Ein nationales Portal steuert genau zwei Dinge bei: seine Identität und einen Leser für seine
URL-Form. Alles andere ist identisch, weil hinter beiden Sites **eine** API auf **einem** Index
liegt.

```
lib/provider/immoscout.js ─┐
├─→ buildImmoscoutProvider(portal) ─→ mobileApi.js (Engine)
lib/provider/immoscoutAt.js ┘ ↑
└── portal.toMobileSearchUrl ─→ web-paths.js | at-paths.js
```

Zwei Entwurfsentscheidungen, die nicht offensichtlich sind:

- **`baseUrl` ≠ wo das Inserat liegt.** Das AT-Portal ist `immobilienscout24.at`, aber ein
österreichisches Inserat wird vom deutschen Index unter einer deutschen numerischen ID
ausgeliefert, und die API nennt selbst die `.de`-Seite als Share-Link. Deshalb gibt es
`EXPOSE_BASE_URL` getrennt von `portal.baseUrl`. Ein aus `baseUrl` gebauter Link wäre 404.
- **Zwei Provider statt `countries: ['de','at']`.** Weil die API einen gemeinsamen ID-Raum
zurückgibt, könnte ein `countryOf(listing)` die beiden nie auseinanderhalten. Ein Provider pro
Site deklariert ein Land, und die Frage stellt sich nicht. Geocoder und Karte bekommen eine
Antwort, die stimmt.

## Vorschlag 1: `param-support.js` durch die API selbst ersetzen

**Das größte Einzelstück, das weg kann.** 201 Zeilen Matrix, aufgenommen durch Replay.

Die API sagt bei jeder Ablehnung, **welchen** Parameter sie meint. Diese Session verifiziert, alle
drei Fälle:

```
haspromotion auf housebuy → ERROR_COMMON_URL_PARAMETER_NOT_SUPPORTED
"The parameter [haspromotion] is not supported."
pricetype=calculatedtotalrent
auf houserent → ERROR_COMMON_URL_PARAMETER_VALIDATION_FAILED
"The parameter [pricetype] has an invalid value [calculatedtotalrent]."
unbekannter Parameter → ERROR_COMMON_URL_PARAMETER_NOT_SUPPORTED
"The parameter [fredynonsense] is not supported."
```

Statt die Matrix zu pflegen: **schicken, Ablehnung lesen, genannten Parameter streichen, erneut
schicken.** Ergebnis pro `(realestatetype, parameter)` in einem Cache lernen, damit es einmal pro
Instanz kostet und nicht pro Lauf.

Das ist nicht nur kürzer, es ist **feature-completer**: heute wird ein Filter, den niemand
aufgenommen hat, mit `no translator` verworfen und die Suche läuft breiter als gesetzt. Mit dem
Reader funktioniert er, sobald die API ihn akzeptiert.

- **Gewinn:** ~160 der 201 Zeilen weg. Übrig bleiben die Noise-Liste und der 412-Leser.
- **Kosten:** beim ersten Lauf bis zu N Zusatz-Requests, N = Zahl der abgelehnten Parameter. Die
API nennt pro 412 nur *einen*, also iterativ. Mit Cache konvergiert das nach einem Lauf.
- **Risiko:** ein 412 aus anderem Grund darf nicht als "Parameter nicht unterstützt" gelernt
werden. Absichern, indem nur auf die zwei bekannten `messageCode` gelernt wird und die
Iteration hart gedeckelt ist.

## Vorschlag 2: Pfadtabellen nicht mehr pflegen, sondern die Seite fragen

`web-paths.js` sagt im eigenen Kopfkommentar, wie die Tabelle entstanden ist: jede Suchseite
berichtet die API-URL, zu der sie aufgelöst hat, in einem Feld `lastSearchApiUrl`.

Wenn das noch stimmt, ist die 253-Zeilen-Tabelle ersetzbar: beim **Speichern** eines Jobs die
Suchseite einmal laden, `lastSearchApiUrl` lesen, die Mobile-URL am Job ablegen. Tabelle bleibt
als Fallback, darf aber verrotten, ohne dass jemand etwas merkt.

- **Gewinn:** jeder ImmoScout-Filter funktioniert ab Tag eins, auch die, die keine Tabelle kennt.
`Real estate type not found` verschwindet als Fehlerklasse.
- **Kosten:** ein Request pro Job-Speicherung, nicht pro Lauf.
- **Risiko, und zwar ein echtes:** die DE-Seite antwortet auf einfache Requests mit 401 (in dieser
Session gemessen, für `/Suche/...` genauso wie für `/expose/...`). Das bräuchte also den
Browser- oder Proxy-Pfad, den das Repo für andere Provider schon hat, und würde ImmoScout die
Eigenschaft nehmen, der einzige vollständig browserfreie Provider zu sein. **Ich konnte
`lastSearchApiUrl` in dieser Session nicht nachprüfen**, genau wegen dieser 401. Vor einer
Umsetzung gehört das verifiziert, sonst ist der ganze Vorschlag Spekulation auf einem
Kommentar.
- Für Österreich gilt er ohnehin nicht: die AT-Seite ist eine andere Anwendung und hat das Feld
nicht.

## Vorschlag 3: eine Slug-Tabelle mit Länder-Overlays statt zweier Vokabulare

`web-paths.js` und `at-paths.js` beschreiben dieselbe API mit zwei getrennten Tabellen. Rund zehn
Slugs sind identisch (`wohnung-mieten`, `haus-kaufen`, …) und stehen doppelt.

Eine gemeinsame Basis `slug → (realType, params)` plus je ein Overlay pro Site würde die Dopplung
entfernen und, wichtiger, sichtbar machen, welche Site was kann.

- **Gewinn:** klein, geschätzt 40–60 Zeilen, plus deutlich bessere Lesbarkeit.
- **Kosten:** ein Umbau an zwei getesteten Tabellen ohne funktionalen Nutzen.
- **Einschätzung:** erst machen, wenn eine dritte Site dazukommt. Bei zwei ist die Trennung noch
ehrlicher als die Abstraktion, weil die beiden Sites tatsächlich verschiedene Vokabulare haben.

## Was nicht geht

**Schweiz passt nicht in diese Architektur.** `immoscout24.ch` gehört einer anderen Firma
(SMG Swiss Marketplace Group), läuft auf einer anderen Plattform hinter Cloudflare und DataDome,
und hat **null** Inserate in diesem Index: `/ch`, `/ch/zuerich` und `/ch/bern` antworten mit
`totalResults: 0`, `/ch/zurich/zurich` und `/ch/geneve` mit 412. `api.immoscout24.ch` existiert,
antwortet aber auf jedem Pfad mit 403.

Ein CH-Provider wäre ein eigenes Reverse-Engineering-Projekt und teilt sich mit diesem Konstrukt
nichts außer dem Namen. Die Schweiz ist bereits über `flatfox` (`['ch']`) und `betterhomes`
(`['de','at','ch']`) abgedeckt.

## Reihenfolge, wenn umgesetzt wird

1. **Vorschlag 1** zuerst. Größter Gewinn, geringstes Risiko, keine neue Abhängigkeit, und macht
das Produkt nebenbei vollständiger statt nur kleiner.
2. **Vorschlag 2** nur nach Verifikation von `lastSearchApiUrl` über den Browser-Pfad. Wenn das
Feld weg ist, fällt der Vorschlag ersatzlos.
3. **Vorschlag 3** zurückstellen bis zu einer dritten Site.
Loading
Loading