# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/res_soleil.py : Les Résidences Soleil (Groupe Savoie) — 15 manoirs # pour aînés au Québec (Montréal, Laval, Rive-Sud, Sherbrooke, Granby, # Pointe-aux-Trembles…). WordPress avec API REST maison : # /wp-json/closest_residence_locator/coordinates///-1/fr retourne # les 15 manoirs géolocalisés (adresse civique, ville, téléphone, url). # Les LOYERS ne sont publiés que dans la grille-image « Notre offre » # (uploads/…/Notre-offre__.jpg) de chaque fiche manoir : # on la lit par OCR (tesseract, requis sur l'hôte) — lignes « ½ # $ à $ ». Une annonce Lou-Ka par manoir × # typologie (1½ studio … 5½), prix plancher de la fourchette « sans repas » # quand elle existe, sinon du forfait repas le moins cher (noté en détails). # Le résultat OCR est mis en cache (louka.db) sous la clé = URL de la grille, # donc relu uniquement quand la résidence publie une nouvelle grille. # ----------------------------------------------------------------------------- from __future__ import annotations import re import shutil import subprocess import sys import tempfile from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector API = ("https://residencessoleil.ca/wp-json/closest_residence_locator/" "coordinates/45.5/-73.5/-1/fr") # grille de prix « Notre offre » (image pleine grandeur, pas les vignettes -NxN) _GRILLE_RE = re.compile( r"https://residencessoleil\.ca/wp-content/uploads/[^\"\s\\']*" r"Notre-offre[^\"\s\\']*?\.jpe?g", re.I) _SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+\.jpe?g$", re.I) _IMG_RE = re.compile( r"https://residencessoleil\.ca/wp-content/uploads/[^\"\s\\']+" r"\.(?:jpe?g|webp)", re.I) _IMG_SKIP_RE = re.compile( r"logo|icon|favicon|Notre-offre|grille|Programme|Meilleur-rapport|" r"Feuillet|-\d+x\d+\.", re.I) # ligne de grille : le chiffre de typologie en début de ligne (le « ½ » de la # colonne est souvent décollé par l'OCR : « 3 2 Sans repas … », « 4 172 … ») _TYPO_RE = re.compile(r"^\s*([1-5])(?!\s*/)\b") _PRICE_RE = re.compile(r"(\d[\d\s]{2,9}?)\s*\$") _REPAS_RE = re.compile(r"(\d+)\s*(?:à\s*(\d+)\s*)?repas/mois") class ResidencesSoleilConnector(BaseConnector): source_id = "res_soleil" request_delay = 0.8 timeout = 45 max_images = 15 # -- OCR de la grille « Notre offre » ------------------------------------------ def _ocr_grille(self, img_url: str) -> str: """Texte OCR de la grille de prix (tesseract fra, mise en page psm 6).""" if not shutil.which("tesseract"): print("[lou-ka] res_soleil : tesseract introuvable — prix ignorés", file=sys.stderr) return "" raw = self.get(img_url).content with tempfile.NamedTemporaryFile(suffix=".jpg") as tf: tf.write(raw) tf.flush() try: proc = subprocess.run( ["tesseract", tf.name, "stdout", "-l", "fra", "--psm", "6"], capture_output=True, text=True, timeout=120) except (OSError, subprocess.TimeoutExpired): return "" return proc.stdout or "" @staticmethod def _parse_grille(text: str) -> dict[int, list[dict]]: """Lignes de la grille -> {typologie: [{forfait, min, max}, …]}. L'OCR insère des espaces parasites dans les montants (« 2 21 1 $ » = 2 211 $) : on capture chiffres+espaces jusqu'au $ puis on compacte. La typologie est le chiffre en début de ligne (1 à 5), jamais le « 1/2 » seul d'une cellule éclatée sur deux lignes. """ rows: dict[int, list[dict]] = {} cur: int | None = None for line in text.splitlines(): line = line.strip() if not line: continue m = _TYPO_RE.match(line) if m: cur = int(m.group(1)) prices = [] for p in _PRICE_RE.findall(line): try: val = int(p.replace(" ", "")) except ValueError: continue if 700 <= val <= 9000: prices.append(val) if not prices or cur is None: continue low = line.lower() if re.search(r"s[ae]ns\s+repas", low): forfait = "sans repas" else: rm = _REPAS_RE.search(low) if rm: n = rm.group(1) + (f" à {rm.group(2)}" if rm.group(2) else "") forfait = f"{n} repas/mois inclus" else: forfait = "forfait repas inclus" rows.setdefault(cur, []).append( {"forfait": forfait, "min": min(prices), "max": max(prices)}) return rows # -- fiche manoir : grille, galerie, présentation ------------------------------- def _page_payload(self, url: str) -> dict: html = self.get(url).text grilles = [u for u in _GRILLE_RE.findall(html) if not _SIZE_SUFFIX_RE.search(u)] grille = grilles[0] if grilles else "" images = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not _IMG_SKIP_RE.search(u)][: self.max_images] intro = "" soup = BeautifulSoup(html, "html.parser") for p in soup.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip() if len(t) >= 120 and "crédit d" not in t.lower(): intro = t break return {"grille": grille, "images": images, "intro": intro} def fetch(self) -> list[Listing]: out: list[Listing] = [] residences = self.get(API).json() if not isinstance(residences, list): return out for res in residences: url = res.get("residence_url") or "" title = (res.get("residence_title") or "").strip() if not url or not title: continue slug = url.rstrip("/").rsplit("/", 1)[-1] try: page = self._page_payload(url) except Exception: continue grille_url = page.get("grille") or "" if not grille_url: continue # OCR mis en cache : relancé seulement si l'URL de la grille change payload = self.detail( slug, grille_url, lambda u=grille_url: {"rows": self._parse_grille(self._ocr_grille(u))}) rows = {int(k): v for k, v in (payload.get("rows") or {}).items()} if not rows: continue address = " ".join(x for x in (res.get("residence_street_number"), res.get("residence_street_name")) if x).strip() city = (res.get("residence_city") or "").strip() try: lat = float(res.get("residence_latitude")) lng = float(res.get("residence_longitude")) except (TypeError, ValueError): lat = lng = None for typo in sorted(rows): options = rows[typo] # loyer de référence : fourchette « sans repas » si publiée, # sinon le forfait repas le moins cher sans = [o for o in options if o["forfait"] == "sans repas"] best = (min(sans, key=lambda o: o["min"]) if sans else min(options, key=lambda o: o["min"])) raw_type = "1 ½ (studio)" if typo == 1 else f"{typo} ½" label = (f"De {best['min']:,} $ à {best['max']:,} $ par mois" .replace(",", " ")) if best["forfait"] != "sans repas": label += f" ({best['forfait']})" details = {"Résidence pour aînés": "oui", "Loyer estimé": "grille officielle « Notre offre » " "de la résidence"} if best["forfait"] != "sans repas": details["Forfait repas"] = best["forfait"] out.append(Listing( source=self.source_id, external_id=f"{slug}-{typo}-5", url=url, title=f"{raw_type} — Les Résidences Soleil {title}", address=address, city=city, unit_type=f"{typo}½", price=float(best["min"]), price_label=label, description=page.get("intro") or "", details=details, images=list(page.get("images") or []), lat=lat, lng=lng, )) return out