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%
7.5 KB · 194 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/chartwell.py : Chartwell (chartwell.com/fr) — résidences pour5#   retraités. Chaîne PANCANADIENNE : on ne garde QUE le Québec, garanti deux6#   fois — par le préfixe /fr/qc/ des URLs du sitemap ET par le champ7#   Province Abbreviation == "QC" des données de la page.8#   Site Next.js/Sitecore rendu serveur : chaque page « plans des9#   appartements » (/fr/qc/<ville>/<résidence>/plans-des-appartements)10#   embarque dans __NEXT_DATA__ la fiche complète de la résidence (nom,11#   adresse civique, ville, lat/lng, téléphone) et la liste12#   « Property Suit Plans » : type d'unité (Studio, 2 1/2 … 5 1/2), prix13#   « À partir de » (Regular SuitePrice), niveau de soins (Autonome…),14#   inclusions (Key Features) et visuel. Une annonce Lou-Ka par résidence ×15#   type d'unité, prix plancher « à partir de ».16# -----------------------------------------------------------------------------17from __future__ import annotations1819import json20import re2122from ..schema import Listing, normalize_unit_type23from .base import BaseConnector2425SITEMAP = "https://chartwell.com/sitemap.xml"2627_PLANS_URL_RE = re.compile(28    r"https://chartwell\.com/fr/qc/[a-z0-9-]+/[a-z0-9-]+/plans-des-appartements$")29_NEXT_DATA_RE = re.compile(30    r'<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)</script>')313233def _jv(fields: dict, name: str):34    """Valeur `jsonValue` d'un champ Sitecore (liste de {name, jsonValue})."""35    f = fields.get(name)36    return f.get("jsonValue") if isinstance(f, dict) else None373839def _scalar(fields: dict, name: str) -> str:40    v = _jv(fields, name)41    if isinstance(v, dict):42        return str(v.get("value") or "").strip()43    return ""444546class ChartwellConnector(BaseConnector):47    source_id = "chartwell"48    request_delay = 0.849    timeout = 455051    # -- fiche résidence (champs Sitecore du parent de la page plans) ----------------52    @staticmethod53    def _property_fields(next_data: dict) -> dict | None:54        """Trouve le nœud « fields » du bien (celui qui porte55        Property Suit Plans) n'importe où dans le layout Sitecore."""56        stack = [next_data]57        while stack:58            node = stack.pop()59            if isinstance(node, dict):60                if isinstance(node.get("fields"), list):61                    names = {f.get("name") for f in node["fields"]62                             if isinstance(f, dict)}63                    if "Property Suit Plans" in names and "Latitude" in names:64                        return {f["name"]: f for f in node["fields"]65                                if isinstance(f, dict) and f.get("name")}66                stack.extend(node.values())67            elif isinstance(node, list):68                stack.extend(node)69        return None7071    def _parse_page(self, url: str, html: str) -> list[Listing]:72        m = _NEXT_DATA_RE.search(html)73        if not m:74            return []75        try:76            data = json.loads(m.group(1))77        except ValueError:78            return []79        fields = self._property_fields(data)80        if not fields:81            return []8283        # garde-fou province : Chartwell est pancanadien, Québec seulement84        prov = _jv(fields, "Province") or []85        abbr = ""86        if isinstance(prov, list) and prov:87            abbr = str(((prov[0].get("fields") or {})88                        .get("Province Abbreviation") or {}).get("value") or "")89        if abbr and abbr.upper() != "QC":90            return []9192        name = (_scalar(fields, "NavigationTitle")93                or _scalar(fields, "Short Property Name")94                or _scalar(fields, "Property Name"))95        address = _scalar(fields, "StreetNameAndNumber")96        postal = _scalar(fields, "Postal code")97        phone = _scalar(fields, "Contact Number")98        city = ""99        cv = _jv(fields, "City") or []100        if isinstance(cv, list) and cv:101            # « City Name » porte les accents (Lévis), displayName non (Levis)102            city = str(((cv[0].get("fields") or {}).get("City Name") or {})103                       .get("value")104                       or cv[0].get("displayName") or cv[0].get("name") or "")105        lat = lng = None106        try:107            lat = float(_scalar(fields, "Latitude"))108            lng = float(_scalar(fields, "Longitude"))109        except ValueError:110            lat = lng = None111112        thumb = ""113        tv = _jv(fields, "Thumbnail Photo")114        if isinstance(tv, dict):115            thumb = str(((tv.get("value") or {}).get("src")) or "")116117        res_slug = url.rstrip("/").rsplit("/", 2)[-2]118        city_slug = url.rstrip("/").rsplit("/", 3)[-3]119120        def val(d, *keys):121            cur = d122            for k in keys:123                if not isinstance(cur, dict):124                    return None125                cur = cur.get(k)126            return cur127128        out: list[Listing] = []129        plans = _jv(fields, "Property Suit Plans") or []130        for plan in plans:131            pf = plan.get("fields") if isinstance(plan, dict) else None132            if not isinstance(pf, dict):133                continue134            suite = val(pf, "SuiteName", "fields", "suiteType", "value") or ""135            price_raw = val(pf, "Regular SuitePrice", "value")136            care = val(pf, "Care Level", "fields",137                       "Suite Care Level", "value") or ""138            try:139                price = float(str(price_raw).replace(" ", "").replace(",", ""))140            except (TypeError, ValueError):141                continue142            if not suite or not 400 <= price <= 20000:143                continue144            feats = []145            for kf in (val(pf, "Key Features") or []):146                t = val(kf, "fields", "Key Feature Name", "value")147                if t and t not in feats:148                    feats.append(str(t))149            images = []150            bg = val(pf, "background Image", "value", "src")151            if bg:152                images.append(str(bg))153            if thumb:154                images.append(thumb)155            details = {"Résidence pour aînés": "oui"}156            if care:157                details["Niveau de soins"] = care158            if postal:159                details["Code postal"] = postal160            if phone:161                details["contact"] = {"phone": phone}162            eid = (f"{city_slug}-{res_slug}-"163                   f"{re.sub(r'[^a-z0-9]+', '-', suite.lower()).strip('-')}")164            out.append(Listing(165                source=self.source_id,166                external_id=eid,167                url=url,168                title=f"{suite} — {name}",169                address=address,170                city=city,171                unit_type=normalize_unit_type(suite),172                price=price,173                price_label=f"À partir de {price:,.0f} $ par mois"174                            .replace(",", " "),175                amenities=feats,176                details=details,177                images=images,178                lat=lat,179                lng=lng,180            ))181        return out182183    def fetch(self) -> list[Listing]:184        out: list[Listing] = []185        xml = self.get(SITEMAP).text186        urls = sorted({u for u in re.findall(r"<loc>([^<]+)</loc>", xml)187                       if _PLANS_URL_RE.match(u)})188        for url in urls:189            try:190                out.extend(self._parse_page(url, self.get(url).text))191            except Exception:192                continue193        return out194