SPB Git

Repository Public

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>
Simon-Pierre Boucher committed · Oct 8, 2026 parent f9aa650
9 changed files +19,841 −80 Browse files
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.

Menu

Theme