SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.7 KB · 202 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/place_florimay.py : connecteur Place Florimay (placeflorimay.ca)5#   Boulevard Taché Ouest, Montmagny (Chaudière-Appalaches) — 170 condos6#   locatifs en 3 phases ; seule la phase 2 (60 unités, 5 étages) est exposée7#   dans le sélecteur du site.8#   Site WordPress (Elementor/Qode) avec le sélecteur d'unités « do1011 »9#   (variante du plugin do-selecteur-plans utilisé par La Cour / AULA /10#   Le St-Georges), rendu côté serveur :11#     - polygones SVG par unité : class disponible / indisponible,12#       data-numero (id du contenu WP), data-etage, data-type (« 4½ coin »),13#       data-prix, data-promotion ;14#     - REST /wp-json/do-selecteur-plans/v1/unite?id=<data-numero> :15#       n° d'unité, état (« disponible » / « indisponible » + libellé16#       « Louée »), type, superficie habitable et de balcon, plan PDF, galerie.17#   Cette variante du plugin ne renvoie PAS de champ `montant` : le loyer18#   publié est celui du polygone (data-prix) — aucune autre valeur n'est19#   supposée. La carte SVG sert de clé de cache BD (self.detail) : le REST20#   n'est rappelé que si l'unité a changé.21#   ⚠ Cloudflare renvoie 403 aux robots sans en-tête de navigateur : le22#   User-Agent de base.py (Chrome + LouKaBot) passe. robots.txt : Allow: /23#   pour les agents génériques (blocage ciblé des robots d'entraînement IA) ;24#   signal de contenu « use=reference », compatible avec un agrégateur qui25#   renvoie vers la source.26#   ⚠ État du marché au 2026-08-09 : les phases 1 et 3 sont complètes et les27#   60 unités de la phase 2 sont toutes « Louée » -> le connecteur retourne28#   0 annonce tant qu'aucune unité n'est remise en location (vérifié aussi29#   unité par unité via le REST).30# -----------------------------------------------------------------------------31from __future__ import annotations3233import hashlib34import json35import re3637from bs4 import BeautifulSoup3839from ..schema import Listing, normalize_unit_type, parse_price40from .base import BaseConnector4142BASE = "https://placeflorimay.ca"43SELECTOR_PATH = "/"             # le sélecteur d'unités est sur l'accueil44REST_UNIT = f"{BASE}/wp-json/do-selecteur-plans/v1/unite"45NAME = "Place Florimay"46# seule adresse publiée par la source (bureau de location du projet) :47# les adresses civiques par phase ne sont pas affichées sur le site.48ADDRESS = "136, boulevard Taché Ouest, Montmagny, QC G5V 3A5"49CITY = "Montmagny"5051_IMG_RE = re.compile(52    r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+'53    r'\.(?:jpg|jpeg|png|webp)', re.I)54_SKIP_IMG = re.compile(55    r"logo|icon|favicon|flavicon|cropped-|plan|selecteur"56    r"|-\d{2,4}x\d{2,4}\.", re.I)575859class PlaceFlorimayConnector(BaseConnector):60    source_id = "place_florimay"61    request_delay = 0.762    max_rest_units = 70         # garde-fou : appels REST max par sync63    max_images = 106465    # -- helpers ---------------------------------------------------------------66    @staticmethod67    def _svg_cards(html: str) -> list[tuple[str, dict]]:68        """Unités DISPONIBLES du sélecteur : (data-numero, attributs data-*).6970        Chaque unité apparaît deux fois (polygone + bouton mobile) : dédup par71        data-numero. Les classes « indisponible » (unité louée) sont exclues.72        """73        soup = BeautifulSoup(html, "html.parser")74        cards: list[tuple[str, dict]] = []75        seen: set[str] = set()76        for el in soup.select("[data-numero]"):77            cls = " ".join(el.get("class") or [])78            if not re.search(r"\bdisponible\b", cls) \79                    or re.search(r"indisponible|attente", cls):80                continue81            pid = (el.get("data-numero") or "").strip()82            if not pid or pid in seen:83                continue84            seen.add(pid)85            attrs = {k: str(v) for k, v in el.attrs.items()86                     if k.startswith("data-")}87            attrs["class"] = cls88            cards.append((pid, attrs))89        return cards9091    @staticmethod92    def _amenities(soup) -> list[str]:93        """Commodités de l'immeuble : items d'icônes du site (structure béton,94        ascenseur, lockers…)."""95        items: list[str] = []96        for el in soup.select(".qodef-icon-list-item .qodef-e-title-text"):97            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()98            if 3 <= len(t) <= 90 and t not in items:99                items.append(t)100        return items[:30]101102    def _images(self, html: str) -> list[str]:103        imgs: list[str] = []104        for u in dict.fromkeys(_IMG_RE.findall(html)):105            if not _SKIP_IMG.search(u) and u not in imgs:106                imgs.append(u)107        return imgs[: self.max_images]108109    @staticmethod110    def _sqft(raw) -> float | None:111        s = str(raw or "").strip().replace(" ", "").replace(",", ".")112        try:113            v = float(s)114        except ValueError:115            return None116        return v if 80 <= v <= 20000 else None117118    # -- fetch -----------------------------------------------------------------119    def fetch(self) -> list[Listing]:120        html = self.get(f"{BASE}{SELECTOR_PATH}").text121        soup = BeautifulSoup(html, "html.parser")122        cards = self._svg_cards(html)123        amenities = self._amenities(soup)124        images = self._images(html)125        rest_calls = 0126127        out: list[Listing] = []128        for pid, attrs in cards[: self.max_rest_units]:129            def _fetch(p=pid):130                nonlocal rest_calls131                if rest_calls >= self.max_rest_units:132                    raise RuntimeError("budget de requêtes REST atteint")133                rest_calls += 1134                return self.get(REST_UNIT, params={"id": p}).json()135            try:136                # clé de cache BD = attributs du polygone (état, prix, type) :137                # le REST n'est appelé que si l'unité a changé138                key = hashlib.sha1(json.dumps(139                    attrs, sort_keys=True).encode()).hexdigest()[:20]140                data = self.detail(pid, key, _fetch)141            except Exception:142                continue143            if not data or data.get("etat") != "disponible":144                continue145146            num = str(data.get("unite") or "").strip() or pid147            t = str(data.get("type") or attrs.get("data-type") or "")148            sup = str(data.get("superficie_habitable") or "").strip()149            terrasse = str(data.get("superficie_terrasse") or "").strip()150            etage = (attrs.get("data-etage") or "").strip()151            style = str(data.get("style") or "").strip()152            chambres = str(data.get("nb_chambres") or "").strip()153154            # prix : cette variante du plugin n'expose pas `montant` -> le155            # loyer publié est celui du polygone du sélecteur156            prix = (attrs.get("data-prix") or "").strip()157            price_label = f"{prix} $ par mois" if prix else ""158159            date_libre = str(data.get("date_libre") or "").strip(" |")160            availability = (f"Libre {date_libre}" if date_libre161                            else str(data.get("etat_titre") or "Disponible"))162163            desc = " — ".join(x for x in [164                t, f"{sup} pi²" if sup else "",165                f"balcon {terrasse} pi²" if terrasse else "",166                f"{chambres} chambre(s)" if chambres else "",167                style,168                f"étage {etage}" if etage else ""] if x)169170            details: dict = {}171            if etage.isdigit():172                details["floor"] = int(etage)173            pdf = str(data.get("pdf") or "")174            if pdf.startswith("http"):175                details["plan_pdf"] = pdf176177            imgs = list(images)178            for g in reversed(data.get("gallery") or []):179                gu = g.get("url") if isinstance(g, dict) else str(g or "")180                if gu.startswith("http") and gu not in imgs:181                    imgs.insert(0, gu)182183            out.append(Listing(184                source=self.source_id,185                external_id=num,186                url=f"{BASE}{SELECTOR_PATH}#unite-{num}",187                title=f"{NAME} — Unité {num}",188                address=ADDRESS,189                sector="",190                city=CITY,191                unit_type=normalize_unit_type(t),192                price=parse_price(price_label),193                price_label=price_label,194                availability=availability,195                area_sqft=self._sqft(sup),196                description=desc[:600],197                amenities=list(amenities),198                details=details,199                images=imgs[: self.max_images + 2],200            ))201        return out202