SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.5 KB · 244 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/beaudoin.py : connecteur Société Beaudoin immobilier5#   (beaudoinimmobilier.ca — Longueuil, Boucherville, Montréal :6#    Anjou, Lachine, Saint-Léonard). Thème WordPress Houzez : les logements7#   sont des posts « property » exposés par l'API REST8#   (/wp-json/wp/v2/properties) avec prix, adresse géocodée, lat/lng,9#    taxonomies (type d'unité, ville, disponibilité, commodités) et10#    galerie d'images (IDs de médias résolus via /wp-json/wp/v2/media).11#   Le site couvre d'autres régions : seul le Grand Montréal est conservé.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import html as htmllib16import re1718from ..schema import Listing, normalize_unit_type, strip_accents19from .base import BaseConnector2021BASE = "https://www.beaudoinimmobilier.ca"22API = f"{BASE}/index.php/wp-json/wp/v2"2324# Villes admissibles (Grand Montréal) — comparées sans accents, en minuscules.25_ALLOWED_CITIES = (26    "montreal", "longueuil", "boucherville", "brossard", "saint-lambert",27    "laval", "lachine", "anjou", "saint-leonard", "saint-hubert",28)293031def _clean(txt: str) -> str:32    txt = re.sub(r"<[^>]+>", " ", txt or "")33    return re.sub(r"\s+", " ", htmllib.unescape(txt)).strip()343536def _additional_features(serialized: str) -> list[str]:37    """Champ Houzez « additional_features » (PHP sérialisé) -> libellés.3839    Paires titre/valeur (« Eau chaude » / « incluse ») -> « Eau chaude incluse ».40    """41    toks = re.findall(r's:\d+:"((?:[^"\\]|\\.)*)"', serialized or "")42    out: list[str] = []43    i = 044    while True:45        try:46            ti = toks.index("fave_additional_feature_title", i)47        except ValueError:48            break49        title = toks[ti + 1].strip() if ti + 1 < len(toks) else ""50        value = ""51        if ti + 3 < len(toks) and toks[ti + 2] == "fave_additional_feature_value":52            value = toks[ti + 3].strip()53        label = f"{title} {value}".strip()54        if title and label not in out:55            out.append(label)56        i = ti + 257    return out585960class BeaudoinConnector(BaseConnector):61    source_id = "beaudoin"62    request_delay = 0.563    max_pages = 5                # garde-fou (5 x 100 propriétés)64    max_media_ids = 30           # images max par annonce6566    # -- helpers REST ----------------------------------------------------------67    def _terms(self, taxonomy: str) -> dict[int, str]:68        try:69            data = self.get(f"{API}/{taxonomy}?per_page=100").json()70            return {t["id"]: htmllib.unescape(t["name"]) for t in data}71        except Exception:72            return {}7374    def _media_urls(self, ids: list[str]) -> list[str]:75        ids = [i for i in ids if str(i).isdigit()][: self.max_media_ids]76        if not ids:77            return []78        try:79            url = (f"{API}/media?include={','.join(map(str, ids))}"80                   f"&per_page=100&_fields=id,source_url")81            data = self.get(url).json()82            by_id = {str(m["id"]): m.get("source_url", "") for m in data}83            return [by_id[str(i)] for i in ids if by_id.get(str(i))]84        except Exception:85            return []8687    @staticmethod88    def _split_city(name: str) -> tuple[str, str]:89        """'Montréal Anjou' -> ('Montréal', 'Anjou') ; 'Longueuil' -> (ville, '')."""90        name = name.strip()91        if strip_accents(name.lower()).startswith("montreal"):92            sector = name[len("Montréal"):].strip(" -–")93            return "Montréal", sector94        return name, ""9596    @staticmethod97    def _city_allowed(name: str) -> bool:98        key = strip_accents(name.lower()).replace(" ", "-")99        return any(tok in key for tok in _ALLOWED_CITIES)100101    # -- fetch ------------------------------------------------------------------102    def fetch(self) -> list[Listing]:103        types = self._terms("property_type")104        statuses = self._terms("property_status")105        cities = self._terms("property_city")106        features = self._terms("property_feature")107108        props: list[dict] = []109        for page in range(1, self.max_pages + 1):110            try:111                batch = self.get(112                    f"{API}/properties?per_page=100&page={page}").json()113            except Exception:114                break115            if not isinstance(batch, list) or not batch:116                break117            props.extend(batch)118            if len(batch) < 100:119                break120121        listings: list[Listing] = []122        for p in props:123            try:124                lst = self._parse_property(p, types, statuses, cities, features)125            except Exception:126                continue127            if lst:128                listings.append(lst)129        return listings130131    def _parse_property(self, p: dict, types: dict, statuses: dict,132                        cities: dict, features: dict) -> Listing | None:133        meta = p.get("property_meta") or {}134135        def m1(key: str) -> str:136            v = meta.get(key) or []137            return str(v[0]).strip() if v and v[0] is not None else ""138139        # Ville / secteur (taxonomie property_city, ex. « Montréal Anjou »)140        city_name = next((cities[i] for i in (p.get("property_city") or [])141                          if i in cities), "")142        if not city_name or not self._city_allowed(city_name):143            return None                       # hors Grand Montréal144        city, sector = self._split_city(city_name)145146        title = _clean((p.get("title") or {}).get("rendered") or "")147148        # Adresse géocodée : « 7340, Avenue Guy, Anjou, Montréal, ... Canada »149        map_addr = m1("fave_property_map_address")150        address = ", ".join(s.strip() for s in map_addr.split(",")[:2]) if map_addr else ""151        address = re.sub(r"\s+", " ", address).strip()152153        # Type d'unité (taxonomie « 3 1/2 », « 4 1/2 Penthouse », ...)154        type_name = next((types[i] for i in (p.get("property_type") or [])155                          if i in types), "")156        unit_type = normalize_unit_type(type_name)157158        # Prix mensuel (champ Houzez)159        price = None160        price_label = ""161        raw_price = m1("fave_property_price")162        if raw_price:163            try:164                price = float(re.sub(r"[^\d.]", "", raw_price))165            except ValueError:166                price = None167            postfix = m1("fave_property_price_postfix") or "mois"168            price_label = f"{raw_price}$ / {postfix}"169        if price is not None and not (100 <= price <= 20000):170            price = None171172        # Disponibilité (taxonomie property_status, « 08 - Disponible pour août »)173        avail = next((statuses[i] for i in (p.get("property_status") or [])174                      if i in statuses), "")175        availability = re.sub(r"^\d+\s*-\s*", "", avail)176177        amenities = [features[i] for i in (p.get("property_feature") or [])178                     if i in features]179        # « Caractéristiques additionnelles » Houzez (PHP sérialisé) :180        # Eau chaude incluse, Lave-vaisselle inclus, Chiens et chats acceptés…181        for label in _additional_features(m1("additional_features")):182            if label not in amenities:183                amenities.append(label)184185        # Superficie structurée (fave_property_size, préfixe « Pieds carrés »)186        area_sqft = None187        raw_size = m1("fave_property_size")188        size_prefix = m1("fave_property_size_prefix").lower()189        if raw_size:190            try:191                val = float(raw_size.replace(",", "."))192            except ValueError:193                val = None194            if val and 80 <= val <= 20000 and (195                    not size_prefix or "pied" in size_prefix196                    or "pi" in size_prefix or "sq" in size_prefix):197                area_sqft = val198199        # Chambres / salles de bain (champs Houzez) -> commodités affichables200        beds = m1("fave_property_bedrooms")201        baths = m1("fave_property_bathrooms")202        if beds.isdigit() and int(beds) > 0:203            amenities.append(f"{beds} chambre{'s' if int(beds) > 1 else ''}")204        if baths.isdigit() and int(baths) > 0:205            amenities.append(206                f"{baths} salle{'s' if int(baths) > 1 else ''} de bain")207208        # Coordonnées209        lat = lng = None210        try:211            lat = float(m1("houzez_geolocation_lat"))212            lng = float(m1("houzez_geolocation_long"))213        except (ValueError, TypeError):214            lat = lng = None215216        # Galerie d'images (IDs de médias -> URLs)217        images = self._media_urls(meta.get("fave_property_images") or [])218        if not images:219            thumb = m1("_thumbnail_id")220            if thumb:221                images = self._media_urls([thumb])222223        description = _clean((p.get("content") or {}).get("rendered") or "")[:600]224225        return Listing(226            source=self.source_id,227            external_id=str(p.get("id")),228            url=p.get("link") or "",229            title=title or f"Logement {p.get('id')}",230            address=address,231            sector=sector,232            city=city,233            unit_type=unit_type,234            price=price,235            price_label=price_label,236            availability=availability,237            area_sqft=area_sqft,238            description=description,239            amenities=amenities,240            images=images,241            lat=lat,242            lng=lng,243        )244