Vague 2 : colonnes bedrooms/bathrooms, perf fb_marketplace/logisquebec, connecteurs Kangalou + Rentals.ca, visites virtuelles
- Schéma : colonnes bedrooms/bathrooms REAL (additives) dérivées dans
finalize() — details explicites > type d'unité (n½ -> n-2, convention QC)
> extraction texte ; backfill one-shot : 96,7 % bedrooms / 51 % bathrooms
sur le parc actif.
- fb_marketplace : recherche et détails Scrapfly EN PARALLÈLE (6 fils,
mêmes requêtes, aucun volume perdu), TTL détail 10 -> 30 j, annonces
louées filtrées avant enrichissement : 43-47 min -> 7,3 min/cycle.
- logisquebec : budget détail 1200 -> 400 (dimensionné TTL 7 j / ~10
passes/j), fiches jamais vues servies en premier, délai 0,7 -> 0,5 s :
19 min -> 7,6 min/cycle, parc complet (6 393 fiches).
- Kangalou (nouveau) : découverte en 1 requête via les pins de la carte
(/fr/recherche/ajax/search-map/, loués/commerciaux exclus), fiches SSR
(adresse, chambres/SDB, GPS, galerie, dispo, superficie) : 7 916 annonces.
- Rentals.ca (nouveau) : même GraphQL que Louer.ca mais inventaire ~+15 %,
rejoué via Scrapfly ASP sans rendu JS (Cloudflare), détails PAR LOTS de
40 via nodes(ids), villes/volumes plafonnés : 3 452 annonces (les
recoupements Louer.ca sont résorbés par la dédup, déjà dans PORTALS).
- Visites virtuelles : details.virtual_tour — tours{} Matterport/YouTube
du réseau Rentals.ca (louer_ca clé cache v2 + rentals_ca) + détection
générique Matterport/iGUIDE/Klapty dans les fiches logisquebec,
duproprio, kangalou. 1 107 annonces actives en ont déjà une.
- TtlDetailCache : peek()/put() pour la planification de lots parallèles.
- sources.json : +kangalou, +rentals_ca (275 sources, 212 connecteurs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 changed files +808 −114
modified
README.md
+3 −3
@@ -15,7 +15,7 @@ | ||
| 15 | 15 | |
| 16 | 16 |  |
| 17 | 17 |  |
| 18 | − | |
| 18 | + | |
| 19 | 19 |  |
| 20 | 20 |  |
| 21 | 21 |  |
@@ -30,7 +30,7 @@ Lou-Ka n'est pas une plateforme d'annonces : c'est un **index fidèle** et un ** | ||
| 30 | 30 | |
| 31 | 31 | > Les sites d'agences n'offrent pas de webhooks. Lou-Ka reproduit l'équivalent : **synchronisation périodique + hash de contenu** → ajouts, mises à jour et retraits détectés automatiquement. Une annonce qui disparaît du site source disparaît de Lou-Ka (et répond **`410 Gone`** aux moteurs de recherche). |
| 32 | 32 | |
| 33 | −**En chiffres** : **22 900+ annonces actives**, **265 sources recensées**, **205 connecteurs**, **820 villes**, **11 régions**, **~70 %** des annonces géolocalisées. | |
| 33 | +**En chiffres** : **22 900+ annonces actives**, **275 sources recensées**, **212 connecteurs**, **820 villes**, **11 régions**, **~70 %** des annonces géolocalisées. | |
| 34 | 34 | |
| 35 | 35 | ## Fonctionnalités |
| 36 | 36 | |
@@ -92,7 +92,7 @@ lou-ka/ | ||
| 92 | 92 | │ ├── marketstats.py # agrégats du marché (source unique stats/API/PDF) |
| 93 | 93 | │ ├── pdfgen.py # fiches PDF + rapport de marché (QR code) |
| 94 | 94 | │ ├── fixtures.py # enregistrement/rejeu HTTP pour tests hors-ligne |
| 95 | −│ └── connectors/ # 205 connecteurs + base.py (auto-découverte) | |
| 95 | +│ └── connectors/ # 212 connecteurs + base.py (auto-découverte) | |
| 96 | 96 | ├── frontend/ # React 18 + Vite + TypeScript (pages, composants, kamaps/) |
| 97 | 97 | ├── data/ |
| 98 | 98 | │ ├── sources.json # registre des 265 sources (statut + raison si non connectable) |
modified
data/sources.json
+21 −1
@@ -2728,6 +2728,26 @@ | ||
| 2728 | 2728 | "connector": "fb_marketplace", |
| 2729 | 2729 | "status": "actif", |
| 2730 | 2730 | "region": "Province" |
| 2731 | + }, | |
| 2732 | + { | |
| 2733 | + "id": "kangalou", | |
| 2734 | + "name": "Kangalou", | |
| 2735 | + "url": "https://www.kangalou.com", | |
| 2736 | + "listing_url": "https://www.kangalou.com/fr/recherche/", | |
| 2737 | + "sectors": "Province entière — portail locatif géré (Solutions Kangalou), ~8 500 annonces actives (pins carte, loués exclus)", | |
| 2738 | + "connector": "kangalou", | |
| 2739 | + "status": "actif", | |
| 2740 | + "region": "Province" | |
| 2741 | + }, | |
| 2742 | + { | |
| 2743 | + "id": "rentals_ca", | |
| 2744 | + "name": "Rentals.ca (Québec)", | |
| 2745 | + "url": "https://rentals.ca", | |
| 2746 | + "listing_url": "https://rentals.ca/quebec-city", | |
| 2747 | + "sectors": "Grandes villes QC — même plateforme GraphQL que Louer.ca mais inventaire de marque ~15 % plus large ; accès via Scrapfly ASP (Cloudflare), volumes plafonnés par ville", | |
| 2748 | + "connector": "rentals_ca", | |
| 2749 | + "status": "actif", | |
| 2750 | + "region": "Province" | |
| 2731 | 2751 | } |
| 2732 | 2752 | ] |
| 2733 | −} | |
| 2753 | +} | |
| \ No newline at end of file | ||
modified
louka/connectors/_detailutil.py
+42 −3
@@ -47,6 +47,25 @@ def ld_description(html: str) -> str: | ||
| 47 | 47 | return best |
| 48 | 48 | |
| 49 | 49 | |
| 50 | +# plateformes de visite virtuelle reconnues dans le HTML d'une fiche | |
| 51 | +_VTOUR_RE = re.compile( | |
| 52 | + r'https?://(?:my\.)?(?:matterport\.com/show/[^"\'\s<>]+' | |
| 53 | + r'|(?:www\.)?youriguide\.com/[^"\'\s<>]+' | |
| 54 | + r'|(?:www\.)?klapty\.com/tour/[^"\'\s<>]+' | |
| 55 | + r'|kuula\.co/(?:share|post)/[^"\'\s<>]+' | |
| 56 | + r'|tours?\.[a-z0-9-]+\.(?:com|ca)/[^"\'\s<>]*(?:tour|visite)[^"\'\s<>]*' | |
| 57 | + r'|(?:www\.)?realvision\.com/[^"\'\s<>]+)', re.I) | |
| 58 | + | |
| 59 | + | |
| 60 | +def virtual_tour(html: str) -> str | None: | |
| 61 | + """Première URL de visite virtuelle (Matterport, iGUIDE, Klapty, Kuula…) | |
| 62 | + trouvée dans le HTML d'une fiche. None si absente — jamais inventée.""" | |
| 63 | + m = _VTOUR_RE.search(html or "") | |
| 64 | + if not m: | |
| 65 | + return None | |
| 66 | + return _html.unescape(m.group(0)).replace("\\/", "/").rstrip("\\").rstrip('&') | |
| 67 | + | |
| 68 | + | |
| 50 | 69 | def gmaps_coords(html: str) -> tuple[float, float] | None: |
| 51 | 70 | m = _COORD_RE.search(html) |
| 52 | 71 | if not m: |
@@ -150,9 +169,10 @@ class TtlDetailCache: | ||
| 150 | 169 | self.key = key |
| 151 | 170 | self.fetch_html = fetch_html or (lambda u: connector.get(u).text) |
| 152 | 171 | |
| 153 | − def get(self, external_id: str, url: str, parse_fn) -> dict | None: | |
| 172 | + def peek(self, external_id: str) -> tuple[dict | None, bool]: | |
| 173 | + """(payload en cache, frais ?) SANS déclencher de requête ni toucher | |
| 174 | + au budget — permet aux connecteurs de planifier des lots parallèles.""" | |
| 154 | 175 | import time as _time |
| 155 | − from .. import db | |
| 156 | 176 | row = self.con.execute( |
| 157 | 177 | "SELECT key, payload, fetched_at FROM detail_cache" |
| 158 | 178 | " WHERE source=? AND external_id=?", |
@@ -165,7 +185,20 @@ class TtlDetailCache: | ||
| 165 | 185 | stale = None |
| 166 | 186 | if (stale is not None and row["key"] == self.key |
| 167 | 187 | and _time.time() - (row["fetched_at"] or 0) < self.ttl): |
| 168 | − return stale # frais : aucun trafic | |
| 188 | + return stale, True # frais : aucun trafic requis | |
| 189 | + return stale, False | |
| 190 | + | |
| 191 | + def put(self, external_id: str, payload: dict) -> None: | |
| 192 | + """Écrit un payload obtenu hors de get() (ex. téléchargement en lot).""" | |
| 193 | + from .. import db | |
| 194 | + db.put_cached_detail(self.con, self.connector.source_id, | |
| 195 | + str(external_id), self.key, payload) | |
| 196 | + | |
| 197 | + def get(self, external_id: str, url: str, parse_fn) -> dict | None: | |
| 198 | + from .. import db | |
| 199 | + stale, fresh = self.peek(external_id) | |
| 200 | + if fresh: | |
| 201 | + return stale # frais : aucun trafic | |
| 169 | 202 | if self.budget <= 0: |
| 170 | 203 | return stale # périmé toléré / None si jamais vu |
| 171 | 204 | self.budget -= 1 |
@@ -217,3 +250,9 @@ def apply_detail(lst: Listing, d: dict) -> None: | ||
| 217 | 250 | "pets", "furnished"): |
| 218 | 251 | if d.get(f) is not None and getattr(lst, f, None) is None: |
| 219 | 252 | setattr(lst, f, d[f]) |
| 253 | + from ..normalize import coerce_count | |
| 254 | + for f in ("bedrooms", "bathrooms"): | |
| 255 | + if d.get(f) is not None and getattr(lst, f, None) is None: | |
| 256 | + setattr(lst, f, coerce_count(d[f])) | |
| 257 | + if d.get("virtual_tour") and not lst.details.get("virtual_tour"): | |
| 258 | + lst.details["virtual_tour"] = d["virtual_tour"] | |
modified
louka/connectors/duproprio.py
+4 −0
@@ -106,6 +106,10 @@ def _parse_fiche(html: str) -> dict: | ||
| 106 | 106 | f"{ym}/{lid}/{slug1600}-{pid}.jpg") |
| 107 | 107 | if photos: |
| 108 | 108 | out["images"] = photos |
| 109 | + | |
| 110 | + vt = du.virtual_tour(html) # visite virtuelle (Matterport/iGUIDE…) | |
| 111 | + if vt: | |
| 112 | + out.setdefault("details", {})["virtual_tour"] = vt | |
| 109 | 113 | return out |
| 110 | 114 | |
| 111 | 115 | |
modified
louka/connectors/fb_marketplace.py
+93 −49
@@ -18,6 +18,7 @@ | ||
| 18 | 18 | # ----------------------------------------------------------------------------- |
| 19 | 19 | from __future__ import annotations |
| 20 | 20 | |
| 21 | +import concurrent.futures as _cf | |
| 21 | 22 | import json |
| 22 | 23 | import os |
| 23 | 24 | import re |
@@ -44,8 +45,13 @@ PRICE_BANDS = [(0, 800), (800, 1100), (1100, 1400), (1400, 1700), | ||
| 44 | 45 | PRICE_MIN, PRICE_MAX = 300, 12000 |
| 45 | 46 | |
| 46 | 47 | DETAIL_LIMIT = int(os.environ.get("LOUKA_FBMP_DETAIL_LIMIT", "120")) |
| 47 | −TTL_DAYS = float(os.environ.get("LOUKA_FBMP_TTL_DAYS", "10")) | |
| 48 | +# les annonces FB changent peu après publication : TTL long = le budget détail | |
| 49 | +# sert surtout aux NOUVELLES annonces plutôt qu'à re-visiter les connues | |
| 50 | +TTL_DAYS = float(os.environ.get("LOUKA_FBMP_TTL_DAYS", "30")) | |
| 48 | 51 | CITY_LIMIT = os.environ.get("LOUKA_FBMP_CITIES", "") # ex. "montreal,laval" |
| 52 | +# requêtes Scrapfly en parallèle (recherche ET détail) : le cycle passait 40+ | |
| 53 | +# minutes en attente sérielle de rendus JS de ~20 s chacun | |
| 54 | +CONCURRENCY = int(os.environ.get("LOUKA_FBMP_CONCURRENCY", "6")) | |
| 49 | 55 | |
| 50 | 56 | |
| 51 | 57 | def _walk_listings(html: str) -> dict: |
@@ -145,17 +151,29 @@ class FacebookMarketplaceConnector(BaseConnector): | ||
| 145 | 151 | out["images"] = photos |
| 146 | 152 | return out |
| 147 | 153 | |
| 148 | − def _search(self, slug: str) -> dict: | |
| 149 | − found: dict = {} | |
| 150 | − for lo, hi in PRICE_BANDS: | |
| 151 | − url = (f"{BASE}/{slug}/propertyrentals" | |
| 152 | − f"?minPrice={lo}&maxPrice={hi}&sortBy=creation_time_descend") | |
| 153 | − try: | |
| 154 | − html = self._scrape(url) | |
| 155 | − except Exception: | |
| 156 | − continue | |
| 157 | − for lid, obj in _walk_listings(html).items(): | |
| 158 | − found.setdefault(lid, obj) | |
| 154 | + def _search_all(self, cities: dict) -> dict[str, tuple[dict, str]]: | |
| 155 | + """Toutes les pages de recherche (ville × tranche de prix) EN PARALLÈLE. | |
| 156 | + | |
| 157 | + Même nombre de requêtes Scrapfly qu'avant (aucune perte de volume) : | |
| 158 | + seule l'attente des rendus JS (~20 s chacun) est recouverte. | |
| 159 | + """ | |
| 160 | + tasks = [] | |
| 161 | + for slug, city in cities.items(): | |
| 162 | + for lo, hi in PRICE_BANDS: | |
| 163 | + url = (f"{BASE}/{slug}/propertyrentals" | |
| 164 | + f"?minPrice={lo}&maxPrice={hi}&sortBy=creation_time_descend") | |
| 165 | + tasks.append((url, city)) | |
| 166 | + found: dict[str, tuple[dict, str]] = {} | |
| 167 | + with _cf.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: | |
| 168 | + futs = {pool.submit(self._scrape, url): city for url, city in tasks} | |
| 169 | + for fut in _cf.as_completed(futs): | |
| 170 | + city = futs[fut] | |
| 171 | + try: | |
| 172 | + html = fut.result() | |
| 173 | + except Exception: | |
| 174 | + continue | |
| 175 | + for lid, obj in _walk_listings(html).items(): | |
| 176 | + found.setdefault(lid, (obj, city)) | |
| 159 | 177 | return found |
| 160 | 178 | |
| 161 | 179 | def fetch(self) -> list[Listing]: |
@@ -166,46 +184,72 @@ class FacebookMarketplaceConnector(BaseConnector): | ||
| 166 | 184 | wanted = {c.strip() for c in CITY_LIMIT.split(",")} |
| 167 | 185 | cities = {k: v for k, v in CITIES.items() if k in wanted} |
| 168 | 186 | |
| 187 | + found = self._search_all(cities) | |
| 188 | + | |
| 189 | + # candidats retenus AVANT tout enrichissement : pas de requête détail | |
| 190 | + # gaspillée sur les annonces sans prix ou déjà louées | |
| 191 | + candidates: list[tuple[str, dict, str]] = [] | |
| 192 | + for lid, (obj, city) in found.items(): | |
| 193 | + if _price(obj) is None: | |
| 194 | + continue # sans prix mensuel valable : ignorer | |
| 195 | + if obj.get("is_sold") or obj.get("is_pending"): | |
| 196 | + continue # déjà loué / en attente | |
| 197 | + candidates.append((lid, obj, city)) | |
| 198 | + | |
| 199 | + # enrichissement détail : cache BD d'abord (peek), puis les fiches | |
| 200 | + # jamais vues / périmées EN PARALLÈLE dans la limite du budget | |
| 169 | 201 | cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS, |
| 170 | 202 | key="v1", fetch_html=self._scrape) |
| 171 | − out: dict[str, Listing] = {} | |
| 203 | + details: dict[str, dict] = {} | |
| 204 | + todo: list[str] = [] | |
| 172 | 205 | try: |
| 173 | − for slug, city in cities.items(): | |
| 174 | − for lid, obj in self._search(slug).items(): | |
| 175 | − if lid in out: | |
| 176 | − continue | |
| 177 | − title = obj.get("marketplace_listing_title") or "" | |
| 178 | − price = _price(obj) | |
| 179 | − if price is None: | |
| 180 | − continue # sans prix mensuel valable : ignorer | |
| 181 | − loc = obj.get("location") or {} | |
| 182 | − coords = loc.get("reverse_geocode") or {} | |
| 183 | − obj_city = (coords.get("city") | |
| 184 | − or (coords.get("city_page") or {}).get("display_name") | |
| 185 | − or city) | |
| 186 | − photo = ((obj.get("primary_listing_photo") or {}) | |
| 187 | − .get("image") or {}).get("uri") or "" | |
| 188 | − url = f"{BASE}/item/{lid}/" | |
| 189 | − | |
| 190 | − detail = cache.get(lid, url, self._parse_detail) or {} | |
| 191 | − desc = detail.get("description") or "" | |
| 192 | − lst = Listing( | |
| 193 | − source=self.source_id, | |
| 194 | − external_id=lid, | |
| 195 | − url=url, | |
| 196 | − title=title[:200], | |
| 197 | − city=obj_city, | |
| 198 | − unit_type=_unit_type(title, desc), | |
| 199 | − price=price, | |
| 200 | − price_label=f"{price:,.0f} $/mois".replace(",", " "), | |
| 201 | − description=desc, | |
| 202 | − images=detail.get("images") or ([photo] if photo else []), | |
| 203 | − lat=detail.get("lat"), | |
| 204 | − lng=detail.get("lng"), | |
| 205 | − ) | |
| 206 | − if obj.get("is_sold") or obj.get("is_pending"): | |
| 207 | − continue # déjà loué / en attente | |
| 208 | − out[lid] = lst | |
| 206 | + for lid, _obj, _city in candidates: | |
| 207 | + payload, fresh = cache.peek(lid) | |
| 208 | + if payload is not None: | |
| 209 | + details[lid] = payload # frais OU périmé toléré | |
| 210 | + if not fresh and len(todo) < cache.budget: | |
| 211 | + todo.append(lid) | |
| 212 | + | |
| 213 | + def _fetch_one(lid: str) -> tuple[str, dict]: | |
| 214 | + try: | |
| 215 | + return lid, self._parse_detail( | |
| 216 | + self._scrape(f"{BASE}/item/{lid}/")) | |
| 217 | + except Exception: | |
| 218 | + return lid, {} | |
| 219 | + if todo: | |
| 220 | + with _cf.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: | |
| 221 | + for lid, payload in pool.map(_fetch_one, todo): | |
| 222 | + if payload: # échec réseau : garder l'ancien | |
| 223 | + cache.put(lid, payload) | |
| 224 | + details[lid] = payload | |
| 225 | + | |
| 226 | + out: dict[str, Listing] = {} | |
| 227 | + for lid, obj, city in candidates: | |
| 228 | + title = obj.get("marketplace_listing_title") or "" | |
| 229 | + price = _price(obj) | |
| 230 | + loc = obj.get("location") or {} | |
| 231 | + coords = loc.get("reverse_geocode") or {} | |
| 232 | + obj_city = (coords.get("city") | |
| 233 | + or (coords.get("city_page") or {}).get("display_name") | |
| 234 | + or city) | |
| 235 | + photo = ((obj.get("primary_listing_photo") or {}) | |
| 236 | + .get("image") or {}).get("uri") or "" | |
| 237 | + detail = details.get(lid) or {} | |
| 238 | + desc = detail.get("description") or "" | |
| 239 | + out[lid] = Listing( | |
| 240 | + source=self.source_id, | |
| 241 | + external_id=lid, | |
| 242 | + url=f"{BASE}/item/{lid}/", | |
| 243 | + title=title[:200], | |
| 244 | + city=obj_city, | |
| 245 | + unit_type=_unit_type(title, desc), | |
| 246 | + price=price, | |
| 247 | + price_label=f"{price:,.0f} $/mois".replace(",", " "), | |
| 248 | + description=desc, | |
| 249 | + images=detail.get("images") or ([photo] if photo else []), | |
| 250 | + lat=detail.get("lat"), | |
| 251 | + lng=detail.get("lng"), | |
| 252 | + ) | |
| 209 | 253 | finally: |
| 210 | 254 | cache.close() |
| 211 | 255 | return list(out.values()) |
added
louka/connectors/kangalou.py
+209 −0
@@ -0,0 +1,209 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/kangalou.py : Kangalou (kangalou.com) — portail locatif québécois | |
| 5 | +# géré (Solutions Kangalou, ~8 700 annonces province entière). Découverte en | |
| 6 | +# UNE requête : l'endpoint carte `POST /fr/recherche/ajax/search-map/` | |
| 7 | +# (pinsOnly=true) renvoie tous les pins JSON — id, URL de fiche, GPS, ville, | |
| 8 | +# type (ti/tn ; 10=Commercial et 11=Garage exclus), « n ½ » (an), prix (mc), | |
| 9 | +# photo et l'état loué (ir, exclu). La fiche /fr/annonce/<slug>/<id>/ (SSR, | |
| 10 | +# aucun anti-bot) complète : adresse civique, chambres/salles de bain, loyer, | |
| 11 | +# étage, superficie, disponibilité, description, inclusions/caractéristiques | |
| 12 | +# et galerie (slider--gallery, images glide) — via TtlDetailCache | |
| 13 | +# (budget/TTL). L'API de liste HTML (POST /fr/api/search) existe aussi mais | |
| 14 | +# pagine par 29 et masque l'état loué (CSS) : les pins sont la source fiable. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import os | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from ..schema import Listing, normalize_unit_type | |
| 22 | +from .base import BaseConnector | |
| 23 | +from . import _detailutil as du | |
| 24 | + | |
| 25 | +BASE = "https://www.kangalou.com" | |
| 26 | +PINS_API = f"{BASE}/fr/recherche/ajax/search-map/" | |
| 27 | + | |
| 28 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_KANGALOU_DETAIL_LIMIT", "350")) | |
| 29 | +TTL_DAYS = float(os.environ.get("LOUKA_KANGALOU_TTL_DAYS", "7")) | |
| 30 | +MAX_PINS = int(os.environ.get("LOUKA_KANGALOU_MAX", "0")) # 0 = tout | |
| 31 | + | |
| 32 | +# type id -> type d'unité par défaut (le « n ½ » du pin prime quand présent) | |
| 33 | +_TYPES = {1: "", 2: "Condo", 3: "Maison", 4: "Chambre", 5: "Chalet", | |
| 34 | + 6: "Studio", 7: "Loft"} | |
| 35 | +_EXCLUDED_TI = {10, 11} # Commercial, Garage : pas du résidentiel | |
| 36 | + | |
| 37 | +# fiche détail (SSR) | |
| 38 | +_GPS_RE = re.compile(r'maps/search/\?query=(-?\d+\.\d+)%2C(-?\d+\.\d+)') | |
| 39 | +_ADDR_RE = re.compile(r'buds--separator[^>]*u-text-size-sm">\s*<span>([^<]+)</span>', re.S) | |
| 40 | +# galerie : pleine taille dans gr-modal-gallery-src (les <img> sont lazyload) ; | |
| 41 | +# hébergement variable (images/glide kangalou OU s3 shareimmo/lws…) | |
| 42 | +_GALLERY_RE = re.compile(r'gr-modal-gallery-src="(https?://[^"]+)"') | |
| 43 | +_FLAT_FIELD = { | |
| 44 | + "loyer": re.compile(r"Loyer:\s*\|\s*([\d\s ,]+\$\s*/\s*mois)"), | |
| 45 | + "etage": re.compile(r"Étage:\s*\|\s*(\d{1,2})\b"), | |
| 46 | + "sdb": re.compile(r"Nombre de salles? de bain:\s*\|\s*(\d+(?:[.,]5)?)"), | |
| 47 | + "chambres": re.compile(r"Nombre de chambres:\s*\|\s*(\d+)"), | |
| 48 | + "superficie": re.compile(r"Superficie:\s*\|\s*([\d\s ,.]+)\s*m\s*\|\s*2"), | |
| 49 | + "dispo": re.compile(r"Disponibilité:\s*\|\s*([^|]{1,60})"), | |
| 50 | +} | |
| 51 | +_DESC_RE = re.compile( | |
| 52 | + r'Description du logement à louer\s*</h2>.*?<div class="typography"[^>]*>(.*?)</div>', | |
| 53 | + re.S) | |
| 54 | + | |
| 55 | + | |
| 56 | +def _parse_fiche(html: str) -> dict: | |
| 57 | + """Champs riches d'une fiche Kangalou (SSR).""" | |
| 58 | + out: dict = {} | |
| 59 | + m = _GPS_RE.search(html) | |
| 60 | + if m: | |
| 61 | + out["lat"], out["lng"] = float(m.group(1)), float(m.group(2)) | |
| 62 | + m = _ADDR_RE.search(html) | |
| 63 | + if m: | |
| 64 | + out["address"] = m.group(1).strip() | |
| 65 | + | |
| 66 | + flat = du.flatten(html) | |
| 67 | + vals = {k: rx.search(flat) for k, rx in _FLAT_FIELD.items()} | |
| 68 | + if vals["chambres"]: | |
| 69 | + out["bedrooms"] = float(vals["chambres"].group(1)) | |
| 70 | + if vals["sdb"]: | |
| 71 | + out["bathrooms"] = float(vals["sdb"].group(1).replace(",", ".")) | |
| 72 | + if vals["dispo"]: | |
| 73 | + out["availability"] = vals["dispo"].group(1).strip(" .") | |
| 74 | + if vals["etage"]: | |
| 75 | + out["details"] = {"floor": int(vals["etage"].group(1))} | |
| 76 | + if vals["superficie"]: | |
| 77 | + try: | |
| 78 | + m2 = float(vals["superficie"].group(1) | |
| 79 | + .replace(" ", "").replace(" ", "").replace(",", ".")) | |
| 80 | + if 8 <= m2 <= 2000: | |
| 81 | + out["area_sqft"] = round(m2 * 10.7639) | |
| 82 | + except ValueError: | |
| 83 | + pass | |
| 84 | + | |
| 85 | + m = _DESC_RE.search(html) | |
| 86 | + if m: | |
| 87 | + txt = re.sub(r"<br\s*/?>", "\n", m.group(1)) | |
| 88 | + txt = re.sub(r"<[^>]+>", " ", txt) | |
| 89 | + import html as _h | |
| 90 | + out["description"] = re.sub(r"[ \t]+", " ", _h.unescape(txt)).strip()[:6000] | |
| 91 | + | |
| 92 | + # commodités : sections Inclusions / Caractéristiques / Détail / Critères | |
| 93 | + # (texte aplati « libellé | valeur ») — libellés courts jusqu'à la section | |
| 94 | + # suivante, sans jamais interpréter | |
| 95 | + amen: list[str] = [] | |
| 96 | + _sections = ("Inclusions", "Caractéristiques intérieures", | |
| 97 | + "Caractéristiques extérieures", "Autres services", | |
| 98 | + "Critères importants", "Détail", "Sécurité", "Buanderie") | |
| 99 | + for section in _sections: | |
| 100 | + m2m = re.search(r"\|\s*" + re.escape(section) + r"\s*\|", flat) | |
| 101 | + if not m2m: # titre de SECTION seulement (« | Titre | »), | |
| 102 | + continue # pas une mention dans la description | |
| 103 | + i = m2m.end() | |
| 104 | + seg = flat[i:i + 400] | |
| 105 | + for item in seg.split("|"): | |
| 106 | + item = item.strip() | |
| 107 | + if not item or len(item) > 45: | |
| 108 | + break | |
| 109 | + if item in _sections: # section suivante : elle a sa passe | |
| 110 | + break | |
| 111 | + if re.match(r"^(Partager|Contacter|Description|Transport|À proximité" | |
| 112 | + r"|Santé|Éducation|Informations)", item): | |
| 113 | + break | |
| 114 | + if item not in amen: | |
| 115 | + amen.append(item) | |
| 116 | + if amen: | |
| 117 | + out["amenities"] = amen | |
| 118 | + | |
| 119 | + # galerie (slider--gallery ; les vignettes asnav dupliquées sont exclues) | |
| 120 | + ig = html.find("slider--gallery") | |
| 121 | + if ig >= 0: | |
| 122 | + seg = html[ig:] | |
| 123 | + cut = seg.find("slider__asnav") | |
| 124 | + if cut > 0: | |
| 125 | + seg = seg[:cut] | |
| 126 | + photos = [] | |
| 127 | + for u in _GALLERY_RE.findall(seg): | |
| 128 | + if u not in photos: | |
| 129 | + photos.append(u) | |
| 130 | + if photos: | |
| 131 | + out["images"] = photos | |
| 132 | + | |
| 133 | + vt = du.virtual_tour(html) | |
| 134 | + if vt: | |
| 135 | + out.setdefault("details", {})["virtual_tour"] = vt | |
| 136 | + return out | |
| 137 | + | |
| 138 | + | |
| 139 | +class KangalouConnector(BaseConnector): | |
| 140 | + source_id = "kangalou" | |
| 141 | + request_delay = 0.5 | |
| 142 | + | |
| 143 | + def _pins(self) -> list[dict]: | |
| 144 | + resp = self.post(PINS_API, data={"pinsOnly": "true"}, | |
| 145 | + headers={"Accept": "application/json", | |
| 146 | + "X-Requested-With": "XMLHttpRequest"}) | |
| 147 | + return (resp.json() or {}).get("pins") or [] | |
| 148 | + | |
| 149 | + @staticmethod | |
| 150 | + def _unit_type(pin: dict) -> str: | |
| 151 | + an = (pin.get("an") or "").strip() # « 4 ½ », « 9 ½ + » | |
| 152 | + if an: | |
| 153 | + ut = normalize_unit_type(an) | |
| 154 | + if re.match(r"^\d½$|^6½\+$", ut or ""): | |
| 155 | + return ut | |
| 156 | + return _TYPES.get(pin.get("ti"), "") | |
| 157 | + | |
| 158 | + def fetch(self) -> list[Listing]: | |
| 159 | + pins = self._pins() | |
| 160 | + if MAX_PINS: | |
| 161 | + pins = pins[:MAX_PINS] | |
| 162 | + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS) | |
| 163 | + out: list[Listing] = [] | |
| 164 | + try: | |
| 165 | + for pin in pins: | |
| 166 | + if pin.get("ir"): | |
| 167 | + continue # déjà loué : pas une offre active | |
| 168 | + if pin.get("ti") in _EXCLUDED_TI or not pin.get("id"): | |
| 169 | + continue # commercial / garage / malformé | |
| 170 | + url = pin.get("l") or "" | |
| 171 | + if not url: | |
| 172 | + continue | |
| 173 | + eid = str(pin["id"]) | |
| 174 | + price = None | |
| 175 | + try: | |
| 176 | + price = float(str(pin.get("mc")).replace(",", "")) | |
| 177 | + except (TypeError, ValueError): | |
| 178 | + pass | |
| 179 | + if price is not None and not (100 <= price <= 20000): | |
| 180 | + price = None # affichage « sur demande » etc. | |
| 181 | + lat = lng = None | |
| 182 | + try: | |
| 183 | + lat, lng = float(pin["lt"]), float(pin["lg"]) | |
| 184 | + except (KeyError, TypeError, ValueError): | |
| 185 | + pass | |
| 186 | + photo = pin.get("p") or "" | |
| 187 | + lst = Listing( | |
| 188 | + source=self.source_id, | |
| 189 | + external_id=eid, | |
| 190 | + url=url, | |
| 191 | + title=(pin.get("n") or "").strip(), | |
| 192 | + city=(pin.get("c") or "").strip(), | |
| 193 | + unit_type=self._unit_type(pin), | |
| 194 | + price=price, | |
| 195 | + price_label=(f"{price:,.0f} $/mois".replace(",", " ") | |
| 196 | + if price else ""), | |
| 197 | + images=[photo] if photo else [], | |
| 198 | + lat=lat, | |
| 199 | + lng=lng, | |
| 200 | + ) | |
| 201 | + payload = cache.get(eid, url, _parse_fiche) | |
| 202 | + if payload: | |
| 203 | + du.apply_detail(lst, payload) | |
| 204 | + if payload.get("address"): | |
| 205 | + lst.title = payload["address"] | |
| 206 | + out.append(lst) | |
| 207 | + finally: | |
| 208 | + cache.close() | |
| 209 | + return out | |
modified
louka/connectors/logisquebec.py
+20 −2
@@ -50,7 +50,12 @@ _LOC_RE = re.compile(r"<loc>(.*?)</loc>") | ||
| 50 | 50 | _PHOTO_RE = re.compile(r"https://i\.logisquebec\.com/i-a-louer/\d+/\d+/[^'\"]+") |
| 51 | 51 | _DISPO_RE = re.compile(r"Disponible à partir de\s*:?\s*\|\s*([^|]{1,60})") |
| 52 | 52 | |
| 53 | −DETAIL_LIMIT = int(os.environ.get("LOUKA_LOGISQUEBEC_DETAIL_LIMIT", "1200")) | |
| 53 | +# Dimensionnement du budget détail : parc ~6 000 fiches, TTL 7 j, watch ~10 | |
| 54 | +# passes/jour -> ~90 rafraîchissements/passe suffisent à tenir le TTL. 400 | |
| 55 | +# laisse 4x de marge (poussées de nouvelles fiches) en ramenant le cycle de | |
| 56 | +# ~19 min à ~5 min. Les fiches JAMAIS VUES sont servies en premier (voir | |
| 57 | +# fetch) : aucune perte de volume, le parc se complète au même rythme. | |
| 58 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_LOGISQUEBEC_DETAIL_LIMIT", "400")) | |
| 54 | 59 | TTL_DAYS = float(os.environ.get("LOUKA_LOGISQUEBEC_TTL_DAYS", "7")) |
| 55 | 60 | MAX_FICHES = int(os.environ.get("LOUKA_LOGISQUEBEC_MAX", "0")) # 0 = tout |
| 56 | 61 | |
@@ -119,12 +124,16 @@ def _parse_fiche(html: str) -> dict: | ||
| 119 | 124 | amenities.append(label) |
| 120 | 125 | if amenities: |
| 121 | 126 | out["amenities"] = amenities |
| 127 | + | |
| 128 | + vt = du.virtual_tour(html) # lien Matterport/iGUIDE… si la fiche en a un | |
| 129 | + if vt: | |
| 130 | + out["virtual_tour"] = vt | |
| 122 | 131 | return out |
| 123 | 132 | |
| 124 | 133 | |
| 125 | 134 | class LogisQuebecConnector(BaseConnector): |
| 126 | 135 | source_id = "logisquebec" |
| 127 | − request_delay = 0.7 | |
| 136 | + request_delay = 0.5 | |
| 128 | 137 | |
| 129 | 138 | # -- découverte ------------------------------------------------------------ |
| 130 | 139 | def _fiche_urls(self) -> list[tuple[str, str, str, str]]: |
@@ -182,6 +191,8 @@ class LogisQuebecConnector(BaseConnector): | ||
| 182 | 191 | details["rooms"] = payload["rooms"] # numberOfRooms (JSON-LD) |
| 183 | 192 | if court_terme: |
| 184 | 193 | details["Location court terme"] = "oui" |
| 194 | + if payload.get("virtual_tour"): | |
| 195 | + details["virtual_tour"] = payload["virtual_tour"] | |
| 185 | 196 | |
| 186 | 197 | amenities = payload.get("amenities") or [] |
| 187 | 198 | lst = Listing( |
@@ -215,6 +226,13 @@ class LogisQuebecConnector(BaseConnector): | ||
| 215 | 226 | if MAX_FICHES: |
| 216 | 227 | fiches = fiches[:MAX_FICHES] |
| 217 | 228 | cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS) |
| 229 | + # priorité du budget aux fiches jamais visitées (tri stable) : les | |
| 230 | + # nouveautés sont toujours captées au premier passage, le reste du | |
| 231 | + # budget sert au rafraîchissement TTL | |
| 232 | + seen = {r["external_id"] for r in cache.con.execute( | |
| 233 | + "SELECT external_id FROM detail_cache WHERE source=?", | |
| 234 | + (self.source_id,))} | |
| 235 | + fiches.sort(key=lambda f: f[3] in seen) | |
| 218 | 236 | out: list[Listing] = [] |
| 219 | 237 | try: |
| 220 | 238 | for url, type_slug, _ville, eid in fiches: |
modified
louka/connectors/louer_ca.py
+123 −53
@@ -58,24 +58,31 @@ _AMENITY_FR = { | ||
| 58 | 58 | class LouerCaConnector(BaseConnector): |
| 59 | 59 | source_id = "louer_ca" |
| 60 | 60 | request_delay = 0.5 |
| 61 | + # points d'entrée du réseau Rentals.ca — surchargés par rentals_ca.py | |
| 62 | + home_url = HOME | |
| 63 | + gql_url = GQL | |
| 64 | + site = "https://louer.ca" | |
| 65 | + fallback_key = FALLBACK_KEY | |
| 66 | + page_size = PAGE_SIZE | |
| 67 | + max_per_city = MAX_PER_CITY | |
| 61 | 68 | |
| 62 | 69 | def __init__(self) -> None: |
| 63 | 70 | super().__init__() |
| 64 | 71 | self.session.headers.update({ |
| 65 | 72 | "Content-Type": "application/json", |
| 66 | − "Origin": "https://louer.ca", | |
| 67 | − "Referer": "https://louer.ca/", | |
| 73 | + "Origin": self.site, | |
| 74 | + "Referer": self.home_url, | |
| 68 | 75 | }) |
| 69 | 76 | self._token = "" |
| 70 | 77 | self._token_time = 0.0 |
| 71 | − self._api_key = FALLBACK_KEY | |
| 78 | + self._api_key = self.fallback_key | |
| 72 | 79 | |
| 73 | 80 | # -- auth ------------------------------------------------------------------ |
| 74 | 81 | def _ensure_token(self) -> None: |
| 75 | 82 | if self._token and time.time() - self._token_time < 2700: # ~45 min |
| 76 | 83 | return |
| 77 | 84 | try: |
| 78 | − home = self.get(HOME).text | |
| 85 | + home = self.get(self.home_url).text | |
| 79 | 86 | m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home) |
| 80 | 87 | if m: |
| 81 | 88 | self._api_key = m.group(1) |
@@ -99,8 +106,8 @@ class LouerCaConnector(BaseConnector): | ||
| 99 | 106 | if auth: |
| 100 | 107 | self._ensure_token() |
| 101 | 108 | headers["Authorization"] = f"Bearer {self._token}" |
| 102 | − resp = self.post(GQL, data=json.dumps({"query": query, | |
| 103 | − "variables": variables}), | |
| 109 | + resp = self.post(self.gql_url, data=json.dumps({"query": query, | |
| 110 | + "variables": variables}), | |
| 104 | 111 | headers=headers) |
| 105 | 112 | payload = resp.json() |
| 106 | 113 | if payload.get("errors"): |
@@ -108,8 +115,9 @@ class LouerCaConnector(BaseConnector): | ||
| 108 | 115 | if "AUTH" in msg.upper() and auth: # jeton expiré : on réessaie |
| 109 | 116 | self._token = "" |
| 110 | 117 | self._ensure_token() |
| 111 | − resp = self.post(GQL, data=json.dumps({"query": query, | |
| 112 | − "variables": variables}), | |
| 118 | + resp = self.post(self.gql_url, | |
| 119 | + data=json.dumps({"query": query, | |
| 120 | + "variables": variables}), | |
| 113 | 121 | headers={"Authorization": f"Bearer {self._token}"}) |
| 114 | 122 | payload = resp.json() |
| 115 | 123 | return payload.get("data") or {} |
@@ -141,7 +149,7 @@ class LouerCaConnector(BaseConnector): | ||
| 141 | 149 | "edges{node{" + self._LIST_FRAG + "}}}}}}") |
| 142 | 150 | out, after = [], None |
| 143 | 151 | while True: |
| 144 | − d = self._gql(query, {"id": city_id, "first": PAGE_SIZE, | |
| 152 | + d = self._gql(query, {"id": city_id, "first": self.page_size, | |
| 145 | 153 | "after": after}) |
| 146 | 154 | rl = ((d.get("node") or {}).get("rentalListings")) or {} |
| 147 | 155 | edges = rl.get("edges") or [] |
@@ -149,7 +157,7 @@ class LouerCaConnector(BaseConnector): | ||
| 149 | 157 | info = rl.get("pageInfo") or {} |
| 150 | 158 | if not info.get("hasNextPage") or not edges: |
| 151 | 159 | break |
| 152 | − if MAX_PER_CITY and len(out) >= MAX_PER_CITY: | |
| 160 | + if self.max_per_city and len(out) >= self.max_per_city: | |
| 153 | 161 | break |
| 154 | 162 | after = info.get("endCursor") |
| 155 | 163 | return out |
@@ -157,8 +165,9 @@ class LouerCaConnector(BaseConnector): | ||
| 157 | 165 | _DETAIL_FRAG = ("id name path location " |
| 158 | 166 | "address{city{name regionCode} neighbourhood{name} " |
| 159 | 167 | "postalCode street} description{plain} " |
| 160 | − "imagesCount images{scales} " | |
| 161 | − "floorPlans{beds baths rent size availability furnished}") | |
| 168 | + "imagesCount images{scales} tours{name type refId} " | |
| 169 | + "floorPlans{beds baths rent size availability furnished " | |
| 170 | + "tours{name type refId}}") | |
| 162 | 171 | |
| 163 | 172 | def _detail(self, gid: str) -> dict: |
| 164 | 173 | d = self._gql("query($id:ID!){node(id:$id){... on RentalListing{" |
@@ -208,6 +217,39 @@ class LouerCaConnector(BaseConnector): | ||
| 208 | 217 | imgs.append(best) |
| 209 | 218 | return imgs |
| 210 | 219 | |
| 220 | + @staticmethod | |
| 221 | + def _count(v) -> float | None: | |
| 222 | + """beds/baths GraphQL -> float (0 = studio) ; None si inconnu.""" | |
| 223 | + try: | |
| 224 | + f = float(v) | |
| 225 | + except (TypeError, ValueError): | |
| 226 | + return None | |
| 227 | + return f if 0 <= f <= 20 else None | |
| 228 | + | |
| 229 | + @staticmethod | |
| 230 | + def _tour_url(tours) -> str | None: | |
| 231 | + """URL de visite virtuelle depuis les `tours` GraphQL du réseau | |
| 232 | + Rentals.ca : refId Matterport/YouTube (ou URL complète). La visite | |
| 233 | + interactive prime sur la simple vidéo.""" | |
| 234 | + video = None | |
| 235 | + for t in tours or []: | |
| 236 | + ref = str(t.get("refId") or "").strip() | |
| 237 | + typ = t.get("type") or "" | |
| 238 | + if not ref: | |
| 239 | + continue | |
| 240 | + if ref.startswith("http"): | |
| 241 | + url = ref | |
| 242 | + elif "matterport" in typ: | |
| 243 | + url = f"https://my.matterport.com/show/?m={ref}" | |
| 244 | + elif "youtube" in typ: | |
| 245 | + url = f"https://www.youtube.com/watch?v={ref}" | |
| 246 | + else: | |
| 247 | + continue | |
| 248 | + if "interactive" in typ or "matterport" in typ: | |
| 249 | + return url | |
| 250 | + video = video or url | |
| 251 | + return video | |
| 252 | + | |
| 211 | 253 | @staticmethod |
| 212 | 254 | def _numeric_id(gid: str) -> str: |
| 213 | 255 | """« cmVudGFsbGlzdGluZzoxMTMyMTE2 » -> « 1132116 » (rentallisting:1132116).""" |
@@ -221,12 +263,78 @@ class LouerCaConnector(BaseConnector): | ||
| 221 | 263 | m = re.search(r"(\d+)", gid) |
| 222 | 264 | return m.group(1) if m else gid |
| 223 | 265 | |
| 266 | + def _card_listings(self, card: dict, node: dict, today: str, | |
| 267 | + default_city: str = "") -> list[Listing]: | |
| 268 | + """Annonces Lou-Ka d'une carte liste + son nœud détail GraphQL | |
| 269 | + (une par plan d'étage). Partagé avec rentals_ca.py.""" | |
| 270 | + out: list[Listing] = [] | |
| 271 | + gid = card.get("id") | |
| 272 | + addr = node.get("address") or {} | |
| 273 | + city = (addr.get("city") or {}).get("name") or default_city | |
| 274 | + sector = (addr.get("neighbourhood") or {}).get("name") or "" | |
| 275 | + loc = card.get("location") or [] | |
| 276 | + lng, lat = (loc + [None, None])[:2] | |
| 277 | + base_desc = ((node.get("description") or {}).get("plain") | |
| 278 | + or "")[:6000] | |
| 279 | + images = self._images(node) | |
| 280 | + amenities = self._amenities(card.get("amenities")) | |
| 281 | + common = dict( | |
| 282 | + source=self.source_id, url=f"{self.site}/{card.get('path','')}", | |
| 283 | + address=addr.get("street") or "", sector=sector, city=city, | |
| 284 | + description=base_desc, amenities=amenities, images=images, | |
| 285 | + lat=lat, lng=lng, | |
| 286 | + ) | |
| 287 | + base_details: dict = {} | |
| 288 | + if addr.get("postalCode"): | |
| 289 | + base_details["Code postal"] = addr["postalCode"] | |
| 290 | + tour = self._tour_url(node.get("tours")) | |
| 291 | + if tour: | |
| 292 | + base_details["virtual_tour"] = tour | |
| 293 | + if base_details: | |
| 294 | + common["details"] = base_details | |
| 295 | + | |
| 296 | + plans = node.get("floorPlans") or [] | |
| 297 | + if not plans: | |
| 298 | + # pas de plan détaillé : une annonce « à partir de » | |
| 299 | + rng = card.get("rentRange") or [] | |
| 300 | + price = rng[0] if rng else None | |
| 301 | + beds = (card.get("bedsRange") or [None])[0] | |
| 302 | + lst = self._mk(common, gid, "", price, | |
| 303 | + self._unit_type(beds), None, today, | |
| 304 | + price_from=bool(rng)) | |
| 305 | + lst.bedrooms = self._count(beds) | |
| 306 | + lst.bathrooms = self._count( | |
| 307 | + (card.get("bathsRange") or [None])[0]) | |
| 308 | + out.append(lst) | |
| 309 | + return out | |
| 310 | + for i, fp in enumerate(plans): | |
| 311 | + avail = fp.get("availability") or {} | |
| 312 | + adate = "now" if avail.get("now") else ( | |
| 313 | + avail.get("date") or "")[:10] or None | |
| 314 | + if adate and adate != "now" and adate <= today: | |
| 315 | + adate = "now" | |
| 316 | + lst = self._mk(common, gid, f"-{i}", fp.get("rent"), | |
| 317 | + self._unit_type(fp.get("beds")), | |
| 318 | + fp.get("size"), today, adate=adate) | |
| 319 | + lst.bedrooms = self._count(fp.get("beds")) | |
| 320 | + lst.bathrooms = self._count(fp.get("baths")) | |
| 321 | + fp_tour = self._tour_url(fp.get("tours")) | |
| 322 | + if fp_tour: # la visite du plan précis bat celle de l'immeuble | |
| 323 | + lst.details["virtual_tour"] = fp_tour | |
| 324 | + if fp.get("furnished") == "yes": | |
| 325 | + lst.furnished = True | |
| 326 | + out.append(lst) | |
| 327 | + return out | |
| 328 | + | |
| 224 | 329 | def fetch(self) -> list[Listing]: |
| 225 | 330 | # le « détail » est une requête GraphQL, pas une page HTML : le |
| 226 | 331 | # fetch_html du cache reçoit le gid et le renvoie tel quel, parse_fn |
| 227 | 332 | # exécute la requête GraphQL. |
| 333 | + # clé v2 : force le re-téléchargement progressif des détails pour | |
| 334 | + # capter tours{} (visites virtuelles) et floorPlans.baths ajoutés en | |
| 335 | + # vague 2 — les payloads v1 périmés restent utilisés en attendant | |
| 228 | 336 | cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS, |
| 229 | − key="v1", fetch_html=lambda gid: gid) | |
| 337 | + key="v2", fetch_html=lambda gid: gid) | |
| 230 | 338 | out: list[Listing] = [] |
| 231 | 339 | today = datetime.date.today().isoformat() |
| 232 | 340 | try: |
@@ -240,46 +348,8 @@ class LouerCaConnector(BaseConnector): | ||
| 240 | 348 | continue |
| 241 | 349 | node = cache.get(gid, gid, |
| 242 | 350 | lambda g: self._detail(g)) or {} |
| 243 | − addr = node.get("address") or {} | |
| 244 | − city = (addr.get("city") or {}).get("name") or slug.title() | |
| 245 | − sector = (addr.get("neighbourhood") or {}).get("name") or "" | |
| 246 | − loc = card.get("location") or [] | |
| 247 | − lng, lat = (loc + [None, None])[:2] | |
| 248 | − base_desc = ((node.get("description") or {}).get("plain") | |
| 249 | − or "")[:6000] | |
| 250 | − images = self._images(node) | |
| 251 | − amenities = self._amenities(card.get("amenities")) | |
| 252 | − common = dict( | |
| 253 | − source=self.source_id, url=f"https://louer.ca/{card.get('path','')}", | |
| 254 | − address=addr.get("street") or "", sector=sector, city=city, | |
| 255 | − description=base_desc, amenities=amenities, images=images, | |
| 256 | − lat=lat, lng=lng, | |
| 257 | − ) | |
| 258 | − if addr.get("postalCode"): | |
| 259 | − common["details"] = {"Code postal": addr["postalCode"]} | |
| 260 | − | |
| 261 | − plans = node.get("floorPlans") or [] | |
| 262 | − if not plans: | |
| 263 | − # pas de plan détaillé : une annonce « à partir de » | |
| 264 | − rng = card.get("rentRange") or [] | |
| 265 | − price = rng[0] if rng else None | |
| 266 | − beds = (card.get("bedsRange") or [None])[0] | |
| 267 | − out.append(self._mk(common, gid, "", price, | |
| 268 | − self._unit_type(beds), None, today, | |
| 269 | − price_from=bool(rng))) | |
| 270 | − continue | |
| 271 | − for i, fp in enumerate(plans): | |
| 272 | − avail = fp.get("availability") or {} | |
| 273 | − adate = "now" if avail.get("now") else ( | |
| 274 | − avail.get("date") or "")[:10] or None | |
| 275 | − if adate and adate != "now" and adate <= today: | |
| 276 | − adate = "now" | |
| 277 | − lst = self._mk(common, gid, f"-{i}", fp.get("rent"), | |
| 278 | − self._unit_type(fp.get("beds")), | |
| 279 | − fp.get("size"), today, adate=adate) | |
| 280 | − if fp.get("furnished") == "yes": | |
| 281 | − lst.furnished = True | |
| 282 | − out.append(lst) | |
| 351 | + out.extend(self._card_listings(card, node, today, | |
| 352 | + default_city=slug.title())) | |
| 283 | 353 | finally: |
| 284 | 354 | cache.close() |
| 285 | 355 | return out |
added
louka/connectors/rentals_ca.py
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/rentals_ca.py : Rentals.ca — portail locatif national, filtré | |
| 5 | +# PROVINCE DE QUÉBEC. Même plateforme GraphQL que Louer.ca (mêmes City.id, | |
| 6 | +# mutation acquireAuthInfo, clé publique `rentalsGqlKey` de window.appconf), | |
| 7 | +# mais l'inventaire de marque est un peu plus large (~+15 % vérifié sur | |
| 8 | +# Québec : 78 annonces contre 67 côté Louer.ca) — les doublons inter-marques | |
| 9 | +# sont résorbés par la dédup (rentals_ca est dans la liste PORTALS). | |
| 10 | +# ⚠️ rentals.ca est derrière Cloudflare (« Just a moment… ») : chaque appel | |
| 11 | +# GraphQL est REJOUÉ via Scrapfly ASP SANS rendu JS (POST JSON, coût minime | |
| 12 | +# par rapport à un render_js). Pour contenir le budget Scrapfly : | |
| 13 | +# - villes limitées (LOUKA_RENTALSCA_CITIES) et plafond par ville ; | |
| 14 | +# - pages de 200 annonces (1 à 4 requêtes liste par ville) ; | |
| 15 | +# - détails PAR LOTS de 40 via nodes(ids:[…]) — 1 requête pour 40 fiches — | |
| 16 | +# avec cache TTL : seuls les lots nouveaux/périmés sont re-téléchargés. | |
| 17 | +# Régime de croisière : ~25-40 appels Scrapfly par synchronisation. | |
| 18 | +# ⚠️ CGU Rentals.ca : extraction interdite sans accord écrit (même réserve | |
| 19 | +# que louer_ca). | |
| 20 | +# ----------------------------------------------------------------------------- | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import datetime | |
| 24 | +import json | |
| 25 | +import os | |
| 26 | +import re | |
| 27 | + | |
| 28 | +from ..schema import Listing | |
| 29 | +from . import _detailutil as du | |
| 30 | +from .louer_ca import LouerCaConnector | |
| 31 | + | |
| 32 | +# villes QC interrogées (slugs rentals.ca) — volontairement bornées : chaque | |
| 33 | +# page liste coûte un appel Scrapfly ASP | |
| 34 | +CITIES = [c.strip() for c in os.environ.get( | |
| 35 | + "LOUKA_RENTALSCA_CITIES", | |
| 36 | + "montreal,quebec-city,laval,gatineau,longueuil,sherbrooke,levis," | |
| 37 | + "trois-rivieres,saguenay,drummondville").split(",") if c.strip()] | |
| 38 | + | |
| 39 | +PAGE_SIZE = int(os.environ.get("LOUKA_RENTALSCA_PAGE_SIZE", "200")) | |
| 40 | +MAX_PER_CITY = int(os.environ.get("LOUKA_RENTALSCA_MAX_PER_CITY", "800")) | |
| 41 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_RENTALSCA_DETAIL_LIMIT", "600")) | |
| 42 | +DETAIL_BATCH = int(os.environ.get("LOUKA_RENTALSCA_DETAIL_BATCH", "40")) | |
| 43 | +TTL_DAYS = float(os.environ.get("LOUKA_RENTALSCA_TTL_DAYS", "7")) | |
| 44 | + | |
| 45 | + | |
| 46 | +class RentalsCaConnector(LouerCaConnector): | |
| 47 | + source_id = "rentals_ca" | |
| 48 | + request_delay = 0.3 # entre appels Scrapfly (API, pas le site) | |
| 49 | + home_url = "https://rentals.ca/" | |
| 50 | + gql_url = "https://rentals.ca/graphql" | |
| 51 | + site = "https://rentals.ca" | |
| 52 | + fallback_key = "kJFM-mm4c-xg6B-qiwy" # rentalsGqlKey publique (2026-08) | |
| 53 | + page_size = PAGE_SIZE | |
| 54 | + max_per_city = MAX_PER_CITY | |
| 55 | + | |
| 56 | + # -- transport : tout passe par Scrapfly ASP (POST JSON, sans rendu JS) ---- | |
| 57 | + def _scrapfly_gql(self, query: str, variables: dict, | |
| 58 | + token: str | None = None) -> dict: | |
| 59 | + headers = {"Content-Type": "application/json", | |
| 60 | + "Origin": self.site, "Referer": self.home_url} | |
| 61 | + if token: | |
| 62 | + headers["Authorization"] = f"Bearer {token}" | |
| 63 | + res = self.scrapfly(self.gql_url, render_js=False, asp=True, | |
| 64 | + method="POST", | |
| 65 | + body=json.dumps({"query": query, | |
| 66 | + "variables": variables}), | |
| 67 | + headers=headers) | |
| 68 | + try: | |
| 69 | + return json.loads(res.get("content") or "{}") | |
| 70 | + except ValueError: | |
| 71 | + return {} | |
| 72 | + | |
| 73 | + def _ensure_token(self) -> None: | |
| 74 | + import time as _time | |
| 75 | + if self._token and _time.time() - self._token_time < 2700: | |
| 76 | + return | |
| 77 | + payload = self._scrapfly_gql( | |
| 78 | + "mutation($k:String!){acquireAuthInfo(credentials:{apiKey:$k})" | |
| 79 | + "{jwt status}}", {"k": self._api_key}) | |
| 80 | + auth = ((payload.get("data") or {}).get("acquireAuthInfo")) or {} | |
| 81 | + jwt = auth.get("jwt") | |
| 82 | + if isinstance(jwt, str) and jwt.startswith("{"): | |
| 83 | + jwt = json.loads(jwt) | |
| 84 | + token = jwt.get("accessToken") if isinstance(jwt, dict) else jwt | |
| 85 | + if not token: | |
| 86 | + # clé périmée ? la relire dans window.appconf de la page d'accueil | |
| 87 | + home = self.get_scrapfly(self.home_url, render_js=False, asp=True) | |
| 88 | + m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home or "") | |
| 89 | + if m: | |
| 90 | + self._api_key = m.group(1) | |
| 91 | + payload = self._scrapfly_gql( | |
| 92 | + "mutation($k:String!){acquireAuthInfo(credentials:" | |
| 93 | + "{apiKey:$k}){jwt status}}", {"k": self._api_key}) | |
| 94 | + auth = ((payload.get("data") or {}) | |
| 95 | + .get("acquireAuthInfo")) or {} | |
| 96 | + jwt = auth.get("jwt") | |
| 97 | + if isinstance(jwt, str) and jwt.startswith("{"): | |
| 98 | + jwt = json.loads(jwt) | |
| 99 | + token = (jwt.get("accessToken") | |
| 100 | + if isinstance(jwt, dict) else jwt) | |
| 101 | + if not token: | |
| 102 | + raise RuntimeError("Rentals.ca: handshake JWT échoué") | |
| 103 | + self._token = token | |
| 104 | + self._token_time = _time.time() | |
| 105 | + | |
| 106 | + def _gql(self, query: str, variables: dict, auth: bool = True) -> dict: | |
| 107 | + if auth: | |
| 108 | + self._ensure_token() | |
| 109 | + payload = self._scrapfly_gql(query, variables, | |
| 110 | + token=self._token if auth else None) | |
| 111 | + if payload.get("errors") and auth: | |
| 112 | + msg = payload["errors"][0].get("message", "") | |
| 113 | + if "AUTH" in msg.upper(): # jeton expiré : on réessaie | |
| 114 | + self._token = "" | |
| 115 | + self._ensure_token() | |
| 116 | + payload = self._scrapfly_gql(query, variables, | |
| 117 | + token=self._token) | |
| 118 | + return payload.get("data") or {} | |
| 119 | + | |
| 120 | + # -- détail par LOTS (1 requête Scrapfly pour DETAIL_BATCH fiches) --------- | |
| 121 | + def _details_batch(self, gids: list[str]) -> dict[str, dict]: | |
| 122 | + q = ("query($ids:[ID!]!){nodes(ids:$ids){... on RentalListing{" | |
| 123 | + + self._DETAIL_FRAG + "}}}") | |
| 124 | + out: dict[str, dict] = {} | |
| 125 | + for node in self._gql(q, {"ids": gids}).get("nodes") or []: | |
| 126 | + if isinstance(node, dict) and node.get("id"): | |
| 127 | + out[node["id"]] = node | |
| 128 | + return out | |
| 129 | + | |
| 130 | + def fetch(self) -> list[Listing]: | |
| 131 | + if not os.environ.get("SCRAPFLY_KEY"): | |
| 132 | + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)") | |
| 133 | + today = datetime.date.today().isoformat() | |
| 134 | + cards: dict[str, tuple[dict, str]] = {} | |
| 135 | + for slug in CITIES: | |
| 136 | + cid = self._city_id(slug) | |
| 137 | + if not cid: | |
| 138 | + continue | |
| 139 | + for card in self._city_listings(cid): | |
| 140 | + gid = card.get("id") | |
| 141 | + if gid: | |
| 142 | + cards.setdefault(gid, (card, slug)) | |
| 143 | + | |
| 144 | + # cache TTL : détails frais réutilisés sans trafic, le reste par lots | |
| 145 | + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS, | |
| 146 | + key="v1", fetch_html=lambda gid: gid) | |
| 147 | + details: dict[str, dict] = {} | |
| 148 | + todo: list[str] = [] | |
| 149 | + out: list[Listing] = [] | |
| 150 | + try: | |
| 151 | + for gid in cards: | |
| 152 | + payload, fresh = cache.peek(gid) | |
| 153 | + if payload is not None: | |
| 154 | + details[gid] = payload | |
| 155 | + if not fresh and len(todo) < DETAIL_LIMIT: | |
| 156 | + todo.append(gid) | |
| 157 | + for i in range(0, len(todo), DETAIL_BATCH): | |
| 158 | + got = self._details_batch(todo[i:i + DETAIL_BATCH]) | |
| 159 | + for gid, node in got.items(): | |
| 160 | + cache.put(gid, node) | |
| 161 | + details[gid] = node | |
| 162 | + | |
| 163 | + for gid, (card, slug) in cards.items(): | |
| 164 | + node = details.get(gid) or {} | |
| 165 | + region = (((node.get("address") or {}).get("city") or {}) | |
| 166 | + .get("regionCode")) | |
| 167 | + if region and region != "QC": | |
| 168 | + continue # sécurité : Québec uniquement | |
| 169 | + out.extend(self._card_listings( | |
| 170 | + card, node, today, | |
| 171 | + default_city=slug.replace("-", " ").title())) | |
| 172 | + finally: | |
| 173 | + cache.close() | |
| 174 | + return out | |
modified
louka/db.py
+11 −3
@@ -38,6 +38,8 @@ CREATE TABLE IF NOT EXISTS listings ( | ||
| 38 | 38 | sector TEXT, |
| 39 | 39 | city TEXT, |
| 40 | 40 | unit_type TEXT, |
| 41 | + bedrooms REAL, | |
| 42 | + bathrooms REAL, | |
| 41 | 43 | price REAL, |
| 42 | 44 | price_label TEXT, |
| 43 | 45 | availability TEXT, |
@@ -158,6 +160,8 @@ _MIGRATIONS = { | ||
| 158 | 160 | "digest": "TEXT", # JSON louka/textmine.py (description structurée) |
| 159 | 161 | "dup_of": "TEXT", # uid de l'annonce canonique si doublon inter-sources |
| 160 | 162 | "dup_sources": "TEXT", # JSON : autres sources où l'annonce est publiée |
| 163 | + "bedrooms": "REAL", # chambres fermées (convention QC : 4½ = 2) | |
| 164 | + "bathrooms": "REAL", # salles de bain (1.5 = salle d'eau en plus) | |
| 161 | 165 | }, |
| 162 | 166 | "sync_log": { |
| 163 | 167 | "stats": "TEXT", |
@@ -258,6 +262,7 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 258 | 262 | uid=lst.uid, source=lst.source, external_id=lst.external_id, |
| 259 | 263 | url=lst.url, title=lst.title, address=lst.address, |
| 260 | 264 | sector=lst.sector, city=lst.city, unit_type=lst.unit_type, |
| 265 | + bedrooms=lst.bedrooms, bathrooms=lst.bathrooms, | |
| 261 | 266 | price=lst.price, price_label=lst.price_label, |
| 262 | 267 | availability=lst.availability, |
| 263 | 268 | availability_date=lst.availability_date, |
@@ -274,13 +279,15 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 274 | 279 | if row is None: |
| 275 | 280 | con.execute( |
| 276 | 281 | """INSERT INTO listings (uid, source, external_id, url, title, |
| 277 | − address, sector, city, unit_type, price, price_label, | |
| 282 | + address, sector, city, unit_type, bedrooms, bathrooms, | |
| 283 | + price, price_label, | |
| 278 | 284 | availability, availability_date, area_sqft, pets, furnished, |
| 279 | 285 | description, digest, amenities, details, images, lat, lng, |
| 280 | 286 | content_hash, first_seen, last_seen, updated_at, |
| 281 | 287 | miss_count, active) |
| 282 | 288 | VALUES (:uid,:source,:external_id,:url,:title,:address, |
| 283 | − :sector,:city,:unit_type,:price,:price_label,:availability, | |
| 289 | + :sector,:city,:unit_type,:bedrooms,:bathrooms, | |
| 290 | + :price,:price_label,:availability, | |
| 284 | 291 | :availability_date,:area_sqft,:pets,:furnished, |
| 285 | 292 | :description,:digest,:amenities,:details,:images,:lat,:lng, |
| 286 | 293 | :content_hash,:now,:now,:now,0,1)""", params) |
@@ -293,7 +300,8 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 293 | 300 | con.execute( |
| 294 | 301 | """UPDATE listings SET url=:url, title=:title, |
| 295 | 302 | address=:address, sector=:sector, city=:city, |
| 296 | − unit_type=:unit_type, price=:price, | |
| 303 | + unit_type=:unit_type, bedrooms=:bedrooms, | |
| 304 | + bathrooms=:bathrooms, price=:price, | |
| 297 | 305 | price_label=:price_label, availability=:availability, |
| 298 | 306 | availability_date=:availability_date, |
| 299 | 307 | area_sqft=:area_sqft, pets=:pets, furnished=:furnished, |
modified
louka/normalize.py
+79 −0
@@ -150,6 +150,85 @@ def normalize_unit_type(raw: str) -> str: | ||
| 150 | 150 | return _clean_text(str(raw)) |
| 151 | 151 | |
| 152 | 152 | |
| 153 | +# --------------------------------------------------------------------------- | |
| 154 | +# Chambres / salles de bain | |
| 155 | +# --------------------------------------------------------------------------- | |
| 156 | + | |
| 157 | +_BEDROOMS_TXT_RE = re.compile( | |
| 158 | + r"(\d+)\s*(?:chambres?(?:\s+a\s+coucher)?\b|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)") | |
| 159 | +_BATHROOMS_TXT_RE = re.compile( | |
| 160 | + r"(\d+(?:[.,]5)?)\s*(?:salles?\s+de\s+bains?\b|\bsdb\b|bath(?:room)?s?\b)") | |
| 161 | + | |
| 162 | + | |
| 163 | +def bedrooms_from_unit_type(unit_type: str) -> float | None: | |
| 164 | + """Chambres fermées selon la convention québécoise des « pièces et demie ». | |
| 165 | + | |
| 166 | + n½ = n pièces -> n-2 chambres (3½→1, 4½→2, 5½→3) ; 1½/2½ -> 0 ; | |
| 167 | + 6½+ -> 4 (borne basse connue) ; Studio/Loft -> 0 ; Chambre -> 1. | |
| 168 | + Types sans nombre de pièces (Maison, Condo…) -> None (inconnu). | |
| 169 | + """ | |
| 170 | + if not unit_type: | |
| 171 | + return None | |
| 172 | + s = unit_type.strip() | |
| 173 | + if s == "6½+": | |
| 174 | + return 4.0 | |
| 175 | + m = re.match(r"^(\d+)½$", s) | |
| 176 | + if m: | |
| 177 | + return float(max(int(m.group(1)) - 2, 0)) | |
| 178 | + key = _key(s) | |
| 179 | + if key in ("studio", "loft"): | |
| 180 | + return 0.0 | |
| 181 | + if key == "chambre": | |
| 182 | + return 1.0 | |
| 183 | + return None | |
| 184 | + | |
| 185 | + | |
| 186 | +def parse_bedrooms(*texts: str) -> float | None: | |
| 187 | + """Nombre de chambres explicite dans un texte : « 3 chambres », « 2 bedrooms ». | |
| 188 | + | |
| 189 | + Jamais de valeur inventée : None si aucun nombre plausible (0-12). | |
| 190 | + """ | |
| 191 | + for t in texts: | |
| 192 | + if not t: | |
| 193 | + continue | |
| 194 | + m = _BEDROOMS_TXT_RE.search(_key(str(t))) | |
| 195 | + if m: | |
| 196 | + n = int(m.group(1)) | |
| 197 | + if 0 <= n <= 12: | |
| 198 | + return float(n) | |
| 199 | + return None | |
| 200 | + | |
| 201 | + | |
| 202 | +def parse_bathrooms(*texts: str) -> float | None: | |
| 203 | + """Nombre de salles de bain : « 2 salles de bain », « 1.5 bath », « 1 sdb ».""" | |
| 204 | + for t in texts: | |
| 205 | + if not t: | |
| 206 | + continue | |
| 207 | + m = _BATHROOMS_TXT_RE.search(_key(str(t))) | |
| 208 | + if m: | |
| 209 | + try: | |
| 210 | + v = float(m.group(1).replace(",", ".")) | |
| 211 | + except ValueError: | |
| 212 | + continue | |
| 213 | + if 0.5 <= v <= 10: | |
| 214 | + return v | |
| 215 | + return None | |
| 216 | + | |
| 217 | + | |
| 218 | +def coerce_count(raw) -> float | None: | |
| 219 | + """« 2 », « 1,5 », 2, 2.0 -> float ; sinon None (valeurs de details JSON).""" | |
| 220 | + if raw is None or isinstance(raw, bool): | |
| 221 | + return None | |
| 222 | + if isinstance(raw, (int, float)): | |
| 223 | + v = float(raw) | |
| 224 | + return v if 0 <= v <= 20 else None | |
| 225 | + m = re.match(r"^\s*(\d+(?:[.,]5)?)\s*$", str(raw)) | |
| 226 | + if not m: | |
| 227 | + return None | |
| 228 | + v = float(m.group(1).replace(",", ".")) | |
| 229 | + return v if 0 <= v <= 20 else None | |
| 230 | + | |
| 231 | + | |
| 153 | 232 | # --------------------------------------------------------------------------- |
| 154 | 233 | # Date de disponibilité |
| 155 | 234 | # --------------------------------------------------------------------------- |
modified
louka/schema.py
+29 −0
@@ -18,12 +18,16 @@ import json | ||
| 18 | 18 | from dataclasses import dataclass, field, asdict |
| 19 | 19 | |
| 20 | 20 | from .normalize import ( # ré-exportés pour les connecteurs existants |
| 21 | + bedrooms_from_unit_type, | |
| 21 | 22 | clean_address, |
| 23 | + coerce_count, | |
| 22 | 24 | extract_details, |
| 23 | 25 | merge_details, |
| 24 | 26 | normalize_unit_type, |
| 25 | 27 | parse_area_sqft, |
| 26 | 28 | parse_availability_date, |
| 29 | + parse_bathrooms, | |
| 30 | + parse_bedrooms, | |
| 27 | 31 | parse_price, |
| 28 | 32 | price_is_from, |
| 29 | 33 | strip_accents, |
@@ -48,6 +52,8 @@ class Listing: | ||
| 48 | 52 | sector: str = "" # quartier/arrondissement (ex. Beauport) |
| 49 | 53 | city: str = "" # Québec, Lévis, ... |
| 50 | 54 | unit_type: str = "" # 1½ … 5½, 6½+, Studio, Loft, Chambre… |
| 55 | + bedrooms: float | None = None # chambres fermées (convention QC : 4½ = 2) | |
| 56 | + bathrooms: float | None = None # salles de bain (1.5 = salle d'eau en plus) | |
| 51 | 57 | price: float | None = None # loyer mensuel ($ CAD), le plus bas si "à partir de" |
| 52 | 58 | price_label: str = "" # texte original (ex. "à partir de 799$") |
| 53 | 59 | availability: str = "" # texte original (ex. "Libre immédiatement") |
@@ -104,6 +110,29 @@ class Listing: | ||
| 104 | 110 | derived["price_from"] = True |
| 105 | 111 | self.details = merge_details(self.details, derived) |
| 106 | 112 | |
| 113 | + # chambres / salles de bain : valeur explicite du connecteur d'abord, | |
| 114 | + # puis champs structurés de details, puis type d'unité (n½ -> n-2), | |
| 115 | + # puis extraction texte — jamais de valeur inventée (None = inconnu) | |
| 116 | + if self.bedrooms is None: | |
| 117 | + for k in ("bedrooms", "Chambres", "Chambre(s)"): | |
| 118 | + self.bedrooms = coerce_count(self.details.get(k)) | |
| 119 | + if self.bedrooms is not None: | |
| 120 | + break | |
| 121 | + if self.bedrooms is None: | |
| 122 | + self.bedrooms = bedrooms_from_unit_type(self.unit_type) | |
| 123 | + if self.bedrooms is None: | |
| 124 | + self.bedrooms = parse_bedrooms(self.title, " | ".join(self.amenities), | |
| 125 | + self.description) | |
| 126 | + if self.bathrooms is None: | |
| 127 | + for k in ("bathrooms", "Salles de bain", "Salle de bain", | |
| 128 | + "Salle(s) de bain"): | |
| 129 | + self.bathrooms = coerce_count(self.details.get(k)) | |
| 130 | + if self.bathrooms is not None: | |
| 131 | + break | |
| 132 | + if self.bathrooms is None: | |
| 133 | + self.bathrooms = parse_bathrooms(" | ".join(self.amenities), | |
| 134 | + self.description) | |
| 135 | + | |
| 107 | 136 | # description structurée (nettoyage + extraction + sections) |
| 108 | 137 | if self.digest is None and self.description: |
| 109 | 138 | try: |
| 110 | 139 | |