SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 603cca7

10 changed files +169 −22

modified data/sources.json +3 −3
@@ -78,7 +78,7 @@
78 78 "listing_url": "https://faubourgdumoulin.ca/disponibilite",
79 79 "sectors": "Québec (Alizé I-II)",
80 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 82 "region": "Québec"
83 83 },
84 84 {
@@ -88,7 +88,7 @@
88 88 "listing_url": "https://18juillet.trudel.ca",
89 89 "sectors": "Vanier/Fleur de Lys, Charlesbourg",
90 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 92 "region": "Québec"
93 93 },
94 94 {
@@ -368,7 +368,7 @@
368 368 "listing_url": "https://www.lerivero.ca",
369 369 "sectors": "Québec (rivière St-Charles)",
370 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 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 27 for obj in vars(module).values():
28 28 if (isinstance(obj, type) and issubclass(obj, BaseConnector)
29 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 32 CONNECTORS[obj.source_id] = obj
modified louka/connectors/base.py +2 −0
@@ -35,6 +35,8 @@ class BaseConnector:
35 35 request_delay: float = 0.6 # politesse entre requêtes
36 36 timeout: int = 30
37 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 41 def __init__(self) -> None:
40 42 self.session = requests.Session()
modified louka/connectors/capreit.py +15 −7
@@ -16,7 +16,8 @@ import re
16 16
17 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 21 strip_accents)
21 22 from .base import BaseConnector
22 23
@@ -148,6 +149,12 @@ class CapreitConnector(BaseConnector):
148 149 if incentive:
149 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 158 out: list[Listing] = []
152 159 if rows:
153 160 for r in rows:
@@ -167,7 +174,13 @@ class CapreitConnector(BaseConnector):
167 174 price=parse_price(r["price"]),
168 175 price_label=r["price"],
169 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 184 amenities=amenities,
172 185 images=images,
173 186 lat=lat,
@@ -176,11 +189,6 @@ class CapreitConnector(BaseConnector):
176 189 else:
177 190 # repli : annonce par propriété avec le prix plancher du flux
178 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 192 out.append(Listing(
185 193 source=self.source_id,
186 194 external_id=pid,
modified louka/connectors/groupe_dallaire.py +56 −4
@@ -10,6 +10,11 @@
10 10 # (numéro, étage, prix, date de disponibilité, plan). Les fiches d'unité
11 11 # (via cache BD) ajoutent type d'unité, superficie nette (« Pieds carrés »)
12 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 19 from __future__ import annotations
15 20
@@ -18,7 +23,9 @@ import html as htmllib
18 23 import json
19 24 import re
20 25 import time
26 +from urllib.parse import urlencode
21 27
28 +import requests
22 29 from bs4 import BeautifulSoup
23 30
24 31 from ..schema import Listing, normalize_unit_type, parse_price
@@ -66,8 +73,10 @@ def _flatten(prefix: str, obj, out: dict) -> None:
66 73 class GroupeDallaireConnector(BaseConnector):
67 74 source_id = "groupe_dallaire"
68 75 request_delay = 0.6
76 + timeout = 8 # IP bloquée par le WAF (timeout TCP) : échouer vite
69 77 max_pages = 25 # garde-fou pagination
70 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 81 # -- POST throttlé ---------------------------------------------------
73 82 def _post(self, url: str, data: dict):
@@ -79,14 +88,57 @@ class GroupeDallaireConnector(BaseConnector):
79 88 resp.raise_for_status()
80 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 135 def fetch(self) -> list[Listing]:
84 136 # 1) Premier appel lazy-load : items page 1 + query/widget_settings
85 − first = self._post(AJAX, {
137 + first = self._post_json({
86 138 "action": "jet_engine_ajax",
87 139 "handler": "get_listing",
88 140 **PAGE_SETTINGS,
89 − }).json()
141 + })
90 142 html1 = (first.get("data") or {}).get("html", "")
91 143
92 144 nav = None
@@ -113,7 +165,7 @@ class GroupeDallaireConnector(BaseConnector):
113 165 _flatten("defaults", nav["query"], data)
114 166 _flatten("settings", nav["widget_settings"], data)
115 167 try:
116 − resp = self._post(AJAX, data).json()
168 + resp = self._post_json(data)
117 169 except Exception:
118 170 break
119 171 content = resp.get("content") or ""
@@ -152,7 +204,7 @@ class GroupeDallaireConnector(BaseConnector):
152 204 if self._fetched >= self.max_details:
153 205 raise RuntimeError("budget de fiches d'unité atteint")
154 206 self._fetched += 1
155 − dhtml = self.get(url).text
207 + dhtml = self._get_html(url)
156 208 out: dict = {}
157 209
158 210 dtext = re.sub(r"<script.*?</script>", "", dhtml, flags=re.S)
modified louka/connectors/logisquebec.py +9 −2
@@ -11,6 +11,9 @@
11 11 # caractéristiques actives (span .caracteristique-texte) dans le HTML.
12 12 # Fiche expirée -> HTTP 410 Gone (retrait immédiat). Cache détail avec TTL
13 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 18 from __future__ import annotations
16 19
@@ -168,11 +171,15 @@ class LogisQuebecConnector(BaseConnector):
168 171 sector, city = (m.group(1), m.group(2)) if m else ("", locality)
169 172
170 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 176 details: dict = {}
172 177 if payload.get("postal_code"):
173 − details["Code postal"] = payload["postal_code"]
178 + details["postal_code"] = payload["postal_code"]
174 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 183 if court_terme:
177 184 details["Location court terme"] = "oui"
178 185
modified louka/connectors/rivero.py +3 −0
@@ -8,6 +8,8 @@
8 8 # plans/images). Phase 1 louée; phase 2 (livraison été 2028) en prélocation.
9 9 # Garde-fou : si toutes les unités « disponibles » affichent un prix
10 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 14 from __future__ import annotations
13 15
@@ -61,6 +63,7 @@ def _unit_type_from_rooms(rooms: float, rooms_bed: int = 0) -> str:
61 63 class RiveroConnector(BaseConnector):
62 64 source_id = "rivero"
63 65 request_delay = 0.6
66 + disabled = True # widget RealVuu (app.realvuu.com) hors-ligne depuis 2026-08-10
64 67
65 68 def fetch(self) -> list[Listing]:
66 69 html = self.get(RV_URL).text
modified louka/connectors/royal_lepage.py +44 −6
@@ -12,6 +12,10 @@
12 12 # et on dédoublonne par numéro MLS (dans l'URL /.../mls{no}/). Aucune
13 13 # dépendance Firecrawl : requêtes directes. Adapté du connecteur « à vendre »
14 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 20 from __future__ import annotations
17 21
@@ -21,15 +25,24 @@ import re
21 25
22 26 from .base import BaseConnector
23 27 from . import _detailutil as du
24 −from ..schema import Listing
28 +from ..schema import Listing, parse_area_sqft
25 29
26 30 BASE = "https://www.royallepage.ca/fr/search/homes"
27 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
29 −# de la fiche détail. Enrichissement plafonné + cache (se remplit au fil des cycles).
30 −DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "300"))
32 +# Description, superficie habitable et bureau (« Royal LePage Humania »…) ne
33 +# sont pas sur la carte : ils viennent de la fiche détail. Enrichissement
34 +# plafonné + cache (se remplit au fil des cycles).
35 +DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "100"))
31 36 _OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)',
32 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 47 # Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches
35 48 # + dédoublonnage MLS couvre la province.
@@ -88,8 +101,9 @@ class RoyalLepageConnector(BaseConnector):
88 101 for name, lat, lng in ANCHORS:
89 102 self._search_anchor(name, lat, lng, by_id)
90 103 listings = list(by_id.values())
91 − # bureau (sous-agence) via la fiche détail — plafonné, cache accumulé
92 − du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1")
104 + # description + superficie + bureau via la fiche détail — plafonné,
105 + # cache accumulé (clé « detail2 » : remplace l'ancien cache office1)
106 + du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_detail, key="detail2")
93 107 return listings
94 108
95 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 203 return {}
190 204 office = _html.unescape(m.group(1)).strip()
191 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 7 # RealVuu (client=trudel, project=fdl) dont la page contient toutes les
8 8 # unités en JSON (numéro, pièces, prix, disponibilité, plans/images).
9 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 13 from __future__ import annotations
12 14
@@ -55,6 +57,7 @@ def _unit_type_from_rooms(rooms: float) -> str:
55 57 class TrudelConnector(BaseConnector):
56 58 source_id = "trudel"
57 59 request_delay = 0.6
60 + disabled = True # widget RealVuu (app.realvuu.com) hors-ligne depuis 2026-08-10
58 61
59 62 def fetch(self) -> list[Listing]:
60 63 html = self.get(RV_URL).text
modified louka/ingest.py +32 −0
@@ -13,17 +13,49 @@ import traceback
13 13 from . import db
14 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 39 def run(sources: list[str] | None = None) -> list[dict]:
18 40 """Exécute l'ingestion pour toutes les sources (ou celles demandées)."""
19 41 con = db.connect()
20 42 results = []
43 + explicit = sources is not None # sync ciblé : ignorer le disjoncteur
21 44 targets = sources or list(CONNECTORS.keys())
22 45 for sid in targets:
23 46 cls = CONNECTORS.get(sid)
24 47 if cls is None:
25 48 print(f"[lou-ka] connecteur inconnu : {sid}", file=sys.stderr)
26 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 59 t0 = time.time()
28 60 print(f"[lou-ka] sync {sid} ...")
29 61 try:
30 62