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 | 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 | |