Connecteurs : rivero/trudel suspendus (RealVuu mort), groupe_dallaire via proxy Scrapfly, disjoncteur ingest, capreit/logisquebec/royal_lepage enrichis
- rivero + trudel : app.realvuu.com hors-ligne depuis 2026-08-10 (DNS NXDOMAIN) -> flag disabled=True (exclus du registre auto), statut sources.json mis a jour, 244 annonces zombies archivees (active=0, aucune suppression) - ingest : disjoncteur leger — une source en echec aux 5 derniers passages est sautee (log sync_log "disjoncteur: ..."), retentee 1 passage sur 5 - groupe_dallaire : le pare-feu bloque l IP du serveur (timeout TCP sur tout le domaine) -> timeout direct 8 s puis repli proxy Scrapfly (simple, puis escalade ASP residentiel apres 422 WAF) pour admin-ajax et fiches ; teste : 255/256 unites retrouvees en ~5 min - capreit : area_sqft (colonne pi2 de la fiche) et availability_date (earliest_date du flux) mappes directement — 117/117 vs 107/117 avant - logisquebec : details canoniques postal_code / contact.phone / rooms (JSON-LD) ; superficie : verifie, aucune source structuree sur la fiche - royal_lepage : enrichissement fiche detail (description "Remarques", superficie habitable, agence) avec cache detail2 + cap 100/cycle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 changed files +169 −22
modified
data/sources.json
+3 −3
@@ -78,7 +78,7 @@ | |||||
| 78 | "listing_url": "https://faubourgdumoulin.ca/disponibilite", | 78 | "listing_url": "https://faubourgdumoulin.ca/disponibilite", | ||
| 79 | "sectors": "Québec (Alizé I-II)", | 79 | "sectors": "Québec (Alizé I-II)", | ||
| 80 | "connector": "groupe_dallaire", | 80 | "connector": "groupe_dallaire", | ||
| 81 | − "status": "actif", | 81 | + "status": "actif (dégradé) — le pare-feu de faubourgdumoulin.ca bloque l'IP du serveur Lou-Ka depuis ~2026-08-17 : repli automatique sur proxy Scrapfly (timeout direct 8 s)", | ||
| 82 | "region": "Québec" | 82 | "region": "Québec" | ||
| 83 | }, | 83 | }, | ||
| 84 | { | 84 | { | ||
@@ -88,7 +88,7 @@ | |||||
| 88 | "listing_url": "https://18juillet.trudel.ca", | 88 | "listing_url": "https://18juillet.trudel.ca", | ||
| 89 | "sectors": "Vanier/Fleur de Lys, Charlesbourg", | 89 | "sectors": "Vanier/Fleur de Lys, Charlesbourg", | ||
| 90 | "connector": "trudel", | 90 | "connector": "trudel", | ||
| 91 | − "status": "actif", | 91 | + "status": "suspendu — widget RealVuu (app.realvuu.com) hors-ligne depuis 2026-08-10 (DNS NXDOMAIN) ; connecteur désactivé (disabled=True) et annonces archivées le 2026-08-18", | ||
| 92 | "region": "Québec" | 92 | "region": "Québec" | ||
| 93 | }, | 93 | }, | ||
| 94 | { | 94 | { | ||
@@ -368,7 +368,7 @@ | |||||
| 368 | "listing_url": "https://www.lerivero.ca", | 368 | "listing_url": "https://www.lerivero.ca", | ||
| 369 | "sectors": "Québec (rivière St-Charles)", | 369 | "sectors": "Québec (rivière St-Charles)", | ||
| 370 | "connector": "rivero", | 370 | "connector": "rivero", | ||
| 371 | − "status": "actif", | 371 | + "status": "suspendu — widget RealVuu (app.realvuu.com) hors-ligne depuis 2026-08-10 (DNS NXDOMAIN) ; connecteur désactivé (disabled=True) et annonces archivées le 2026-08-18", | ||
| 372 | "region": "Québec" | 372 | "region": "Québec" | ||
| 373 | }, | 373 | }, | ||
| 374 | { | 374 | { | ||
modified
louka/connectors/__init__.py
+2 −0
@@ -27,4 +27,6 @@ for _mod in pkgutil.iter_modules(__path__): | |||||
| 27 | for obj in vars(module).values(): | 27 | for obj in vars(module).values(): | ||
| 28 | if (isinstance(obj, type) and issubclass(obj, BaseConnector) | 28 | if (isinstance(obj, type) and issubclass(obj, BaseConnector) | ||
| 29 | and obj is not BaseConnector and getattr(obj, "source_id", "")): | 29 | and obj is not BaseConnector and getattr(obj, "source_id", "")): | ||
| 30 | + if getattr(obj, "disabled", False): # source hors-ligne (voir le module) | ||||
| 31 | + continue | ||||
| 30 | CONNECTORS[obj.source_id] = obj | 32 | CONNECTORS[obj.source_id] = obj | ||
modified
louka/connectors/base.py
+2 −0
@@ -35,6 +35,8 @@ class BaseConnector: | |||||
| 35 | request_delay: float = 0.6 # politesse entre requêtes | 35 | request_delay: float = 0.6 # politesse entre requêtes | ||
| 36 | timeout: int = 30 | 36 | timeout: int = 30 | ||
| 37 | use_detail_cache: bool = True # cache BD des pages détail | 37 | use_detail_cache: bool = True # cache BD des pages détail | ||
| 38 | + disabled: bool = False # True = source hors-ligne : le module est | ||||
| 39 | + # conservé mais exclu du registre (connectors/__init__.py) et donc de l'ingestion | ||||
| 38 | 40 | ||||
| 39 | def __init__(self) -> None: | 41 | def __init__(self) -> None: | ||
| 40 | self.session = requests.Session() | 42 | self.session = requests.Session() | ||
modified
louka/connectors/capreit.py
+15 −7
@@ -16,7 +16,8 @@ import re | |||||
| 16 | 16 | ||||
| 17 | from bs4 import BeautifulSoup | 17 | from bs4 import BeautifulSoup | ||
| 18 | 18 | ||||
| 19 | −from ..schema import (Listing, infer_city, normalize_unit_type, parse_price, | 19 | +from ..schema import (Listing, infer_city, normalize_unit_type, | ||
| 20 | + parse_area_sqft, parse_availability_date, parse_price, | ||||
| 20 | strip_accents) | 21 | strip_accents) | ||
| 21 | from .base import BaseConnector | 22 | from .base import BaseConnector | ||
| 22 | 23 | ||||
@@ -148,6 +149,12 @@ class CapreitConnector(BaseConnector): | |||||
| 148 | if incentive: | 149 | if incentive: | ||
| 149 | desc = f"Promotion : {incentive}. {desc}".strip() | 150 | desc = f"Promotion : {incentive}. {desc}".strip() | ||
| 150 | 151 | ||||
| 152 | + # date de disponibilité structurée du flux (ex. 20260201) | ||||
| 153 | + avail_date = None | ||||
| 154 | + ed = str(p.get("earliest_date") or "") | ||||
| 155 | + if re.fullmatch(r"20\d{6}", ed): | ||||
| 156 | + avail_date = f"{ed[:4]}-{ed[4:6]}-{ed[6:]}" | ||||
| 157 | + | ||||
| 151 | out: list[Listing] = [] | 158 | out: list[Listing] = [] | ||
| 152 | if rows: | 159 | if rows: | ||
| 153 | for r in rows: | 160 | for r in rows: | ||
@@ -167,7 +174,13 @@ class CapreitConnector(BaseConnector): | |||||
| 167 | price=parse_price(r["price"]), | 174 | price=parse_price(r["price"]), | ||
| 168 | price_label=r["price"], | 175 | price_label=r["price"], | ||
| 169 | availability=r["avail"], | 176 | availability=r["avail"], | ||
| 170 | − description=" — ".join(x for x in [desc, r["sqft"]] if x)[:600], | 177 | + # date de la ligne (« Disponible 1 sept. ») sinon | ||
| 178 | + # earliest_date structuré du flux | ||||
| 179 | + availability_date=(parse_availability_date(r["avail"]) | ||||
| 180 | + or avail_date), | ||||
| 181 | + # superficie structurée de la ligne (ex. « 875 pi² ») | ||||
| 182 | + area_sqft=parse_area_sqft(r["sqft"]), | ||||
| 183 | + description=desc[:600], | ||||
| 171 | amenities=amenities, | 184 | amenities=amenities, | ||
| 172 | images=images, | 185 | images=images, | ||
| 173 | lat=lat, | 186 | lat=lat, | ||
@@ -176,11 +189,6 @@ class CapreitConnector(BaseConnector): | |||||
| 176 | else: | 189 | else: | ||
| 177 | # repli : annonce par propriété avec le prix plancher du flux | 190 | # repli : annonce par propriété avec le prix plancher du flux | ||
| 178 | min_rent = p.get("min_rent") | 191 | min_rent = p.get("min_rent") | ||
| 179 | − # date de disponibilité structurée du flux (ex. 20260201) | ||||
| 180 | − avail_date = None | ||||
| 181 | − ed = str(p.get("earliest_date") or "") | ||||
| 182 | − if re.fullmatch(r"20\d{6}", ed): | ||||
| 183 | − avail_date = f"{ed[:4]}-{ed[4:6]}-{ed[6:]}" | ||||
| 184 | out.append(Listing( | 192 | out.append(Listing( | ||
| 185 | source=self.source_id, | 193 | source=self.source_id, | ||
| 186 | external_id=pid, | 194 | external_id=pid, | ||
modified
louka/connectors/groupe_dallaire.py
+56 −4
@@ -10,6 +10,11 @@ | |||||
| 10 | # (numéro, étage, prix, date de disponibilité, plan). Les fiches d'unité | 10 | # (numéro, étage, prix, date de disponibilité, plan). Les fiches d'unité | ||
| 11 | # (via cache BD) ajoutent type d'unité, superficie nette (« Pieds carrés ») | 11 | # (via cache BD) ajoutent type d'unité, superficie nette (« Pieds carrés ») | ||
| 12 | # et inclusions réelles (« Incluant »). | 12 | # et inclusions réelles (« Incluant »). | ||
| 13 | +# 2026-08-18 : le pare-feu du site bloque l'IP du serveur Lou-Ka (timeout TCP | ||||
| 14 | +# sur TOUT le domaine, même la page d'accueil — vérifié : OK depuis une autre | ||||
| 15 | +# IP). On tente d'abord la voie directe avec un timeout court (8 s), puis on | ||||
| 16 | +# bascule automatiquement sur Scrapfly en proxy simple (asp=False, | ||||
| 17 | +# render_js=False — 1 crédit/appel) pour rejouer les mêmes requêtes. | ||||
| 13 | # ----------------------------------------------------------------------------- | 18 | # ----------------------------------------------------------------------------- | ||
| 14 | from __future__ import annotations | 19 | from __future__ import annotations | ||
| 15 | 20 | ||||
@@ -18,7 +23,9 @@ import html as htmllib | |||||
| 18 | import json | 23 | import json | ||
| 19 | import re | 24 | import re | ||
| 20 | import time | 25 | import time | ||
| 26 | +from urllib.parse import urlencode | ||||
| 21 | 27 | ||||
| 28 | +import requests | ||||
| 22 | from bs4 import BeautifulSoup | 29 | from bs4 import BeautifulSoup | ||
| 23 | 30 | ||||
| 24 | from ..schema import Listing, normalize_unit_type, parse_price | 31 | from ..schema import Listing, normalize_unit_type, parse_price | ||
@@ -66,8 +73,10 @@ def _flatten(prefix: str, obj, out: dict) -> None: | |||||
| 66 | class GroupeDallaireConnector(BaseConnector): | 73 | class GroupeDallaireConnector(BaseConnector): | ||
| 67 | source_id = "groupe_dallaire" | 74 | source_id = "groupe_dallaire" | ||
| 68 | request_delay = 0.6 | 75 | request_delay = 0.6 | ||
| 76 | + timeout = 8 # IP bloquée par le WAF (timeout TCP) : échouer vite | ||||
| 69 | max_pages = 25 # garde-fou pagination | 77 | max_pages = 25 # garde-fou pagination | ||
| 70 | max_details = 150 # garde-fou fiches d'unité (vraies requêtes) | 78 | max_details = 150 # garde-fou fiches d'unité (vraies requêtes) | ||
| 79 | + _proxied = False # bascule Scrapfly (proxy simple) après 1 timeout | ||||
| 71 | 80 | ||||
| 72 | # -- POST throttlé --------------------------------------------------- | 81 | # -- POST throttlé --------------------------------------------------- | ||
| 73 | def _post(self, url: str, data: dict): | 82 | def _post(self, url: str, data: dict): | ||
@@ -79,14 +88,57 @@ class GroupeDallaireConnector(BaseConnector): | |||||
| 79 | resp.raise_for_status() | 88 | resp.raise_for_status() | ||
| 80 | return resp | 89 | return resp | ||
| 81 | 90 | ||||
| 91 | + # -- transport avec repli proxy (IP du serveur bloquée par le WAF) ---- | ||||
| 92 | + _asp = False # escalade ASP (résidentiel) après un échec amont | ||||
| 93 | + | ||||
| 94 | + def _proxy(self, url: str, method: str = "GET", | ||||
| 95 | + body: str | None = None) -> str: | ||||
| 96 | + """Proxy Scrapfly avec escalade : proxy simple (1 crédit) d'abord ; | ||||
| 97 | + le WAF coupe les rafales (ERR::SCRAPE::UPSTREAM_WEBSITE_ERROR dès la | ||||
| 98 | + 2e requête rapprochée — vérifié 2026-08-18), on escalade alors en ASP | ||||
| 99 | + résidentiel (fiable, mais ~325 crédits l'appel de ~1 Mo) pour le | ||||
| 100 | + reste de la passe.""" | ||||
| 101 | + headers = ({"Content-Type": "application/x-www-form-urlencoded"} | ||||
| 102 | + if method == "POST" else None) | ||||
| 103 | + last = 0 | ||||
| 104 | + for attempt in range(4): | ||||
| 105 | + if attempt: | ||||
| 106 | + time.sleep(5 * attempt) | ||||
| 107 | + res = self.scrapfly(url, render_js=False, asp=self._asp, | ||||
| 108 | + method=method, body=body, headers=headers) | ||||
| 109 | + last = res.get("status_code") or 0 | ||||
| 110 | + content = res.get("content") or "" | ||||
| 111 | + if 200 <= last < 400 and content: | ||||
| 112 | + return content | ||||
| 113 | + self._asp = True # erreur amont/WAF : escalade et retente | ||||
| 114 | + raise RuntimeError(f"proxy Scrapfly : HTTP {last} ({url})") | ||||
| 115 | + | ||||
| 116 | + def _post_json(self, data: dict) -> dict: | ||||
| 117 | + """POST admin-ajax : direct d'abord, proxy Scrapfly simple en repli.""" | ||||
| 118 | + if not self._proxied: | ||||
| 119 | + try: | ||||
| 120 | + return self._post(AJAX, data).json() | ||||
| 121 | + except (requests.ConnectionError, requests.Timeout): | ||||
| 122 | + self._proxied = True # IP bloquée : proxy pour toute la passe | ||||
| 123 | + return json.loads(self._proxy(AJAX, method="POST", body=urlencode(data))) | ||||
| 124 | + | ||||
| 125 | + def _get_html(self, url: str) -> str: | ||||
| 126 | + """GET fiche d'unité : direct d'abord, proxy Scrapfly simple en repli.""" | ||||
| 127 | + if not self._proxied: | ||||
| 128 | + try: | ||||
| 129 | + return self.get(url).text | ||||
| 130 | + except (requests.ConnectionError, requests.Timeout): | ||||
| 131 | + self._proxied = True | ||||
| 132 | + return self._proxy(url) | ||||
| 133 | + | ||||
| 82 | # --------------------------------------------------------------------- | 134 | # --------------------------------------------------------------------- | ||
| 83 | def fetch(self) -> list[Listing]: | 135 | def fetch(self) -> list[Listing]: | ||
| 84 | # 1) Premier appel lazy-load : items page 1 + query/widget_settings | 136 | # 1) Premier appel lazy-load : items page 1 + query/widget_settings | ||
| 85 | − first = self._post(AJAX, { | 137 | + first = self._post_json({ | ||
| 86 | "action": "jet_engine_ajax", | 138 | "action": "jet_engine_ajax", | ||
| 87 | "handler": "get_listing", | 139 | "handler": "get_listing", | ||
| 88 | **PAGE_SETTINGS, | 140 | **PAGE_SETTINGS, | ||
| 89 | − }).json() | 141 | + }) | ||
| 90 | html1 = (first.get("data") or {}).get("html", "") | 142 | html1 = (first.get("data") or {}).get("html", "") | ||
| 91 | 143 | ||||
| 92 | nav = None | 144 | nav = None | ||
@@ -113,7 +165,7 @@ class GroupeDallaireConnector(BaseConnector): | |||||
| 113 | _flatten("defaults", nav["query"], data) | 165 | _flatten("defaults", nav["query"], data) | ||
| 114 | _flatten("settings", nav["widget_settings"], data) | 166 | _flatten("settings", nav["widget_settings"], data) | ||
| 115 | try: | 167 | try: | ||
| 116 | − resp = self._post(AJAX, data).json() | 168 | + resp = self._post_json(data) | ||
| 117 | except Exception: | 169 | except Exception: | ||
| 118 | break | 170 | break | ||
| 119 | content = resp.get("content") or "" | 171 | content = resp.get("content") or "" | ||
@@ -152,7 +204,7 @@ class GroupeDallaireConnector(BaseConnector): | |||||
| 152 | if self._fetched >= self.max_details: | 204 | if self._fetched >= self.max_details: | ||
| 153 | raise RuntimeError("budget de fiches d'unité atteint") | 205 | raise RuntimeError("budget de fiches d'unité atteint") | ||
| 154 | self._fetched += 1 | 206 | self._fetched += 1 | ||
| 155 | − dhtml = self.get(url).text | 207 | + dhtml = self._get_html(url) | ||
| 156 | out: dict = {} | 208 | out: dict = {} | ||
| 157 | 209 | ||||
| 158 | dtext = re.sub(r"<script.*?</script>", "", dhtml, flags=re.S) | 210 | dtext = re.sub(r"<script.*?</script>", "", dhtml, flags=re.S) | ||
modified
louka/connectors/logisquebec.py
+9 −2
@@ -11,6 +11,9 @@ | |||||
| 11 | # caractéristiques actives (span .caracteristique-texte) dans le HTML. | 11 | # caractéristiques actives (span .caracteristique-texte) dans le HTML. | ||
| 12 | # Fiche expirée -> HTTP 410 Gone (retrait immédiat). Cache détail avec TTL | 12 | # Fiche expirée -> HTTP 410 Gone (retrait immédiat). Cache détail avec TTL | ||
| 13 | # (les prix changent) via _detailutil.TtlDetailCache. | 13 | # (les prix changent) via _detailutil.TtlDetailCache. | ||
| 14 | +# Superficie : la fiche n'a AUCUN champ structuré (ni JSON-LD floorSize, ni | ||||
| 15 | +# caractéristique dédiée — vérifié 2026-08-18) ; seule l'extraction texte de | ||||
| 16 | +# finalize() (description/commodités) s'applique, ~26 % du parc en donne une. | ||||
| 14 | # ----------------------------------------------------------------------------- | 17 | # ----------------------------------------------------------------------------- | ||
| 15 | from __future__ import annotations | 18 | from __future__ import annotations | ||
| 16 | 19 | ||||
@@ -168,11 +171,15 @@ class LogisQuebecConnector(BaseConnector): | |||||
| 168 | sector, city = (m.group(1), m.group(2)) if m else ("", locality) | 171 | sector, city = (m.group(1), m.group(2)) if m else ("", locality) | ||
| 169 | 172 | ||||
| 170 | price = payload.get("price") | 173 | price = payload.get("price") | ||
| 174 | + # clés canoniques (mêmes noms que normalize.extract_details : le | ||||
| 175 | + # merge_details de finalize() fusionne contact{} clé par clé) | ||||
| 171 | details: dict = {} | 176 | details: dict = {} | ||
| 172 | if payload.get("postal_code"): | 177 | if payload.get("postal_code"): | ||
| 173 | − details["Code postal"] = payload["postal_code"] | 178 | + details["postal_code"] = payload["postal_code"] | ||
| 174 | if payload.get("telephone"): | 179 | if payload.get("telephone"): | ||
| 175 | − details["Téléphone"] = payload["telephone"] | 180 | + details["contact"] = {"phone": payload["telephone"]} | ||
| 181 | + if payload.get("rooms"): | ||||
| 182 | + details["rooms"] = payload["rooms"] # numberOfRooms (JSON-LD) | ||||
| 176 | if court_terme: | 183 | if court_terme: | ||
| 177 | details["Location court terme"] = "oui" | 184 | details["Location court terme"] = "oui" | ||
| 178 | 185 | ||||
modified
louka/connectors/rivero.py
+3 −0
@@ -8,6 +8,8 @@ | |||||
| 8 | # plans/images). Phase 1 louée; phase 2 (livraison été 2028) en prélocation. | 8 | # plans/images). Phase 1 louée; phase 2 (livraison été 2028) en prélocation. | ||
| 9 | # Garde-fou : si toutes les unités « disponibles » affichent un prix | 9 | # Garde-fou : si toutes les unités « disponibles » affichent un prix | ||
| 10 | # identique (placeholder), le prix n'est pas retenu. | 10 | # identique (placeholder), le prix n'est pas retenu. | ||
| 11 | +# DÉSACTIVÉ 2026-08-18 : app.realvuu.com hors-ligne depuis le 2026-08-10 | ||||
| 12 | +# (DNS NXDOMAIN). Réactiver (disabled=False) si le widget RealVuu revit. | ||||
| 11 | # ----------------------------------------------------------------------------- | 13 | # ----------------------------------------------------------------------------- | ||
| 12 | from __future__ import annotations | 14 | from __future__ import annotations | ||
| 13 | 15 | ||||
@@ -61,6 +63,7 @@ def _unit_type_from_rooms(rooms: float, rooms_bed: int = 0) -> str: | |||||
| 61 | class RiveroConnector(BaseConnector): | 63 | class RiveroConnector(BaseConnector): | ||
| 62 | source_id = "rivero" | 64 | source_id = "rivero" | ||
| 63 | request_delay = 0.6 | 65 | request_delay = 0.6 | ||
| 66 | + disabled = True # widget RealVuu (app.realvuu.com) hors-ligne depuis 2026-08-10 | ||||
| 64 | 67 | ||||
| 65 | def fetch(self) -> list[Listing]: | 68 | def fetch(self) -> list[Listing]: | ||
| 66 | html = self.get(RV_URL).text | 69 | html = self.get(RV_URL).text | ||
modified
louka/connectors/royal_lepage.py
+44 −6
@@ -12,6 +12,10 @@ | |||||
| 12 | # et on dédoublonne par numéro MLS (dans l'URL /.../mls{no}/). Aucune | 12 | # et on dédoublonne par numéro MLS (dans l'URL /.../mls{no}/). Aucune | ||
| 13 | # dépendance Firecrawl : requêtes directes. Adapté du connecteur « à vendre » | 13 | # dépendance Firecrawl : requêtes directes. Adapté du connecteur « à vendre » | ||
| 14 | # d'Immo-Ka (agent-courtage/immoka). | 14 | # d'Immo-Ka (agent-courtage/immoka). | ||
| 15 | +# La fiche détail (accessible en direct, ~0,7 s) fournit ce que la carte | ||||
| 16 | +# liste n'a pas : description (« Remarques »), superficie habitable et | ||||
| 17 | +# bureau — enrichissement plafonné (DETAIL_LIMIT/cycle) + cache BD, le parc | ||||
| 18 | +# se complète au fil des synchronisations. | ||||
| 15 | # ----------------------------------------------------------------------------- | 19 | # ----------------------------------------------------------------------------- | ||
| 16 | from __future__ import annotations | 20 | from __future__ import annotations | ||
| 17 | 21 | ||||
@@ -21,15 +25,24 @@ import re | |||||
| 21 | 25 | ||||
| 22 | from .base import BaseConnector | 26 | from .base import BaseConnector | ||
| 23 | from . import _detailutil as du | 27 | from . import _detailutil as du | ||
| 24 | −from ..schema import Listing | 28 | +from ..schema import Listing, parse_area_sqft | ||
| 25 | 29 | ||||
| 26 | BASE = "https://www.royallepage.ca/fr/search/homes" | 30 | BASE = "https://www.royallepage.ca/fr/search/homes" | ||
| 27 | MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250) | 31 | MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250) | ||
| 28 | −# Le nom du BUREAU (« Royal LePage Humania »…) n'est pas sur la carte : il vient | 32 | +# Description, superficie habitable et bureau (« Royal LePage Humania »…) ne | ||
| 29 | −# de la fiche détail. Enrichissement plafonné + cache (se remplit au fil des cycles). | 33 | +# sont pas sur la carte : ils viennent de la fiche détail. Enrichissement | ||
| 30 | −DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "300")) | 34 | +# plafonné + cache (se remplit au fil des cycles). | ||
| 35 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "100")) | ||||
| 31 | _OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)', | 36 | _OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)', | ||
| 32 | re.S | re.I) | 37 | re.S | re.I) | ||
| 38 | +# bloc « Remarques » de la fiche : paragraphes de description (+ inclusions/ | ||||
| 39 | +# exclusions) jusqu'à la section suivante | ||||
| 40 | +_DESC_BLOCK_RE = re.compile(r"<h2>\s*Remarques\s*:?\s*</h2>(.*?)(?=<h[24]|" | ||||
| 41 | + r'<div class="details-row"|$)', re.S | re.I) | ||||
| 42 | +_DESC_P_RE = re.compile(r'<p class="body-15[^"]*">(.*?)</p>', re.S | re.I) | ||||
| 43 | +# « Superficie habitable (approx): » -> « 527 Pieds carrés » (label/value) | ||||
| 44 | +_AREA_VAL_RE = re.compile(r'Superficie habitable[^<]*:\s*</span>\s*' | ||||
| 45 | + r'<span class="value">\s*([^<]+)', re.I) | ||||
| 33 | 46 | ||||
| 34 | # Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches | 47 | # Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches | ||
| 35 | # + dédoublonnage MLS couvre la province. | 48 | # + dédoublonnage MLS couvre la province. | ||
@@ -88,8 +101,9 @@ class RoyalLepageConnector(BaseConnector): | |||||
| 88 | for name, lat, lng in ANCHORS: | 101 | for name, lat, lng in ANCHORS: | ||
| 89 | self._search_anchor(name, lat, lng, by_id) | 102 | self._search_anchor(name, lat, lng, by_id) | ||
| 90 | listings = list(by_id.values()) | 103 | listings = list(by_id.values()) | ||
| 91 | − # bureau (sous-agence) via la fiche détail — plafonné, cache accumulé | 104 | + # description + superficie + bureau via la fiche détail — plafonné, | ||
| 92 | − du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1") | 105 | + # cache accumulé (clé « detail2 » : remplace l'ancien cache office1) | ||
| 106 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_detail, key="detail2") | ||||
| 93 | return listings | 107 | return listings | ||
| 94 | 108 | ||||
| 95 | def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None: | 109 | def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None: | ||
@@ -189,3 +203,27 @@ def _parse_rlp_office(html: str) -> dict: | |||||
| 189 | return {} | 203 | return {} | ||
| 190 | office = _html.unescape(m.group(1)).strip() | 204 | office = _html.unescape(m.group(1)).strip() | ||
| 191 | return {"details": {"Agence": office}} if office else {} | 205 | return {"details": {"Agence": office}} if office else {} | ||
| 206 | + | ||||
| 207 | + | ||||
| 208 | +def _parse_rlp_detail(html: str) -> dict: | ||||
| 209 | + """Fiche détail complète : bureau + description (« Remarques », avec les | ||||
| 210 | + paragraphes Inclusions/Exclusions — extract_details les minera) + | ||||
| 211 | + superficie habitable (« 527 Pieds carrés »).""" | ||||
| 212 | + out = _parse_rlp_office(html) | ||||
| 213 | + m = _DESC_BLOCK_RE.search(html) | ||||
| 214 | + if m: | ||||
| 215 | + paras = [] | ||||
| 216 | + for p in _DESC_P_RE.findall(m.group(1)): | ||||
| 217 | + t = _html.unescape(re.sub(r"<br\s*/?>", "\n", p)) | ||||
| 218 | + t = re.sub(r"<[^>]+>", " ", t) | ||||
| 219 | + t = re.sub(r"[ \t]+", " ", t).strip() | ||||
| 220 | + if t: | ||||
| 221 | + paras.append(t) | ||||
| 222 | + if paras: | ||||
| 223 | + out["description"] = "\n\n".join(paras)[:6000] | ||||
| 224 | + m = _AREA_VAL_RE.search(html) | ||||
| 225 | + if m: | ||||
| 226 | + area = parse_area_sqft(m.group(1)) | ||||
| 227 | + if area: | ||||
| 228 | + out["area_sqft"] = area | ||||
| 229 | + return out | ||||
modified
louka/connectors/trudel.py
+3 −0
@@ -7,6 +7,8 @@ | |||||
| 7 | # RealVuu (client=trudel, project=fdl) dont la page contient toutes les | 7 | # RealVuu (client=trudel, project=fdl) dont la page contient toutes les | ||
| 8 | # unités en JSON (numéro, pièces, prix, disponibilité, plans/images). | 8 | # unités en JSON (numéro, pièces, prix, disponibilité, plans/images). | ||
| 9 | # Le Dix-Huit Juillet n'affiche aucune unité (landing de contact seulement). | 9 | # Le Dix-Huit Juillet n'affiche aucune unité (landing de contact seulement). | ||
| 10 | +# DÉSACTIVÉ 2026-08-18 : app.realvuu.com hors-ligne depuis le 2026-08-10 | ||||
| 11 | +# (DNS NXDOMAIN). Réactiver (disabled=False) si le widget RealVuu revit. | ||||
| 10 | # ----------------------------------------------------------------------------- | 12 | # ----------------------------------------------------------------------------- | ||
| 11 | from __future__ import annotations | 13 | from __future__ import annotations | ||
| 12 | 14 | ||||
@@ -55,6 +57,7 @@ def _unit_type_from_rooms(rooms: float) -> str: | |||||
| 55 | class TrudelConnector(BaseConnector): | 57 | class TrudelConnector(BaseConnector): | ||
| 56 | source_id = "trudel" | 58 | source_id = "trudel" | ||
| 57 | request_delay = 0.6 | 59 | request_delay = 0.6 | ||
| 60 | + disabled = True # widget RealVuu (app.realvuu.com) hors-ligne depuis 2026-08-10 | ||||
| 58 | 61 | ||||
| 59 | def fetch(self) -> list[Listing]: | 62 | def fetch(self) -> list[Listing]: | ||
| 60 | html = self.get(RV_URL).text | 63 | html = self.get(RV_URL).text | ||
modified
louka/ingest.py
+32 −0
@@ -13,17 +13,49 @@ import traceback | |||||
| 13 | from . import db | 13 | from . import db | ||
| 14 | from .connectors import CONNECTORS | 14 | from .connectors import CONNECTORS | ||
| 15 | 15 | ||||
| 16 | +# Disjoncteur (circuit breaker) : une source qui a échoué aux BREAKER_FAILS | ||||
| 17 | +# derniers passages consécutifs est SAUTÉE (les timeouts/retries des sources | ||||
| 18 | +# mortes ralentissaient tout le cycle), puis retentée un passage sur | ||||
| 19 | +# BREAKER_RETRY_EVERY. Les sauts sont consignés dans sync_log (ok=0, | ||||
| 20 | +# message « disjoncteur: … ») — ils comptent donc comme échecs consécutifs, | ||||
| 21 | +# ce qui fait avancer le compteur jusqu'à la prochaine retentative. | ||||
| 22 | +BREAKER_FAILS = 5 | ||||
| 23 | +BREAKER_RETRY_EVERY = 5 | ||||
| 24 | + | ||||
| 25 | + | ||||
| 26 | +def _consecutive_failures(con, source: str) -> int: | ||||
| 27 | + """Nombre d'entrées sync_log ok=0 consécutives (les plus récentes).""" | ||||
| 28 | + rows = con.execute( | ||||
| 29 | + "SELECT ok FROM sync_log WHERE source=? ORDER BY ts DESC LIMIT 60", | ||||
| 30 | + (source,)).fetchall() | ||||
| 31 | + n = 0 | ||||
| 32 | + for r in rows: | ||||
| 33 | + if r["ok"]: | ||||
| 34 | + break | ||||
| 35 | + n += 1 | ||||
| 36 | + return n | ||||
| 37 | + | ||||
| 16 | 38 | ||||
| 17 | def run(sources: list[str] | None = None) -> list[dict]: | 39 | def run(sources: list[str] | None = None) -> list[dict]: | ||
| 18 | """Exécute l'ingestion pour toutes les sources (ou celles demandées).""" | 40 | """Exécute l'ingestion pour toutes les sources (ou celles demandées).""" | ||
| 19 | con = db.connect() | 41 | con = db.connect() | ||
| 20 | results = [] | 42 | results = [] | ||
| 43 | + explicit = sources is not None # sync ciblé : ignorer le disjoncteur | ||||
| 21 | targets = sources or list(CONNECTORS.keys()) | 44 | targets = sources or list(CONNECTORS.keys()) | ||
| 22 | for sid in targets: | 45 | for sid in targets: | ||
| 23 | cls = CONNECTORS.get(sid) | 46 | cls = CONNECTORS.get(sid) | ||
| 24 | if cls is None: | 47 | if cls is None: | ||
| 25 | print(f"[lou-ka] connecteur inconnu : {sid}", file=sys.stderr) | 48 | print(f"[lou-ka] connecteur inconnu : {sid}", file=sys.stderr) | ||
| 26 | continue | 49 | continue | ||
| 50 | + if not explicit: | ||||
| 51 | + fails = _consecutive_failures(con, sid) | ||||
| 52 | + if fails >= BREAKER_FAILS and fails % BREAKER_RETRY_EVERY != 0: | ||||
| 53 | + print(f"[lou-ka] {sid} sauté (disjoncteur : " | ||||
| 54 | + f"{fails} échecs consécutifs)") | ||||
| 55 | + db.log_failure(con, sid, | ||||
| 56 | + f"disjoncteur: sauté ({fails} échecs consécutifs)") | ||||
| 57 | + results.append({"source": sid, "skipped": True, "fails": fails}) | ||||
| 58 | + continue | ||||
| 27 | t0 = time.time() | 59 | t0 = time.time() | ||
| 28 | print(f"[lou-ka] sync {sid} ...") | 60 | print(f"[lou-ka] sync {sid} ...") | ||
| 29 | try: | 61 | try: | ||
| 30 | 62 | ||||