SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
9.0 KB · 207 lines python
Raw Blame History
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