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%
13.3 KB · 308 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/kijiji.py : Kijiji (kijiji.ca) — petites annonces de LOCATION5#   UNIQUEMENT les catégories logement À LOUER, UNIQUEMENT le Québec (l9001) :6#     c37 appartements & condos à louer · c36 chambres à louer & colocation7#   Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre,8#   prix, GPS, adresse, date de disponibilité et attributs (meublé, animaux,9#   inclusions…) — aucune API privée nécessaire. Adapté du connecteur « à10#   vendre » d'Immo-Ka (agent-courtage/immoka).11#   Prix « Sur demande » (price.type=CONTACT, ~3 % des annonces) : le montant12#   est souvent écrit dans la description (« Loyer : 1 550 $ / mois ») — on13#   le récupère prudemment (montant collé à $ + mois/month plausible), sinon14#   le libellé affiché devient « Sur demande » plutôt que vide.15#   (Vérifié à la source : les annonces sans photo ou à photo unique le sont16#   réellement sur Kijiji — imageUrls vide/à 1 sur la fiche aussi.)17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import os22import re2324from ..schema import Listing25from .base import BaseConnector2627from . import _detailutil as du2829BASE = "https://www.kijiji.ca"30# (code catégorie, segment d'URL, type d'unité par défaut)31CATEGORIES = [32    (37, "b-appartement-condo", ""),                 # unité déduite des attributs33    (36, "b-chambre-a-louer-colocataire", "Chambre"),34]35MAX_PAGES = int(os.environ.get("LOUKA_KIJIJI_MAX_PAGES", "100"))36DETAIL_LIMIT = int(os.environ.get("LOUKA_KIJIJI_DETAIL_LIMIT", "400"))3738# les annonces vivent sous des clés Apollo « RealEstateListing:123 » (c37)39# ou « StandardListing:123 » (c36)40_LISTING_KEY_RE = re.compile(r"^(?:RealEstate|Standard)Listing:\d+$")41_NEXT_RE = re.compile(42    r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)4344# attributs binaires -> commodité affichable (uniquement si la valeur est vraie)45_AMENITY_LABELS = {46    "heat": "Chauffage inclus", "hydro": "Électricité incluse",47    "water": "Eau incluse", "internet": "Internet inclus",48    "cabletv": "Câble/télé inclus", "laundryinunit": "Laveuse/sécheuse dans l'unité",49    "laundryinbuilding": "Buanderie dans l'immeuble", "dishwasher": "Lave-vaisselle",50    "fridgefreezer": "Réfrigérateur/congélateur", "airconditioning": "Air climatisé",51    "balcony": "Balcon", "elevator": "Ascenseur", "gym": "Salle d'entraînement",52    "pool": "Piscine", "concierge": "Concierge",53    "twentyfourhoursecurity": "Sécurité 24 h", "storagelocker": "Espace de rangement",54    "bicycleparking": "Stationnement pour vélo", "yard": "Cour",55    "wheelchairaccessible": "Accessible en fauteuil roulant",56}57_UNIT_TYPES = {58    "apartment": "Appartement", "condo": "Condo",59    "basement-apartment": "Appartement au sous-sol", "house": "Maison",60    "townhouse": "Maison de ville", "duplex-triplex": "Duplex/Triplex",61}62_AGREEMENTS = {"one-year": "Bail de 1 an", "month-to-month": "Au mois",63               "not-available": ""}64# villes fréquentes sans accents dans les adresses Kijiji65_CITY_FIX = {66    "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis",67    "trois-rivieres": "Trois-Rivières", "riviere-des-prairies": "Montréal",68    "ville de montreal": "Montréal", "ville de quebec": "Québec",69}707172# loyer mensuel écrit dans le texte (annonces « Sur demande ») : un montant73# DOIT toucher un « $ » ET un mot mensuel (mois/month) ou un libellé loyer/prix74_NUM = r"(\d{1,2}[\s,.]?\d{3}|\d{3,4})"75_PRICE_TXT_RE = re.compile(76    r"\b(?:loyer|prix|rent|price)\s*:?\s*(?:est\s+de\s+|de\s+|à partir de\s+)?"77    + _NUM + r"(?:[.,]\d{2})?\s*\$"78    r"|" + _NUM + r"(?:[.,]\d{2})?\s*\$\s*"79    r"(?:/|par\s+|per\s+)\s*(?:mois|month)"80    r"|\$\s*" + _NUM + r"(?:\.\d{2})?\s*(?:/|per\s+|a\s+)\s*month"81    # fourchette « $1,100 to $1,300/month » : capter la borne BASSE aussi82    r"|\$\s*" + _NUM + r"(?:\.\d{2})?\s*(?:to|à|[-–])\s*\$\s*[\d ,.]+"83    r"\s*(?:/|per\s+)\s*month",84    re.I)858687def _price_from_text(text: str) -> float | None:88    """Loyer mensuel plausible (300–12 000 $) déduit du texte de l'annonce.8990    Conservateur : montant collé à un « $ » et à un contexte mensuel91    (loyer/prix/rent ou /mois, /month). Le plus BAS des montants trouvés92    (« à partir de… ») ; None si rien de plausible — jamais inventé.93    """94    vals = []95    for m in _PRICE_TXT_RE.finditer(text or ""):96        raw = next(g for g in m.groups() if g)97        try:98            val = float(re.sub(r"[\s,.]", "", raw))99        except ValueError:100            continue101        if 300 <= val <= 12000:102            vals.append(val)103    return min(vals) if vals else None104105106def _fix_city(raw: str) -> str:107    key = (raw or "").strip().lower()108    if key in _CITY_FIX:109        return _CITY_FIX[key]110    return " ".join(w.capitalize() for w in key.replace("-", " ").split())111112113def _attr_value(a: dict) -> str:114    """Première valeur d'un attribut Apollo (canonique, sinon affichée)."""115    for k in ("canonicalValues", "values"):116        vals = a.get(k) or []117        if vals:118            return str(vals[0])119    return ""120121122def _apply_attrs(attrs: list[dict], out: dict) -> None:123    """Interprète les attributs Kijiji (mêmes clés en liste et en fiche)."""124    amenities = out.setdefault("amenities", [])125    details = out.setdefault("details", {})126    for a in attrs or []:127        cn = a.get("canonicalName") or ""128        val = _attr_value(a)129        if not val:130            continue131        if cn in _AMENITY_LABELS:132            if val == "1":133                amenities.append(_AMENITY_LABELS[cn])134        elif cn == "furnished":135            out["furnished"] = val == "1"136        elif cn == "petsallowed":137            out["pets"] = "oui" if val == "1" else "non"138        elif cn == "numberbedrooms":139            out["bedrooms"] = val          # '0' = studio, sinon nb de chambres140        elif cn == "numberbathrooms":141            try:                            # canonique en dixièmes : '15' = 1.5142                n = int(val) / 10143                details["Salles de bain"] = f"{n:g}"144            except ValueError:145                pass146        elif cn in ("areainfeet", "sizesqft"):147            m = re.search(r"[\d.]+", val.replace(",", ""))148            if m and float(m.group(0)) > 0:149                out["area_sqft"] = float(m.group(0))150        elif cn == "dateavailable":151            m = re.match(r"(\d{4}-\d{2}-\d{2})", val)152            if m:153                out["availability_date"] = m.group(1)154        elif cn == "unittype":155            details["Type d'unité"] = _UNIT_TYPES.get(val, val)156        elif cn == "agreementtype":157            bail = _AGREEMENTS.get(val, val)158            if bail:159                details["Bail"] = bail160        elif cn == "numberparkingspots" and val.isdigit() and int(val) > 0:161            amenities.append(f"Stationnement ({val})")162163164def _parse_kijiji_detail(html: str) -> dict:165    """Fiche Kijiji : description complète, attributs, galerie haute résolution."""166    m = _NEXT_RE.search(html)167    if not m:168        return {}169    try:170        data = json.loads(m.group(1))171    except ValueError:172        return {}173    apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {})174    it = next((v for k, v in apollo.items()175               if _LISTING_KEY_RE.match(k) and isinstance(v, dict)176               and v.get("description")), None)177    if not it:178        return {}179    out: dict = {}180    if it.get("description"):181        out["description"] = str(it["description"]).strip()[:6000]182    imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u)183            for u in it.get("imageUrls") or []]184    if imgs:185        out["images"] = imgs186    _apply_attrs((it.get("attributes") or {}).get("all") or [], out)187    out.pop("bedrooms", None)   # le type d'unité est déjà fixé au niveau liste188    loc = it.get("location") or {}189    addr = (loc.get("address") or "").replace(", Canada", "")190    if re.match(r"\s*\d", addr):191        out["address"] = addr.split(",")[0]192    return out193194195class KijijiConnector(BaseConnector):196    source_id = "kijiji"197    request_delay = 1.2198199    def _page(self, seg: str, cat: int, page: int) -> list[dict]:200        """Annonces (Apollo state) d'une page de catégorie."""201        path = (f"{seg}/quebec/c{cat}l9001" if page == 1202                else f"{seg}/quebec/page-{page}/c{cat}l9001")203        html = self.get(f"{BASE}/{path}").text204        m = _NEXT_RE.search(html)205        data = json.loads(m.group(1)) if m else {}206        apollo = (data.get("props", {}).get("pageProps", {})207                  .get("__APOLLO_STATE__", {}))208        return [v for k, v in apollo.items()209                if _LISTING_KEY_RE.match(k) and isinstance(v, dict)]210211    def _to_listing(self, it: dict, unit_default: str) -> Listing | None:212        lid = str(it.get("id") or "")213        url = it.get("url") or ""214        if not lid or not url:215            return None216        price = None217        pr = it.get("price") or {}218        if isinstance(pr, dict) and pr.get("amount"):219            price = round(pr["amount"] / 100.0, 0)   # cents → $/mois220        loc = it.get("location") or {}221        coords = loc.get("coordinates") or {}222        address = (loc.get("address") or "").replace(", Canada", "")223        parts = [p.strip() for p in address.split(",") if p.strip()]224        # adresse à la française « 89, rue Dartois, Montréal » : le n° civique225        # arrive seul en tête — le recoller à la rue, sinon la rue devenait226        # la « ville » et polluait les filtres227        if len(parts) >= 2 and re.fullmatch(r"\d+[A-Za-z]?", parts[0]):228            parts = [f"{parts[0]} {parts[1]}"] + parts[2:]229        street = parts[0] if parts and re.match(r"\s*\d", parts[0]) else ""230        # la ville = premier élément après la rue qui n'est ni la province231        # ni un code postal (formats « rue, ville, QC H2X 1X1 »)232        rest = [p for p in (parts[1:] if street else parts)233                if not re.match(r"(?i)^(qc|qu[ée]bec)\b", p)234                and not re.match(r"(?i)^[a-z]\d[a-z]", p)]235        city = _fix_city(rest[0] if rest else (loc.get("name") or ""))236        images = [re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u)237                  for u in it.get("imageUrls") or []]238        extra: dict = {}239        _apply_attrs((it.get("attributes") or {}).get("all") or [], extra)240        unit_type = unit_default241        beds = extra.pop("bedrooms", None)242        if not unit_type and beds:243            try:                     # Kijiji code parfois « 2.5 » (2 ch. + den)244                n = int(float(beds))245            except ValueError:246                n = 0247            unit_type = "Studio" if n == 0 else f"{n} chambres"  # → n+2 ½248        lst = Listing(249            source=self.source_id,250            external_id=lid,251            url=url,252            title=it.get("title") or "",253            address=street,254            city=city,255            unit_type=unit_type,256            price=price,257            price_label=(f"{price:,.0f} $/mois".replace(",", " ")258                         if price else ""),259            description=(it.get("description") or "")[:2000],260            amenities=extra.get("amenities") or [],261            details=extra.get("details") or {},262            images=images,263            lat=coords.get("latitude"),264            lng=coords.get("longitude"),265        )266        if extra.get("availability_date"):267            lst.availability_date = extra["availability_date"]268            lst.availability = f"Libre le {extra['availability_date']}"269        if extra.get("furnished") is not None:270            lst.furnished = extra["furnished"]271        if extra.get("pets"):272            lst.pets = extra["pets"]273        if extra.get("area_sqft"):274            lst.area_sqft = extra["area_sqft"]275        return lst276277    def fetch(self) -> list[Listing]:278        out: dict[str, Listing] = {}279        for cat, seg, unit_default in CATEGORIES:280            for page in range(1, MAX_PAGES + 1):281                try:282                    items = self._page(seg, cat, page)283                except Exception:284                    break285                fresh = 0286                for it in items:287                    lst = self._to_listing(it, unit_default)288                    if lst is not None and lst.uid not in out:289                        out[lst.uid] = lst290                        fresh += 1291                # plus rien de neuf (page de fin remplie de topAds répétés)292                if fresh == 0 or len(items) < 10:293                    break294        listings = list(out.values())295        du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1")296        # prix « Sur demande » : tenter le montant écrit dans le texte de297        # l'annonce (APRÈS enrich : la description complète vient de la fiche)298        for lst in listings:299            if lst.price is None:300                p = _price_from_text(f"{lst.title}\n{lst.description}")301                if p is not None:302                    lst.price = p303                    lst.price_label = (f"{p:,.0f} $/mois (selon la description)"304                                       .replace(",", " "))305                elif not lst.price_label:306                    lst.price_label = "Sur demande"307        return listings308