# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/chartwell.py : Chartwell (chartwell.com/fr) — résidences pour # retraités. Chaîne PANCANADIENNE : on ne garde QUE le Québec, garanti deux # fois — par le préfixe /fr/qc/ des URLs du sitemap ET par le champ # Province Abbreviation == "QC" des données de la page. # Site Next.js/Sitecore rendu serveur : chaque page « plans des # appartements » (/fr/qc///plans-des-appartements) # embarque dans __NEXT_DATA__ la fiche complète de la résidence (nom, # adresse civique, ville, lat/lng, téléphone) et la liste # « Property Suit Plans » : type d'unité (Studio, 2 1/2 … 5 1/2), prix # « À partir de » (Regular SuitePrice), niveau de soins (Autonome…), # inclusions (Key Features) et visuel. Une annonce Lou-Ka par résidence × # type d'unité, prix plancher « à partir de ». # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from ..schema import Listing, normalize_unit_type from .base import BaseConnector SITEMAP = "https://chartwell.com/sitemap.xml" _PLANS_URL_RE = re.compile( r"https://chartwell\.com/fr/qc/[a-z0-9-]+/[a-z0-9-]+/plans-des-appartements$") _NEXT_DATA_RE = re.compile( r'') def _jv(fields: dict, name: str): """Valeur `jsonValue` d'un champ Sitecore (liste de {name, jsonValue}).""" f = fields.get(name) return f.get("jsonValue") if isinstance(f, dict) else None def _scalar(fields: dict, name: str) -> str: v = _jv(fields, name) if isinstance(v, dict): return str(v.get("value") or "").strip() return "" class ChartwellConnector(BaseConnector): source_id = "chartwell" request_delay = 0.8 timeout = 45 # -- fiche résidence (champs Sitecore du parent de la page plans) ---------------- @staticmethod def _property_fields(next_data: dict) -> dict | None: """Trouve le nœud « fields » du bien (celui qui porte Property Suit Plans) n'importe où dans le layout Sitecore.""" stack = [next_data] while stack: node = stack.pop() if isinstance(node, dict): if isinstance(node.get("fields"), list): names = {f.get("name") for f in node["fields"] if isinstance(f, dict)} if "Property Suit Plans" in names and "Latitude" in names: return {f["name"]: f for f in node["fields"] if isinstance(f, dict) and f.get("name")} stack.extend(node.values()) elif isinstance(node, list): stack.extend(node) return None def _parse_page(self, url: str, html: str) -> list[Listing]: m = _NEXT_DATA_RE.search(html) if not m: return [] try: data = json.loads(m.group(1)) except ValueError: return [] fields = self._property_fields(data) if not fields: return [] # garde-fou province : Chartwell est pancanadien, Québec seulement prov = _jv(fields, "Province") or [] abbr = "" if isinstance(prov, list) and prov: abbr = str(((prov[0].get("fields") or {}) .get("Province Abbreviation") or {}).get("value") or "") if abbr and abbr.upper() != "QC": return [] name = (_scalar(fields, "NavigationTitle") or _scalar(fields, "Short Property Name") or _scalar(fields, "Property Name")) address = _scalar(fields, "StreetNameAndNumber") postal = _scalar(fields, "Postal code") phone = _scalar(fields, "Contact Number") city = "" cv = _jv(fields, "City") or [] if isinstance(cv, list) and cv: # « City Name » porte les accents (Lévis), displayName non (Levis) city = str(((cv[0].get("fields") or {}).get("City Name") or {}) .get("value") or cv[0].get("displayName") or cv[0].get("name") or "") lat = lng = None try: lat = float(_scalar(fields, "Latitude")) lng = float(_scalar(fields, "Longitude")) except ValueError: lat = lng = None thumb = "" tv = _jv(fields, "Thumbnail Photo") if isinstance(tv, dict): thumb = str(((tv.get("value") or {}).get("src")) or "") res_slug = url.rstrip("/").rsplit("/", 2)[-2] city_slug = url.rstrip("/").rsplit("/", 3)[-3] def val(d, *keys): cur = d for k in keys: if not isinstance(cur, dict): return None cur = cur.get(k) return cur out: list[Listing] = [] plans = _jv(fields, "Property Suit Plans") or [] for plan in plans: pf = plan.get("fields") if isinstance(plan, dict) else None if not isinstance(pf, dict): continue suite = val(pf, "SuiteName", "fields", "suiteType", "value") or "" price_raw = val(pf, "Regular SuitePrice", "value") care = val(pf, "Care Level", "fields", "Suite Care Level", "value") or "" try: price = float(str(price_raw).replace(" ", "").replace(",", "")) except (TypeError, ValueError): continue if not suite or not 400 <= price <= 20000: continue feats = [] for kf in (val(pf, "Key Features") or []): t = val(kf, "fields", "Key Feature Name", "value") if t and t not in feats: feats.append(str(t)) images = [] bg = val(pf, "background Image", "value", "src") if bg: images.append(str(bg)) if thumb: images.append(thumb) details = {"Résidence pour aînés": "oui"} if care: details["Niveau de soins"] = care if postal: details["Code postal"] = postal if phone: details["contact"] = {"phone": phone} eid = (f"{city_slug}-{res_slug}-" f"{re.sub(r'[^a-z0-9]+', '-', suite.lower()).strip('-')}") out.append(Listing( source=self.source_id, external_id=eid, url=url, title=f"{suite} — {name}", address=address, city=city, unit_type=normalize_unit_type(suite), price=price, price_label=f"À partir de {price:,.0f} $ par mois" .replace(",", " "), amenities=feats, details=details, images=images, lat=lat, lng=lng, )) return out def fetch(self) -> list[Listing]: out: list[Listing] = [] xml = self.get(SITEMAP).text urls = sorted({u for u in re.findall(r"([^<]+)", xml) if _PLANS_URL_RE.match(u)}) for url in urls: try: out.extend(self._parse_page(url, self.get(url).text)) except Exception: continue return out