SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.4 KB · 213 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/la_cour.py : connecteur La Cour Appartements (lacourappartements.com)5#   155, rue du Prince-Édouard, Saint-Roch (Québec) — 159 unités sur 8 étages.6#   Site WordPress avec le plugin « do-selecteur-plans » (même plateforme que7#   le1070.ca / kwayaweh.ca côté Immostar, et que AULA Lévis) :8#     - page /plans/ : polygones SVG par unité (class disponible/indisponible,9#       data-nom = n° d'unité, data-etage, data-type, data-prix, data-promotion)10#     - REST /wp-json/do-selecteur-plans/v1/unite?id=<data-numero> : montant,11#       superficie habitable/terrasse, date de libération, inclusions par unité,12#       plan (galerie PNG + PDF)13#   La carte SVG (attributs data-*) sert de clé de cache BD (self.detail) : le14#   REST n'est re-frappé que si l'unité a changé depuis la dernière sync.15#   La classe est PARTAGÉE : AulaConnector (aula.py) en hérite et ne change16#   que les constantes du projet — le parsing est identique.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import json22import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, infer_city, parse_price27from .base import BaseConnector2829# photos du projet dans wp-content/uploads (jamais les logos, icônes, plans,30# lettrages décoratifs ni les variantes redimensionnées -1536x864 etc.)31_IMG_RE = re.compile(32    r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp)',33    re.I)34_SKIP_IMG = re.compile(35    r"logo|icon|favicon|plan|selecteur|doodle|lettre|-\d{2,4}x\d{2,4}\.", re.I)363738class LaCourConnector(BaseConnector):39    source_id = "la_cour"40    request_delay = 0.641    max_rest_units = 90          # garde-fou : appels REST max par sync42    max_images = 104344    # -- constantes du projet (surchargées par AulaConnector) ------------------45    BASE = "https://lacourappartements.com"46    PLANS_PATH = "plans/"47    NAME = "La Cour Appartements"48    ADDRESS = "155, rue du Prince-Édouard, Québec, G1K 5V6"49    SECTOR = "Saint-Roch"50    CITY = "Québec"51    PHOTO_PAGES = ("projet/",)   # pages crawlées pour les photos du projet52    AMENITY_PAGES = ()           # pages avec commodités (widgets Elementor/Jet)5354    # -- helpers ---------------------------------------------------------------55    def _site_images(self) -> list[str]:56        """Photos du projet (uploads jpg/webp des pages PHOTO_PAGES)."""57        imgs: list[str] = []58        for page in self.PHOTO_PAGES:59            try:60                html = self.get(f"{self.BASE}/{page}").text61            except Exception:62                continue63            for u in dict.fromkeys(_IMG_RE.findall(html)):64                if not _SKIP_IMG.search(u) and u not in imgs:65                    imgs.append(u)66        return imgs[: self.max_images]6768    def _project_amenities(self) -> list[str]:69        """Commodités du projet : items Jet Engine70        (.jet-listing-dynamic-repeater__item) des pages AMENITY_PAGES, plus les71        titres h6 Elementor de la première page (espaces communs). Les notes72        du genre « Disponible sur les étages… » sont ignorées."""73        items: list[str] = []74        for i, page in enumerate(self.AMENITY_PAGES):75            try:76                html = self.get(f"{self.BASE}/{page}").text77            except Exception:78                continue79            soup = BeautifulSoup(html, "html.parser")80            sel = ".jet-listing-dynamic-repeater__item"81            if i == 0:82                sel += ", h6.elementor-heading-title"83            for el in soup.select(sel):84                t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()85                if (3 <= len(t) <= 90 and t not in items86                        and not t.lower().startswith("disponible sur")):87                    items.append(t)88        return items[:30]8990    def _svg_cards(self, html: str) -> list[tuple[str, str, dict]]:91        """Unités disponibles du sélecteur SVG : (n° d'unité, post-id, data-*).9293        Chaque unité apparaît deux fois (polygone + bouton mobile) : dédup par94        n° d'unité. Les classes « indisponible » et « attente » sont exclues.95        """96        soup = BeautifulSoup(html, "html.parser")97        cards: list[tuple[str, str, dict]] = []98        seen: set[str] = set()99        for el in soup.select("[data-numero]"):100            cls = " ".join(el.get("class") or [])101            if not re.search(r"\bdisponible\b", cls) \102                    or re.search(r"indisponible|attente", cls):103                continue104            nom = (el.get("data-nom") or "").strip()105            pid = (el.get("data-numero") or "").strip()106            if not nom or not pid or nom in seen:107                continue108            seen.add(nom)109            attrs = {k: str(v) for k, v in el.attrs.items()110                     if k.startswith("data-")}111            attrs["class"] = cls112            cards.append((nom, pid, attrs))113        return cards114115    # -- fetch -----------------------------------------------------------------116    def fetch(self) -> list[Listing]:117        html = self.get(f"{self.BASE}/{self.PLANS_PATH}").text118        cards = self._svg_cards(html)119        images = self._site_images()120        project_amenities = self._project_amenities()121        rest_calls = 0122123        out: list[Listing] = []124        for nom, pid, attrs in cards[: self.max_rest_units]:125            def _fetch(p=pid):126                nonlocal rest_calls127                if rest_calls >= self.max_rest_units:128                    raise RuntimeError("budget de requêtes REST atteint")129                rest_calls += 1130                return self.get(131                    f"{self.BASE}/wp-json/do-selecteur-plans/v1/unite",132                    params={"id": p}).json()133            try:134                # clé de cache BD = attributs SVG (prix/type/promo/état) : le135                # REST n'est appelé que si l'unité est nouvelle ou modifiée136                key = hashlib.sha1(json.dumps(137                    attrs, sort_keys=True).encode()).hexdigest()[:20]138                data = self.detail(nom, key, _fetch)139            except Exception:140                continue141            if not data or data.get("etat") != "disponible":142                continue143144            t = str(data.get("type") or attrs.get("data-type") or "")145            sup = str(data.get("superficie_habitable") or "").strip()146            terrasse = str(data.get("superficie_terrasse") or "").strip()147            etage = (attrs.get("data-etage") or "").strip()148149            # disponibilité : date de libération (« | Janvier 2027 ») sinon150            # le libellé « a_partir » (« Libre le 8 août 2026 » / « Libre »)151            date_libre = str(data.get("date_libre") or "").strip(" |")152            a_partir = str(data.get("a_partir") or "").strip()153            avail = (f"Libre {date_libre}" if date_libre154                     else a_partir or "Disponible")155156            # prix : montant REST (« 1 453 $ »), repli sur data-prix du SVG157            montant = str(data.get("montant") or "").strip()158            if not montant and (attrs.get("data-prix") or "").strip():159                montant = f"{attrs['data-prix'].strip()} $"160            price_label = f"{montant} par mois" if montant else ""161162            desc = " — ".join(x for x in [163                t, f"{sup} pi²" if sup else "",164                f"terrasse {terrasse} pi²" if terrasse else "",165                f"étage {etage}" if etage else ""] if x)166167            # inclusions par unité (La Cour) + commodités du projet (AULA)168            amenities = [str(a).strip() for a in (data.get("inclusion") or [])169                         if str(a).strip()]170            amenities += [a for a in project_amenities if a not in amenities]171172            # plan de l'unité (galerie REST) en tête des photos du projet173            imgs = list(images)174            for g in reversed(data.get("gallery") or []):175                gu = g.get("url") if isinstance(g, dict) else str(g or "")176                if gu.startswith("http") and gu not in imgs:177                    imgs.insert(0, gu)178179            details: dict = {}180            if etage.isdigit():181                details["floor"] = int(etage)182            pdf = str(data.get("pdf") or "")183            if pdf.startswith("http"):184                details["plan_pdf"] = pdf185186            sup_val = None187            try:188                v = float(sup.replace(" ", "").replace(",", "."))189                if 80 <= v <= 20000:190                    sup_val = v191            except ValueError:192                pass193194            out.append(Listing(195                source=self.source_id,196                external_id=nom,197                url=f"{self.BASE}/{self.PLANS_PATH}#unite-{nom}",198                title=f"{self.NAME} — Unité {nom}",199                address=self.ADDRESS,200                sector=self.SECTOR,201                city=infer_city(self.SECTOR, default=self.CITY),202                unit_type=t,203                price=parse_price(montant),204                price_label=price_label,205                availability=avail,206                area_sqft=sup_val,207                description=desc[:600],208                amenities=amenities[:30],209                details=details,210                images=imgs[: self.max_images + 3],211            ))212        return out213