Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/res_soleil.py : Les Résidences Soleil (Groupe Savoie) — 15 manoirs5# pour aînés au Québec (Montréal, Laval, Rive-Sud, Sherbrooke, Granby,6# Pointe-aux-Trembles…). WordPress avec API REST maison :7# /wp-json/closest_residence_locator/coordinates/<lat>/<lng>/-1/fr retourne8# 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.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re21import shutil22import subprocess23import sys24import tempfile2526from bs4 import BeautifulSoup2728from ..schema import Listing29from .base import BaseConnector3031API = ("https://residencessoleil.ca/wp-json/closest_residence_locator/"32 "coordinates/45.5/-73.5/-1/fr")3334# 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_IMG_RE = re.compile(40 r"https://residencessoleil\.ca/wp-content/uploads/[^\"\s\\']+"41 r"\.(?:jpe?g|webp)", re.I)42_IMG_SKIP_RE = re.compile(43 r"logo|icon|favicon|Notre-offre|grille|Programme|Meilleur-rapport|"44 r"Feuillet|-\d+x\d+\.", re.I)45# ligne de grille : le chiffre de typologie en début de ligne (le « ½ » de la46# 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")48_PRICE_RE = re.compile(r"(\d[\d\s]{2,9}?)\s*\$")49_REPAS_RE = re.compile(r"(\d+)\s*(?:à\s*(\d+)\s*)?repas/mois")505152class ResidencesSoleilConnector(BaseConnector):53 source_id = "res_soleil"54 request_delay = 0.855 timeout = 4556 max_images = 155758 # -- 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).content66 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 ""7677 @staticmethod78 def _parse_grille(text: str) -> dict[int, list[dict]]:79 """Lignes de la grille -> {typologie: [{forfait, min, max}, …]}.8081 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 le84 « 1/2 » seul d'une cellule éclatée sur deux lignes.85 """86 rows: dict[int, list[dict]] = {}87 cur: int | None = None88 for line in text.splitlines():89 line = line.strip()90 if not line:91 continue92 m = _TYPO_RE.match(line)93 if m:94 cur = int(m.group(1))95 prices = []96 for p in _PRICE_RE.findall(line):97 try:98 val = int(p.replace(" ", ""))99 except ValueError:100 continue101 if 700 <= val <= 9000:102 prices.append(val)103 if not prices or cur is None:104 continue105 low = line.lower()106 if re.search(r"s[ae]ns\s+repas", low):107 forfait = "sans repas"108 else:109 rm = _REPAS_RE.search(low)110 if rm:111 n = rm.group(1) + (f" à {rm.group(2)}" if rm.group(2) else "")112 forfait = f"{n} repas/mois inclus"113 else:114 forfait = "forfait repas inclus"115 rows.setdefault(cur, []).append(116 {"forfait": forfait, "min": min(prices), "max": max(prices)})117 return rows118119 # -- fiche manoir : grille, galerie, présentation -------------------------------120 def _page_payload(self, url: str) -> dict:121 html = self.get(url).text122 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 images = [u for u in dict.fromkeys(_IMG_RE.findall(html))126 if not _IMG_SKIP_RE.search(u)][: self.max_images]127 intro = ""128 soup = BeautifulSoup(html, "html.parser")129 for p in soup.find_all("p"):130 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()131 if len(t) >= 120 and "crédit d" not in t.lower():132 intro = t133 break134 return {"grille": grille, "images": images, "intro": intro}135136 def fetch(self) -> list[Listing]:137 out: list[Listing] = []138 residences = self.get(API).json()139 if not isinstance(residences, list):140 return out141 for res in residences:142 url = res.get("residence_url") or ""143 title = (res.get("residence_title") or "").strip()144 if not url or not title:145 continue146 slug = url.rstrip("/").rsplit("/", 1)[-1]147 try:148 page = self._page_payload(url)149 except Exception:150 continue151 grille_url = page.get("grille") or ""152 if not grille_url:153 continue154155 # OCR mis en cache : relancé seulement si l'URL de la grille change156 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()}160 if not rows:161 continue162163 address = " ".join(x for x in (res.get("residence_street_number"),164 res.get("residence_street_name"))165 if x).strip()166 city = (res.get("residence_city") or "").strip()167 try:168 lat = float(res.get("residence_latitude"))169 lng = float(res.get("residence_longitude"))170 except (TypeError, ValueError):171 lat = lng = None172173 for typo in sorted(rows):174 options = rows[typo]175 # loyer de référence : fourchette « sans repas » si publiée,176 # sinon le forfait repas le moins cher177 sans = [o for o in options if o["forfait"] == "sans repas"]178 best = (min(sans, key=lambda o: o["min"]) if sans179 else min(options, key=lambda o: o["min"]))180 raw_type = "1 ½ (studio)" if typo == 1 else f"{typo} ½"181 label = (f"De {best['min']:,} $ à {best['max']:,} $ par mois"182 .replace(",", " "))183 if best["forfait"] != "sans repas":184 label += f" ({best['forfait']})"185 details = {"Résidence pour aînés": "oui",186 "Loyer estimé": "grille officielle « Notre offre » "187 "de la résidence"}188 if best["forfait"] != "sans repas":189 details["Forfait repas"] = best["forfait"]190 out.append(Listing(191 source=self.source_id,192 external_id=f"{slug}-{typo}-5",193 url=url,194 title=f"{raw_type} — Les Résidences Soleil {title}",195 address=address,196 city=city,197 unit_type=f"{typo}½",198 price=float(best["min"]),199 price_label=label,200 description=page.get("intro") or "",201 details=details,202 images=list(page.get("images") or []),203 lat=lat,204 lng=lng,205 ))206 return out207