SPB Git

spb/lou-ka

Public

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

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 (Aug 18, 2026) parent 603cca7 Browse files

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
Menu

Theme