SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
6.6 KB · 168 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces immobilières5#   UNIQUEMENT les catégories immobilier À VENDRE, UNIQUEMENT le Québec (l9001) :6#     c35  maisons à vendre · c643 condos à vendre · c641 terrains à vendre7#   Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre,8#   prix, GPS, adresse et vignette — aucune API privée nécessaire.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import json13import os14import re1516from ..schema import PropertyListing17from .base import BaseConnector1819from . import _detailutil as du2021BASE = "https://www.kijiji.ca"22# (code catégorie, segment d'URL, type canonique)23CATEGORIES = [24    (35, "b-maison-a-vendre", "Maison"),25    (643, "b-condo-a-vendre", "Condo"),26    (641, "b-terrain-a-vendre", "Terrain"),27]28MAX_PAGES = int(os.environ.get("IMMOKA_KIJIJI_MAX_PAGES", "100"))29DETAIL_LIMIT = int(os.environ.get("IMMOKA_KIJIJI_DETAIL_LIMIT", "400"))3031_ATTR_LABELS = {32    "numberbedrooms": "Chambres", "numberbathrooms": "Salles de bain",33    "areainfeet": "Superficie (pi²)", "forsalebyhousing": "À vendre par",34    "yearbuilt": "Année de construction", "sizesqft": "Superficie (pi²)",35}363738def _parse_kijiji_detail(html: str) -> dict:39    """Fiche Kijiji : description complète, attributs, galerie haute résolution."""40    m = re.search(41        r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',42        html, re.S)43    if not m:44        return {}45    try:46        data = json.loads(m.group(1))47    except ValueError:48        return {}49    apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {})50    it = next((v for k, v in apollo.items()51               if k.startswith("StandardListing:") and isinstance(v, dict)52               and v.get("description")), None)53    if not it:54        return {}55    out: dict = {}56    if it.get("description"):57        out["description"] = str(it["description"]).strip()[:6000]58    imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u)59            for u in it.get("imageUrls") or []]60    if imgs:61        out["images"] = imgs62    features, details = [], {}63    for a in (it.get("attributes") or {}).get("all") or []:64        cn = a.get("canonicalName") or ""65        val = ", ".join(str(v) for v in a.get("values") or [])66        if not val:67            continue68        label = _ATTR_LABELS.get(cn, a.get("name") or cn)69        features.append(f"{label} : {val}")70        details[label] = val71        if cn == "numberbedrooms" and val.isdigit():72            out["bedrooms"] = int(val)73        elif cn == "numberbathrooms" and val.isdigit():74            out["bathrooms"] = int(val)75        elif cn in ("areainfeet", "sizesqft"):76            mn = re.search(r"[\d.]+", val.replace(",", ""))77            if mn:78                out["area_sqft"] = float(mn.group(0))79        elif cn == "yearbuilt" and val.isdigit():80            out["year_built"] = int(val)81    if features:82        out["features"] = features83    if details:84        out["details"] = details85    loc = it.get("location") or {}86    addr = (loc.get("address") or "").replace(", Canada", "")87    if re.match(r"\s*\d", addr):88        out["address"] = addr.split(",")[0]89    return out909192class KijijiConnector(BaseConnector):93    source_id = "kijiji"94    request_delay = 1.29596    def _next_data(self, html: str) -> dict:97        m = re.search(98            r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',99            html, re.S)100        return json.loads(m.group(1)) if m else {}101102    def _page(self, seg: str, cat: int, page: int) -> list[dict]:103        """Annonces (Apollo state) d'une page de catégorie."""104        path = (f"{seg}/quebec/c{cat}l9001" if page == 1105                else f"{seg}/quebec/page-{page}/c{cat}l9001")106        html = self.get(f"{BASE}/{path}").text107        data = self._next_data(html)108        apollo = (data.get("props", {}).get("pageProps", {})109                  .get("__APOLLO_STATE__", {}))110        return [v for k, v in apollo.items()111                if k.startswith("StandardListing:") and isinstance(v, dict)]112113    def _to_listing(self, it: dict, ptype: str) -> PropertyListing | None:114        lid = str(it.get("id") or "")115        url = it.get("url") or ""116        if not lid or not url:117            return None118        price = None119        pr = it.get("price") or {}120        if isinstance(pr, dict) and pr.get("amount"):121            price = round(pr["amount"] / 100.0, 0)   # cents → $122        loc = it.get("location") or {}123        coords = loc.get("coordinates") or {}124        address = (loc.get("address") or "").replace(", Canada", "")125        # « Saint-Hubert, QC J3Y 6Y3 » → ville avant la 1re virgule126        city = loc.get("name") or (address.split(",")[0] if address else "")127        images = []128        for u in it.get("imageUrls") or []:129            images.append(re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u))130        return PropertyListing(131            source=self.source_id,132            external_id=lid,133            url=url,134            title=it.get("title") or "",135            address=address.split(",")[0] if re.match(r"\s*\d", address) else "",136            city=city,137            property_type=ptype,138            price=price,139            price_label=f"{price:,.0f} $".replace(",", " ") if price else "",140            description=(it.get("description") or "")[:2000],141            images=images,142            lat=coords.get("latitude"),143            lng=coords.get("longitude"),144            broker_name="Kijiji (particuliers)",145            agency="Kijiji Québec",146        )147148    def fetch(self) -> list[PropertyListing]:149        out: dict[str, PropertyListing] = {}150        for cat, seg, ptype in CATEGORIES:151            for page in range(1, MAX_PAGES + 1):152                try:153                    items = self._page(seg, cat, page)154                except Exception:155                    break156                fresh = 0157                for it in items:158                    lst = self._to_listing(it, ptype)159                    if lst is not None and lst.uid not in out:160                        out[lst.uid] = lst161                        fresh += 1162                # plus rien de neuf (page de fin remplie de topAds répétés)163                if fresh == 0 or len(items) < 10:164                    break165        listings = list(out.values())166        du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1")167        return listings168