Commit
[ka2] fix connecteur res_soleil: refonte du site — grille de prix HTML remplace l'image OCR « Notre offre ». Les syncs du 2026-10-06 17:18 au 2026-10-07 rapportaient found=0 (dérive signalée, puis les 54 annonces retirées le 07 07:36) et le dernier passage échouait en DNS transitoire (vague systémique connue du nœud). Vérifié live : l'API wp-json closest_residence_locator est intacte (15 manoirs, 200), mais les fiches manoir ne publient PLUS l'image uploads/…/Notre-offre_*.jpg que le connecteur lisait par OCR tesseract — elles embarquent désormais une grille de prix HTML sémantique (section .lrs-<manoir>-pricing : .pricing-row avec .unit-type / .meal-plan « Sans repas | N repas/mois » / .monthly-price « X $ à Y $ »), confirmée sur les 15 fiches. Fix : _parse_grille lit les .pricing-row de la soupe (plus d'OCR, plus de subprocess tesseract, plus de cache detail — la grille est inline), schéma d'external_id {slug}-{typo}-5 et logique de prix (plancher « sans repas » sinon forfait le moins cher) inchangés → aucune ré-ingestion. Erreur FRANCHE si AUCUNE fiche n'a de grille (pattern sotramont bcdf125 : gabarit remanié ≠ vrai zéro — c'est exactement le zéro silencieux qui a causé les retraits). Fixtures enregistrées pour la première fois (16 requêtes, 55 annonces). Testé : fetch() venv → 55 annonces (1½ Manoir Brossard 1 716 $ 60 repas/mois, 3½ 1 464 $ sans repas, 15 photos — 54 historiques + 1 typologie), pytest res_soleil 2 passed, run.py sync res_soleil → found=55 ok (added=1, updated=54 : les 54 retirées réactivées, 16,3 s), 55 actives en BD, pm2 restart lou-ka-sync, site :8095 → 200.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
modified
louka/connectors/res_soleil.py
+44 −80
@@ -6,22 +6,16 @@ | ||
| 6 | 6 | # Pointe-aux-Trembles…). WordPress avec API REST maison : |
| 7 | 7 | # /wp-json/closest_residence_locator/coordinates/<lat>/<lng>/-1/fr retourne |
| 8 | 8 | # les 15 manoirs géolocalisés (adresse civique, ville, téléphone, url). |
| 9 | −# Les LOYERS ne sont publiés que dans la grille-image « Notre offre » | |
| 10 | −# (uploads/…/Notre-offre_<Manoir>_<année>.jpg) de chaque fiche manoir : | |
| 11 | −# on la lit par OCR (tesseract, requis sur l'hôte) — lignes « <typo> ½ | |
| 12 | −# <forfait repas> <min>$ à <max>$ ». Une annonce Lou-Ka par manoir × | |
| 13 | −# typologie (1½ studio … 5½), prix plancher de la fourchette « sans repas » | |
| 14 | −# quand elle existe, sinon du forfait repas le moins cher (noté en détails). | |
| 15 | −# Le résultat OCR est mis en cache (louka.db) sous la clé = URL de la grille, | |
| 16 | −# donc relu uniquement quand la résidence publie une nouvelle grille. | |
| 9 | +# Depuis la refonte d'octobre 2026, chaque fiche manoir publie une grille de | |
| 10 | +# prix HTML sémantique (section .lrs-<manoir>-pricing : .pricing-row avec | |
| 11 | +# .unit-type / .meal-plan / .monthly-price) — fini l'OCR de l'ancienne image | |
| 12 | +# « Notre offre ». Une annonce Lou-Ka par manoir × typologie (1½ studio … | |
| 13 | +# 5½), prix plancher de la fourchette « sans repas » quand elle existe, | |
| 14 | +# sinon du forfait repas le moins cher (noté en détails). | |
| 17 | 15 | # ----------------------------------------------------------------------------- |
| 18 | 16 | from __future__ import annotations |
| 19 | 17 | |
| 20 | 18 | import re |
| 21 | −import shutil | |
| 22 | −import subprocess | |
| 23 | −import sys | |
| 24 | −import tempfile | |
| 25 | 19 | |
| 26 | 20 | from bs4 import BeautifulSoup |
| 27 | 21 | |
@@ -31,20 +25,13 @@ from .base import BaseConnector | ||
| 31 | 25 | API = ("https://residencessoleil.ca/wp-json/closest_residence_locator/" |
| 32 | 26 | "coordinates/45.5/-73.5/-1/fr") |
| 33 | 27 | |
| 34 | −# grille de prix « Notre offre » (image pleine grandeur, pas les vignettes -NxN) | |
| 35 | −_GRILLE_RE = re.compile( | |
| 36 | − r"https://residencessoleil\.ca/wp-content/uploads/[^\"\s\\']*" | |
| 37 | − r"Notre-offre[^\"\s\\']*?\.jpe?g", re.I) | |
| 38 | −_SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+\.jpe?g$", re.I) | |
| 39 | 28 | _IMG_RE = re.compile( |
| 40 | 29 | r"https://residencessoleil\.ca/wp-content/uploads/[^\"\s\\']+" |
| 41 | 30 | r"\.(?:jpe?g|webp)", re.I) |
| 42 | 31 | _IMG_SKIP_RE = re.compile( |
| 43 | 32 | r"logo|icon|favicon|Notre-offre|grille|Programme|Meilleur-rapport|" |
| 44 | 33 | r"Feuillet|-\d+x\d+\.", re.I) |
| 45 | −# ligne de grille : le chiffre de typologie en début de ligne (le « ½ » de la | |
| 46 | −# colonne est souvent décollé par l'OCR : « 3 2 Sans repas … », « 4 172 … ») | |
| 47 | −_TYPO_RE = re.compile(r"^\s*([1-5])(?!\s*/)\b") | |
| 34 | +_TYPO_RE = re.compile(r"([1-5])\s*½") | |
| 48 | 35 | _PRICE_RE = re.compile(r"(\d[\d\s]{2,9}?)\s*\$") |
| 49 | 36 | _REPAS_RE = re.compile(r"(\d+)\s*(?:à\s*(\d+)\s*)?repas/mois") |
| 50 | 37 | |
@@ -54,55 +41,34 @@ class ResidencesSoleilConnector(BaseConnector): | ||
| 54 | 41 | request_delay = 0.8 |
| 55 | 42 | timeout = 45 |
| 56 | 43 | max_images = 15 |
| 44 | + use_detail_cache = False # la grille de prix est inline dans la page | |
| 57 | 45 | |
| 58 | − # -- OCR de la grille « Notre offre » ------------------------------------------ | |
| 59 | − def _ocr_grille(self, img_url: str) -> str: | |
| 60 | − """Texte OCR de la grille de prix (tesseract fra, mise en page psm 6).""" | |
| 61 | − if not shutil.which("tesseract"): | |
| 62 | − print("[lou-ka] res_soleil : tesseract introuvable — prix ignorés", | |
| 63 | − file=sys.stderr) | |
| 64 | − return "" | |
| 65 | − raw = self.get(img_url).content | |
| 66 | − with tempfile.NamedTemporaryFile(suffix=".jpg") as tf: | |
| 67 | − tf.write(raw) | |
| 68 | − tf.flush() | |
| 69 | − try: | |
| 70 | − proc = subprocess.run( | |
| 71 | − ["tesseract", tf.name, "stdout", "-l", "fra", "--psm", "6"], | |
| 72 | − capture_output=True, text=True, timeout=120) | |
| 73 | − except (OSError, subprocess.TimeoutExpired): | |
| 74 | − return "" | |
| 75 | − return proc.stdout or "" | |
| 76 | − | |
| 46 | + # -- grille de prix HTML de la fiche manoir -------------------------------- | |
| 77 | 47 | @staticmethod |
| 78 | − def _parse_grille(text: str) -> dict[int, list[dict]]: | |
| 79 | − """Lignes de la grille -> {typologie: [{forfait, min, max}, …]}. | |
| 80 | − | |
| 81 | − L'OCR insère des espaces parasites dans les montants (« 2 21 1 $ » = | |
| 82 | − 2 211 $) : on capture chiffres+espaces jusqu'au $ puis on compacte. | |
| 83 | − La typologie est le chiffre en début de ligne (1 à 5), jamais le | |
| 84 | − « 1/2 » seul d'une cellule éclatée sur deux lignes. | |
| 85 | − """ | |
| 48 | + def _parse_grille(soup: BeautifulSoup) -> dict[int, list[dict]]: | |
| 49 | + """Section .pricing-row -> {typologie: [{forfait, min, max}, …]}.""" | |
| 86 | 50 | rows: dict[int, list[dict]] = {} |
| 87 | − cur: int | None = None | |
| 88 | − for line in text.splitlines(): | |
| 89 | − line = line.strip() | |
| 90 | − if not line: | |
| 51 | + for row in soup.select("section[class*=pricing] .pricing-row"): | |
| 52 | + unit = row.select_one(".unit-type") | |
| 53 | + price = row.select_one(".monthly-price") | |
| 54 | + if not unit or not price: | |
| 91 | 55 | continue |
| 92 | − m = _TYPO_RE.match(line) | |
| 93 | − if m: | |
| 94 | − cur = int(m.group(1)) | |
| 56 | + tm = _TYPO_RE.search(unit.get_text(" ", strip=True)) | |
| 57 | + if not tm: | |
| 58 | + continue | |
| 59 | + typo = int(tm.group(1)) | |
| 95 | 60 | prices = [] |
| 96 | − for p in _PRICE_RE.findall(line): | |
| 61 | + for p in _PRICE_RE.findall(price.get_text(" ", strip=True)): | |
| 97 | 62 | try: |
| 98 | − val = int(p.replace(" ", "")) | |
| 63 | + val = int(re.sub(r"\s", "", p)) | |
| 99 | 64 | except ValueError: |
| 100 | 65 | continue |
| 101 | − if 700 <= val <= 9000: | |
| 66 | + if 500 <= val <= 20000: | |
| 102 | 67 | prices.append(val) |
| 103 | − if not prices or cur is None: | |
| 68 | + if not prices: | |
| 104 | 69 | continue |
| 105 | − low = line.lower() | |
| 70 | + meal = row.select_one(".meal-plan") | |
| 71 | + low = meal.get_text(" ", strip=True).lower() if meal else "" | |
| 106 | 72 | if re.search(r"s[ae]ns\s+repas", low): |
| 107 | 73 | forfait = "sans repas" |
| 108 | 74 | else: |
@@ -112,52 +78,41 @@ class ResidencesSoleilConnector(BaseConnector): | ||
| 112 | 78 | forfait = f"{n} repas/mois inclus" |
| 113 | 79 | else: |
| 114 | 80 | forfait = "forfait repas inclus" |
| 115 | − rows.setdefault(cur, []).append( | |
| 81 | + rows.setdefault(typo, []).append( | |
| 116 | 82 | {"forfait": forfait, "min": min(prices), "max": max(prices)}) |
| 117 | 83 | return rows |
| 118 | 84 | |
| 119 | − # -- fiche manoir : grille, galerie, présentation ------------------------------- | |
| 85 | + # -- fiche manoir : grille, galerie, présentation -------------------------- | |
| 120 | 86 | def _page_payload(self, url: str) -> dict: |
| 121 | 87 | html = self.get(url).text |
| 122 | − grilles = [u for u in _GRILLE_RE.findall(html) | |
| 123 | − if not _SIZE_SUFFIX_RE.search(u)] | |
| 124 | − grille = grilles[0] if grilles else "" | |
| 125 | 88 | images = [u for u in dict.fromkeys(_IMG_RE.findall(html)) |
| 126 | 89 | if not _IMG_SKIP_RE.search(u)][: self.max_images] |
| 127 | − intro = "" | |
| 128 | 90 | soup = BeautifulSoup(html, "html.parser") |
| 91 | + intro = "" | |
| 129 | 92 | for p in soup.find_all("p"): |
| 130 | 93 | t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip() |
| 131 | 94 | if len(t) >= 120 and "crédit d" not in t.lower(): |
| 132 | 95 | intro = t |
| 133 | 96 | break |
| 134 | − return {"grille": grille, "images": images, "intro": intro} | |
| 97 | + return {"rows": self._parse_grille(soup), "images": images, | |
| 98 | + "intro": intro} | |
| 135 | 99 | |
| 136 | 100 | def fetch(self) -> list[Listing]: |
| 137 | 101 | out: list[Listing] = [] |
| 138 | 102 | residences = self.get(API).json() |
| 139 | 103 | if not isinstance(residences, list): |
| 140 | 104 | return out |
| 105 | + pages_sans_grille: list[str] = [] | |
| 141 | 106 | for res in residences: |
| 142 | 107 | url = res.get("residence_url") or "" |
| 143 | 108 | title = (res.get("residence_title") or "").strip() |
| 144 | 109 | if not url or not title: |
| 145 | 110 | continue |
| 146 | 111 | slug = url.rstrip("/").rsplit("/", 1)[-1] |
| 147 | − try: | |
| 148 | − page = self._page_payload(url) | |
| 149 | − except Exception: | |
| 150 | − continue | |
| 151 | − grille_url = page.get("grille") or "" | |
| 152 | − if not grille_url: | |
| 153 | − continue | |
| 154 | − | |
| 155 | − # OCR mis en cache : relancé seulement si l'URL de la grille change | |
| 156 | − payload = self.detail( | |
| 157 | − slug, grille_url, | |
| 158 | − lambda u=grille_url: {"rows": self._parse_grille(self._ocr_grille(u))}) | |
| 159 | − rows = {int(k): v for k, v in (payload.get("rows") or {}).items()} | |
| 112 | + page = self._page_payload(url) | |
| 113 | + rows = page.get("rows") or {} | |
| 160 | 114 | if not rows: |
| 115 | + pages_sans_grille.append(slug) | |
| 161 | 116 | continue |
| 162 | 117 | |
| 163 | 118 | address = " ".join(x for x in (res.get("residence_street_number"), |
@@ -183,7 +138,7 @@ class ResidencesSoleilConnector(BaseConnector): | ||
| 183 | 138 | if best["forfait"] != "sans repas": |
| 184 | 139 | label += f" ({best['forfait']})" |
| 185 | 140 | details = {"Résidence pour aînés": "oui", |
| 186 | − "Loyer estimé": "grille officielle « Notre offre » " | |
| 141 | + "Loyer estimé": "grille de prix officielle " | |
| 187 | 142 | "de la résidence"} |
| 188 | 143 | if best["forfait"] != "sans repas": |
| 189 | 144 | details["Forfait repas"] = best["forfait"] |
@@ -203,4 +158,13 @@ class ResidencesSoleilConnector(BaseConnector): | ||
| 203 | 158 | lat=lat, |
| 204 | 159 | lng=lng, |
| 205 | 160 | )) |
| 161 | + # erreur FRANCHE : les 15 manoirs sont permanents — zéro grille de prix | |
| 162 | + # sur TOUTES les fiches = gabarit remanié, pas un vrai zéro (c'est | |
| 163 | + # l'ancienne image « Notre offre » disparue qui a causé les zéros | |
| 164 | + # silencieux d'octobre 2026). | |
| 165 | + if not out and residences: | |
| 166 | + raise RuntimeError( | |
| 167 | + "res_soleil : aucune grille de prix trouvée sur les " | |
| 168 | + f"{len(residences)} fiches manoir (ex. {pages_sans_grille[:3]}) " | |
| 169 | + "— gabarit probablement remanié, vérifier le connecteur") | |
| 206 | 170 | return out |
added
tests/fixtures/res_soleil/30072ef76897057c2b06.html
+2612 −0
Large diff (2,613 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/res_soleil/4a0492ed2c855621cb43.html
+2539 −0
Large diff (2,540 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/res_soleil/6251286eea48d5daecae.html
+2721 −0
Large diff (2,722 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/res_soleil/62de3c3de7088b9abd8f.html
+2666 −0
Large diff (2,667 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/res_soleil/74bd77a373badfe1d7f8.html
+2752 −0
Large diff (2,753 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/res_soleil/83fee5ee96d564e98b42.html
+2616 −0
Large diff (2,617 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/res_soleil/8920daafe8570e11beff.html
+3250 −0
Large diff (3,251 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/res_soleil/a1fa44990c0669f17919.html
+641 −0
Large diff (641 lines) is not loaded by default to keep the page fast.Load diff
Diff truncated — file too large.