SPB Git

spb/lou-ka Public

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

HTML 99.7%
13.2 KB · 310 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/dma_locago.py : connecteur DMA / Locago (Douville, Moffet & Associés)5#   locago.ca présente les complexes; les unités (avec prix/disponibilité) sont6#   embarquées dans les pages MapSVG de v1.dma.immo (JSON `data_db.objects` :7#   superficie, étage, orientation, mois/année de disponibilité, prix).8#   La Tour Frontenac a son propre sélecteur JetEngine sur dma.immo9#   (/frontenac-selecteur-pub/ : cartes avec prix, étage, pi², orientation).10#   Une annonce par unité disponible; une annonce « projet » pour les complexes11#   sans sélecteur d'unités (IDOLA, Le Pivot).12#   Luxo Place (Ottawa) est exclu — hors région Québec/Lévis.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, infer_city, normalize_unit_type, parse_price22from .base import BaseConnector2324V1 = "https://www.v1.dma.immo"25DMA = "https://dma.immo"26LOCAGO = "https://locago.ca"27FRONTENAC_URL = f"{DMA}/frontenac-selecteur-pub/"28FRONTENAC_ADDRESS = "1175, avenue de Germain-des-Prés, Québec"2930_MONTHS = {31    "01": "janvier", "02": "février", "03": "mars", "04": "avril",32    "05": "mai", "06": "juin", "07": "juillet", "08": "août",33    "09": "septembre", "10": "octobre", "11": "novembre", "12": "décembre",34}3536# Complexes avec sélecteur d'unités MapSVG sur v1.dma.immo37# (slug, nom, secteur, mot-clé image locago, site vitrine pour photos, adresse)38# L'adresse n'est renseignée que si le site du projet publie une adresse39# civique unique et sans ambiguïté (complexes multi-immeubles -> vide).40_UNIT_PAGES = [41    ("le-wow-unites", "Le WOW", "Sainte-Foy", "WOW", "https://lewow.ca",42     "930, rue Samuel-King, Québec"),43    ("la-suite-unites", "La Suite", "Sainte-Foy", "SUITE",44     "https://lasuite.ca", ""),45    ("domaine-des-meandres-unites", "Domaine des Méandres", "Lebourgneuf",46     "DDM", "https://info.domainedesmeandres.com", ""),47    ("vc-unites", "Villas Cortina", "Charlesbourg", "CORTINA",48     "https://info.villascortina.com", ""),49    ("le-divin-unites", "Le Divin", "Beauport", "DIVIN",50     "https://ledivin.ca", ""),51    ("le-divin-2-unites", "Le Divin (phase 2)", "Beauport", "DIVIN",52     "https://ledivin.ca", ""),53    ("ar-unites-01-02", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",54     "https://info.laristocrate.ca", ""),55    ("ar-unites-03", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",56     "https://info.laristocrate.ca", ""),57    ("ar-unites-04", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",58     "https://info.laristocrate.ca", ""),59]6061# Complexes présentés sur locago.ca sans sélecteur d'unités public62# (id, nom, secteur, mot-clé image locago, url projet, adresse)63_PROJECT_ONLY = [64    ("idola", "IDOLA", "Sainte-Foy", "IDOLA", "https://idola.ca/",65     "2689, boulevard Hochelaga, Québec"),66    ("le-pivot", "Le Pivot", "Vanier", "PIVOT", "https://pivotqc.com/",67     "1695, rue Monseigneur-Plessis, Québec"),68]6970_IMG_RE = re.compile(71    r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)',72    re.I)73_SKIP_IMG = re.compile(r"logo|icon|favicon|dma-|-\d{2,3}x\d{2,3}\.", re.I)747576class DMALocagoConnector(BaseConnector):77    source_id = "dma_locago"78    request_delay = 0.679    max_site_images = 128081    # -- helpers ---------------------------------------------------------------82    def _page_images(self, url: str) -> list[str]:83        """Photos (wp-content/uploads) d'une page, sans logos ni vignettes."""84        try:85            html = self.get(url).text86        except Exception:87            return []88        imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))89                if not _SKIP_IMG.search(u)]90        return imgs[: self.max_site_images]9192    @staticmethod93    def _mapsvg_objects(html: str) -> list[dict]:94        """Extrait les unités des blocs `mapsvg_options = {...};` (data_db)."""95        objects: list[dict] = []96        dec = json.JSONDecoder()97        for m in re.finditer(r"mapsvg_options\s*=\s*", html):98            try:99                opts, _ = dec.raw_decode(html[m.end():])100            except Exception:101                continue102            data_db = opts.get("data_db") or {}103            if isinstance(data_db, dict):104                objects.extend(data_db.get("objects") or [])105        return objects106107    @staticmethod108    def _unit_type(obj: dict) -> str:109        """'3.5' -> '3½' (avec repli sur typlog/descript)."""110        nb = str(obj.get("nbpiece") or "").strip().replace(",", ".")111        m = re.match(r"^(\d+)(\.5)?$", nb)112        if m:113            return f"{m.group(1)}½"114        raw = obj.get("descript") or obj.get("typlog") or ""115        if re.search(r"studio", str(raw), re.I):116            return "Studio"117        return normalize_unit_type(str(raw))118119    @staticmethod120    def _availability(obj: dict) -> str:121        # Le mois/année de disponibilité est renseigné pour toutes les unités122        # offertes (statuts « 1 » et « 2 ») ; il prime sur le statut brut.123        month = _MONTHS.get(str(obj.get("available_month") or ""), "")124        year = str(obj.get("available_year") or "").strip()125        if month and year:126            return f"{month} {year}"127        if obj.get("available") == "1":128            return "Libre immédiatement"129        return "Disponible prochainement"130131    # -- fetch -----------------------------------------------------------------132    def fetch(self) -> list[Listing]:133        # 1) Images « héro » des complexes depuis locago.ca134        img_keys = {row[3] for row in _UNIT_PAGES + _PROJECT_ONLY}135        locago_imgs: dict[str, str] = {}136        try:137            home = self.get(LOCAGO).text138            for u in _IMG_RE.findall(home):139                if _SKIP_IMG.search(u):140                    continue141                for key in img_keys:142                    if key.lower() in u.lower() and key not in locago_imgs:143                        locago_imgs[key] = u144        except Exception:145            pass146147        # Photos supplémentaires par site vitrine (1 requête par site)148        site_imgs: dict[str, list[str]] = {}149150        listings: dict[str, Listing] = {}151152        # 2) Unités disponibles des pages MapSVG (v1.dma.immo)153        for slug, name, sector, img_key, site, address in _UNIT_PAGES:154            try:155                html = self.get(f"{V1}/{slug}/").text156            except Exception:157                continue158            if site not in site_imgs:159                site_imgs[site] = self._page_images(site)160            images = ([locago_imgs[img_key]] if img_key in locago_imgs else [])161            images += [u for u in site_imgs[site] if u not in images]162163            for obj in self._mapsvg_objects(html):164                if str(obj.get("available")) not in ("1", "2"):165                    continue  # unité louée166                uid = str(obj.get("title") or "").strip()167                unite = str(obj.get("unite") or "").strip()168                if not uid or uid in listings:169                    continue170                # superficie structurée (pi²) -> area_sqft explicite171                sup = str(obj.get("superficie") or "").strip()172                area_sqft = None173                m = re.match(r"^(\d{2,5}(?:[.,]\d+)?)$", sup)174                if m:175                    area_sqft = float(m.group(1).replace(",", "."))176                desc_parts = [str(obj.get("descript") or "").strip()]177                if sup:178                    desc_parts.append(f"{sup} pi²")179                elif obj.get("sup_range"):180                    desc_parts.append(str(obj["sup_range"]))181                if obj.get("orientation"):182                    desc_parts.append(f"Orientation : {obj['orientation']}")183                # étage structuré (« 0 » = non renseigné chez la source)184                details: dict = {}185                etage = str(obj.get("etage") or "").strip()186                if etage.isdigit() and int(etage) >= 1:187                    details["floor"] = int(etage)188                startfrom = str(obj.get("startfrom") or "").strip()189                listings[uid] = Listing(190                    source=self.source_id,191                    external_id=uid,192                    url=f"{V1}/{slug}/#unite-{unite or uid}",193                    title=f"{name} — Unité {unite or uid}",194                    address=address,195                    sector=sector,196                    city=infer_city(sector),197                    unit_type=self._unit_type(obj),198                    price=parse_price(startfrom),199                    price_label=f"À partir de {startfrom}" if startfrom else "",200                    availability=self._availability(obj),201                    area_sqft=area_sqft,202                    description=" — ".join(p for p in desc_parts if p)[:600],203                    details=details,204                    images=images,205                )206207        # 3) Tour Frontenac : sélecteur JetEngine sur dma.immo (cartes d'unités)208        try:209            self._fetch_frontenac(listings, locago_imgs)210        except Exception:211            pass212213        # 4) Annonces « projet » pour les complexes sans sélecteur d'unités214        for pid, name, sector, img_key, url, address in _PROJECT_ONLY:215            try:216                images = ([locago_imgs[img_key]] if img_key in locago_imgs217                          else [])218                images += [u for u in self._page_images(url)219                           if u not in images]220                listings[f"projet-{pid}"] = Listing(221                    source=self.source_id,222                    external_id=f"projet-{pid}",223                    url=url,224                    title=name,225                    address=address,226                    sector=sector,227                    city=infer_city(sector),228                    unit_type="",229                    price=None,230                    price_label="",231                    availability="",232                    description=(f"Complexe locatif {name} ({sector}) géré par "233                                 "DMA / Locago — voir le site du projet pour "234                                 "les unités disponibles."),235                    images=images,236                )237            except Exception:238                continue239240        return list(listings.values())241242    # -- Tour Frontenac (sélecteur JetEngine sur dma.immo) ----------------------243    def _fetch_frontenac(self, listings: dict[str, Listing],244                         locago_imgs: dict[str, str]) -> None:245        """Cartes d'unités disponibles : no, type, étage, pi², orientation,246        prix « À partir de » — pas de date de disponibilité affichée."""247        html = self.get(FRONTENAC_URL).text248        soup = BeautifulSoup(html, "html.parser")249        images = ([locago_imgs["frontenac"]] if "frontenac" in locago_imgs250                  else [])251        images += [u for u in self._page_images("https://appartsfrontenac.ca/")252                   if u not in images]253254        for card in soup.select(".jet-listing-grid__item"):255            text = card.get_text(" ", strip=True)256            m = re.search(r"Unité\s+(\w+)", text)257            if not m:258                continue259            unit = m.group(1)260            uid = f"FRONTENAC-{unit}"261            if uid in listings:262                continue263264            type_el = card.select_one("span.subtitle")265            type_raw = type_el.get_text(" ", strip=True) if type_el else ""266            # types en toutes lettres chez la source (« Une chambre »…)267            key = type_raw.strip().lower()268            unit_type = {269                "studio": "Studio", "une chambre": "3½",270                "deux chambres": "4½", "trois chambres": "5½",271            }.get(key) or normalize_unit_type(type_raw)272273            details: dict = {}274            fm = re.search(r"[ÉE]tage\s+(\d{1,2})\b", text)275            if fm:276                details["floor"] = int(fm.group(1))277            area_sqft = None278            am = re.search(r"(\d[\d\s]{1,6})\s*pi", text)279            if am:280                area_sqft = float(am.group(1).replace(" ", ""))281282            desc_parts = [type_raw]283            if am:284                desc_parts.append(f"{am.group(1).strip()} pi²")285            om = re.search(r"Orientation\s*:\s*([A-Za-zÀ-ÿ -]+?)(?:\s{2,}|$|T[ÉE]L)",286                           text)287            if om:288                desc_parts.append(f"Orientation : {om.group(1).strip()}")289290            pm = re.search(r"\$\s*([\d\s]{3,10})", text)291            price_label = f"À partir de {pm.group(1).strip()} $" if pm else ""292293            listings[uid] = Listing(294                source=self.source_id,295                external_id=uid,296                url=FRONTENAC_URL,297                title=f"Tour Frontenac — Unité {unit}",298                address=FRONTENAC_ADDRESS,299                sector="Sainte-Foy",300                city=infer_city("Sainte-Foy"),301                unit_type=unit_type,302                price=parse_price(price_label),303                price_label=price_label,304                availability="",305                area_sqft=area_sqft,306                description=" — ".join(p for p in desc_parts if p)[:600],307                details=details,308                images=images,309            )310