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
3 days agolast push
HTML 98.9% Python 0.6%
8.1 KB · 183 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/logiluxx.py : connecteur Logiluxx / Investissements Cleary5#   (logiluxx.com — habitations locatives tout inclus LEED, 50 ans et mieux).6#   Trois immeubles, chacun avec son mini-site statique (HTML iso-8859-1) :7#   St-Hubert I et II (Faubourg Cousineau, Longueuil) et Boucherville.8#   Les sites St-Hubert affichent une table « PRIX » par typologie9#   (superficie @ superficie, loyer « à partir de X$ ») ; Boucherville n'a10#   que les plans (superficies) et fonctionne par liste d'attente.11#   Granularité : une annonce par immeuble × typologie.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122# (id, base URL, nom, secteur, ville) — les mini-sites existent aussi en23# sous-domaines (<id>.logiluxx.com) mais leur certificat TLS est invalide :24# on passe par les miroirs sur le domaine principal.25BUILDINGS = [26    ("sthubert", "https://logiluxx.com/sthubert/",27     "Logiluxx St-Hubert I", "Saint-Hubert", "Longueuil"),28    ("sthubert2", "https://logiluxx.com/sthubert2/",29     "Logiluxx St-Hubert II", "Saint-Hubert", "Longueuil"),30    ("boucherville", "https://logiluxx.com/boucherville/",31     "Logiluxx Boucherville", "", "Boucherville"),32]3334# ligne de la table PRIX : « 3 1/2  817 @ 1056  160  977 @ 1216  à partir de 1941$ »35PRICE_ROW_RE = re.compile(36    r"(\d)\s*1/2\s+([\d]+(?:\s*@\s*\d+)?)\s+(\d+)\s+[\d]+(?:\s*@\s*\d+)?\s+"37    r"[àa] partir de\s*([\d\s,]{3,9})\s*\$", re.I)38# ligne d'un plan : « 3 1/2  770  140  910 » (superficie, balcon, totale)39PLAN_ROW_RE = re.compile(r"(\d)\s*1/2\s+(\d{3,4})\s+(\d{2,3})\s+(\d{3,4})")40# typologies annoncées (« LA SUITE 3 1/2 », « LES PENTHOUSES 5 1/2 »)41SUITE_RE = re.compile(r"(?:SUITES?|PENTHOUSES?)\s+(\d)\s*1/2", re.I)42IMG_RE = re.compile(r'src="(images/projets/[^"]+\.(?:jpe?g|png))"', re.I)43INCLUSION_RE = re.compile(r"^-\s*(.{4,90})$")44PHONE_RE = re.compile(r"(\d{3})[.\s](\d{3})[.\s](\d{4})")4546PLAN_PAGE_OF = {"3": "plan3demi.html", "4": "plan4demi.html",47                "5": "plan5demi.html"}484950class LogiluxxConnector(BaseConnector):51    source_id = "logiluxx"52    request_delay = 0.85354    def _get_text(self, url: str) -> tuple[str, str]:55        """(html, texte aplati) — les mini-sites sont encodés iso-8859-156        (mais un fetch de secours via proxy peut renvoyer de l'UTF-8)."""57        resp = self.get(url)58        try:59            html = resp.content.decode("utf-8")60        except UnicodeDecodeError:61            html = resp.content.decode("iso-8859-1", errors="replace")62        soup = BeautifulSoup(html, "html.parser")63        for tag in soup(["script", "style", "noscript"]):64            tag.decompose()65        return html, re.sub(r"\s+", " ", soup.get_text(" ", strip=True))6667    def fetch(self) -> list[Listing]:68        # échec HONNÊTE : une erreur réseau/parse sur un immeuble doit lever69        # (ok=0 dans sync_log) et non être avalée — un « 0 trouvé ok » déclenche70        # la dérive et menace les 9 annonces (vu le 2026-09-07 12:10)71        listings: list[Listing] = []72        for bid, base, name, sector, city in BUILDINGS:73            found = self._fetch_building(bid, base, name, sector, city)74            if not found:75                raise RuntimeError(76                    f"logiluxx : aucune typologie parsée sur {base} "77                    "(rendu partiel ou site restructuré)")78            listings.extend(found)79        return listings8081    def _fetch_building(self, bid: str, base: str, name: str,82                        sector: str, city: str) -> list[Listing]:83        html, text = self._get_text(base)8485        # inclusions (« - Chauffage, électricité et eau chaude », …)86        amenities = ["Formule tout inclus (chauffage, électricité, eau chaude)",87                     "5 électroménagers en acier inoxydable",88                     "Climatisation", "Immeuble LEED",89                     "Pour les 50 ans et mieux"]90        for kw, label in [91            (r"[Pp]iscine", "Piscine chauffée"),92            (r"entra[îi]nement", "Centre d'entraînement"),93            (r"[Aa]scenseur", "Ascenseurs"),94            (r"[Cc][âa]blodistribution", "Câblodistribution (base) incluse"),95        ]:96            if re.search(kw, text):97                amenities.append(label)9899        contact: dict = {}100        m = PHONE_RE.search(text)101        if m:102            contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"103        m = re.search(r"[\w.\-]+@logiluxx\.com", text)104        if m:105            contact["email"] = m.group(0).lower()106107        images = [base + u for u in dict.fromkeys(IMG_RE.findall(html))108                  if "logo" not in u.lower()][:12]109110        waitlist = bool(re.search(r"LISTE D.ATTENTE", text, re.I))111        availability = "Liste d'attente" if waitlist else ""112113        out: list[Listing] = []114        seen: set[str] = set()115116        # 1) table PRIX (St-Hubert) : typologie + superficies + loyer plancher117        for n, area_rng, balcon, price_txt in PRICE_ROW_RE.findall(text):118            if n in seen:119                continue120            seen.add(n)121            price_label = f"à partir de {price_txt.strip()}$"122            area_min = float(area_rng.split("@")[0].strip())123            out.append(self._listing(124                bid, base, name, sector, city, n, price_label, area_min,125                f"Superficie : {area_rng.strip()} pi² + balcon {balcon} pi²",126                availability, amenities, contact, images))127128        # 2) sinon (Boucherville) : typologies des sections « PLANS DES129        #    SUITES », superficies lues sur la page plan de chaque typologie130        if not out:131            for n in dict.fromkeys(SUITE_RE.findall(html)):132                if n in seen:133                    continue134                seen.add(n)135                area_min, area_note = None, ""136                page = PLAN_PAGE_OF.get(n)137                if page:138                    try:139                        _, ptext = self._get_text(base + page)140                        areas = [float(a) for t, a, _b, _tot141                                 in PLAN_ROW_RE.findall(ptext) if t == n]142                        if areas:143                            area_min = min(areas)144                            area_note = (f"Superficie : "145                                         f"{int(min(areas))}–{int(max(areas))} pi²"146                                         if len(set(areas)) > 1 else147                                         f"Superficie : {int(areas[0])} pi²")148                    except Exception:149                        pass150                out.append(self._listing(151                    bid, base, name, sector, city, n, "", area_min,152                    area_note, availability, amenities, contact, images))153        return out154155    def _listing(self, bid, base, name, sector, city, n, price_label,156                 area_min, area_note, availability, amenities, contact,157                 images) -> Listing:158        ut = normalize_unit_type(f"{n}½")159        penthouse = (n == "5")160        desc = (f"{name} — habitations locatives haut de gamme tout inclus "161                f"pour les 50 ans et mieux (Investissements Cleary), "162                f"certification LEED. "163                + ("Penthouse. " if penthouse else "")164                + (area_note or ""))165        return Listing(166            source=self.source_id,167            external_id=f"{bid}-{n}.5",168            url=base,169            title=f"{name} — {ut}" + (" (penthouse)" if penthouse else ""),170            address="",171            sector=sector,172            city=city,173            unit_type=ut,174            price=parse_price(price_label),175            price_label=price_label,176            availability=availability,177            area_sqft=area_min,178            description=desc.strip(),179            amenities=list(dict.fromkeys(amenities)),180            details={"contact": dict(contact)} if contact else {},181            images=images,182        )183