SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.8 KB · 180 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/devcore.py : connecteur Devcore Group (devcore.ca — 555 portes à5#   Sept-Îles + Gatineau/Maniwaki/Thurso/Val-d'Or ; siège à Gatineau).6#   WordPress + WPML. Le site n'expose pas d'unités individuelles : chaque7#   « milieu de vie » (CPT `properties`) est une page marketing par complexe.8#   L'API REST wp-json est ouverte et PRIVILÉGIÉE ici (4 requêtes par sync,9#   zéro parsing de gabarit) : /wp/v2/properties + les taxonomies10#   property_city (ville), property_type (« 1 chambre », « Studio »…) et11#   property_price (fourchettes ; « … / mois » = locatif, « $100,000… » =12#   vente). Une annonce Lou-Ka = un complexe locatif : prix seulement si la13#   page annonce « à partir de N$ » (jamais de valeur inventée), typologies14#   en commodités, fourchettes en détails. Filtres : propriétés de l'Ontario15#   (Cornwall, Ottawa, Limoges…) exclues, vente-seulement exclue, logements16#   corporatifs (moyen terme) exclus, doublons éditoriaux (pages dupliquées17#   du même complexe) dédoublonnés par ville+description. La page /location/18#   du site est périmée (fiches 404, métadonnées croisées) : non utilisée.19#   robots.txt ouvert, sitemap Yoast.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as htmllib24import re2526from bs4 import BeautifulSoup2728from ..schema import Listing, strip_accents29from .base import BaseConnector3031BASE = "https://devcore.ca"32API = f"{BASE}/wp-json/wp/v2"3334# villes du Québec : nom de terme property_city -> (ville Lou-Ka, secteur)35_QC_CITIES = {36    "sept-iles": ("Sept-Îles", ""),37    "gatineau": ("Gatineau", ""),38    "aylmer": ("Gatineau", "Aylmer"),39    "hull": ("Gatineau", "Hull"),40    "val d'or": ("Val-d'Or", ""),41    "val-d'or": ("Val-d'Or", ""),42    "thurso": ("Thurso", ""),43    "maniwaki": ("Maniwaki", ""),44}4546# suffixe de redimensionnement WordPress (« -1024x683.jpg » -> pleine taille)47_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)4849_FROM_PRICE_RE = re.compile(50    r"(?:à partir de|a partir de|starting at)\s*([\d\s,  ]{3,9})\s*\$"51    r"|(?:à partir de|a partir de|starting at)\s*\$\s*([\d\s,  ]{3,9})", re.I)52_AVAIL_RE = re.compile(53    r"Disponible\s+(?:pour|d[èe]s|en|le)\s+[\wà-ÿ']+(?:\s+\d{4})?", re.I)545556def _city_key(name: str) -> str:57    return strip_accents(htmllib.unescape(name or "").strip().lower()) \58        .replace("’", "'")596061class DevcoreConnector(BaseConnector):62    source_id = "devcore"63    request_delay = 0.66465    # -- appels API ------------------------------------------------------------66    def _terms(self, taxonomy: str) -> dict[int, str]:67        data = self.get(f"{API}/{taxonomy}?per_page=100").json()68        return {t["id"]: htmllib.unescape(t["name"]) for t in data}6970    def fetch(self) -> list[Listing]:71        cities = self._terms("property_city")72        types = self._terms("property_type")73        prices = self._terms("property_price")74        props = self.get(f"{API}/properties?per_page=100").json()7576        candidates: list[Listing] = []77        for p in props:78            try:79                lst = self._parse_property(p, cities, types, prices)80            except Exception:81                continue82            if lst is not None:83                candidates.append(lst)8485        # dédoublonnage éditorial : le site duplique certains complexes sous86        # plusieurs slugs (« Domaine de la Baie » x3…) — clé = ville + titre87        # normalisé (suffixe « , Ville » retiré) ; on garde le plus petit id88        # WP (le plus ancien = le plus stable d'une synchro à l'autre)89        dedup: dict[str, Listing] = {}90        for lst in sorted(candidates, key=lambda l: int(l.external_id)):91            t = strip_accents(lst.title.lower())92            t = re.sub(rf",\s*{re.escape(strip_accents(lst.city.lower()))}\s*$", "", t)93            key = f"{lst.city}|{re.sub(r'[^a-z0-9]+', ' ', t).strip()}"94            if key not in dedup:95                dedup[key] = lst96        return list(dedup.values())9798    # -- une propriété (complexe locatif) ---------------------------------------99    def _parse_property(self, p: dict, cities: dict, types: dict,100                        prices: dict) -> Listing | None:101        link = p.get("link") or ""102        if "/en/" in link:                     # doublons de traduction WPML103            return None104        title = htmllib.unescape((p.get("title") or {}).get("rendered", "")).strip()105        if re.search(r"corporatif|corporate", title, re.I):106            return None                        # logement corporatif = moyen terme107108        # locatif seulement : au moins une fourchette « … / mois » ou « / month »109        price_labels = [prices[i] for i in p.get("property_price", []) if i in prices]110        monthly = [l for l in price_labels if re.search(r"/\s*(mois|month)", l, re.I)]111        if not monthly:112            return None                        # vente ou page sans données113114        # ville (taxonomie property_city) : Québec seulement115        city = sector = ""116        for i in p.get("property_city", []):117            hit = _QC_CITIES.get(_city_key(cities.get(i, "")))118            if hit:119                city, sector = hit120                break121        if not city:122            return None                        # Ontario (Cornwall, Limoges…) ou vide123124        # contenu rendu : description, images, disponibilité, « à partir de »125        content = (p.get("content") or {}).get("rendered", "")126        soup = BeautifulSoup(content, "html.parser")127128        paras = [re.sub(r"\s+", " ", el.get_text(" ", strip=True))129                 for el in soup.find_all(["p", "h2", "h3", "li"])]130        paras = [t for t in paras if len(t) > 2 and t not in ("Découvrez le projet",)]131        description = "\n".join(dict.fromkeys(paras))[:1500]132        if not description:133            return None                       # page coquille (« projet à venir »)134135        text = soup.get_text(" ", strip=True)136        price_label = ""137        m = _FROM_PRICE_RE.search(text)138        if m:139            amount = re.sub(r"[\s,  ]", "", m.group(1) or m.group(2))140            price_label = f"à partir de {amount}$"141        availability = ""142        m = _AVAIL_RE.search(text)143        if m:144            availability = re.sub(r"\s+", " ", m.group(0)).strip()145146        images: list[str] = []147        for img in soup.select("img[src]"):148            u = _SIZE_SUFFIX.sub("", img["src"].strip())149            if (u.startswith("http") and "/wp-content/uploads/" in u150                    and not u.lower().endswith(".svg") and u not in images):151                images.append(u)152153        # typologies offertes (taxonomie property_type) -> commodités affichables154        type_names = list(dict.fromkeys(155            types[i] for i in p.get("property_type", []) if i in types))156        amenities = [f"Unités : {t}" for t in type_names]157158        details: dict = {"complex": title}159        if type_names:160            details["unit_types"] = type_names161        if monthly:162            details["price_ranges"] = monthly163164        return Listing(165            source=self.source_id,166            external_id=str(p["id"]),         # id WordPress stable167            url=link,168            title=title,169            sector=sector,170            city=city,171            unit_type="",                     # complexe multi-typologies172            price=None,                       # rempli par finalize() si price_label173            price_label=price_label,174            availability=availability,175            description=description,176            amenities=amenities,177            details=details,178            images=images[:20],179        )180