SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
13.6 KB · 325 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/kijiji.py : Kijiji (kijiji.ca) — FOR-RENT classifieds ONLY,5#   every province EXCEPT Québec (province location ids verified live6#   2026-08-27):7#     c37 apartments & condos for rent · c36 room rentals & roommates8#   Pages list 40+ ads in __NEXT_DATA__ (Apollo state) with title, price,9#   GPS, address, availability date and attributes (furnished, pets,10#   inclusions…) — no private API needed. Adapted from Immo-Ka's for-sale11#   connector.12#   «Please contact» prices (price.type=CONTACT, ~3% of ads): the amount is13#   often written in the description («Rent: $1,550/month») — recovered14#   conservatively (amount glued to $ + a monthly word), otherwise the label15#   shows «On request» rather than being empty.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import json20import os21import re2223from ..schema import Listing24from .base import BaseConnector2526from . import _detailutil as du2728BASE = "https://www.kijiji.ca"29# (category code, URL segment, default unit type)30CATEGORIES = [31    (37, "b-apartments-condos", ""),          # unit derived from attributes32    (36, "b-room-rental-roommate", "Room"),33]34# provinces outside Québec: (URL slug, Kijiji location id, province code)35PROVINCES = [36    ("ontario", "9004", "ON"),37    ("british-columbia", "9007", "BC"),38    ("alberta", "9003", "AB"),39    ("manitoba", "9006", "MB"),40    ("saskatchewan", "9009", "SK"),41    ("nova-scotia", "9002", "NS"),42    ("new-brunswick", "9005", "NB"),43    ("newfoundland", "9008", "NL"),44    ("prince-edward-island", "9011", "PE"),45]46MAX_PAGES = int(os.environ.get("RENTKA_KIJIJI_MAX_PAGES", "12"))  # per prov/cat47DETAIL_LIMIT = int(os.environ.get("RENTKA_KIJIJI_DETAIL_LIMIT", "400"))4849# les annonces vivent sous des clés Apollo « RealEstateListing:123 » (c37)50# ou « StandardListing:123 » (c36)51_LISTING_KEY_RE = re.compile(r"^(?:RealEstate|Standard)Listing:\d+$")52_NEXT_RE = re.compile(53    r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)5455# binary attributes -> displayable amenity (only when the value is true)56_AMENITY_LABELS = {57    "heat": "Heat included", "hydro": "Electricity included",58    "water": "Water included", "internet": "Internet included",59    "cabletv": "Cable TV included", "laundryinunit": "In-unit laundry",60    "laundryinbuilding": "Laundry in building", "dishwasher": "Dishwasher",61    "fridgefreezer": "Fridge/freezer", "airconditioning": "Air conditioning",62    "balcony": "Balcony", "elevator": "Elevator", "gym": "Gym",63    "pool": "Pool", "concierge": "Concierge",64    "twentyfourhoursecurity": "24-hour security",65    "storagelocker": "Storage locker",66    "bicycleparking": "Bicycle parking", "yard": "Yard",67    "wheelchairaccessible": "Wheelchair accessible",68}69_UNIT_TYPES = {70    "apartment": "Apartment", "condo": "Condo",71    "basement-apartment": "Basement apartment", "house": "House",72    "townhouse": "Townhouse", "duplex-triplex": "Duplex/Triplex",73}74_AGREEMENTS = {"one-year": "1-year lease", "month-to-month": "Month-to-month",75               "not-available": ""}76# common city fixes in Kijiji addresses77_CITY_FIX = {78    "st. johns": "St. John's", "st johns": "St. John's",79}808182# loyer mensuel écrit dans le texte (annonces « Sur demande ») : un montant83# DOIT toucher un « $ » ET un mot mensuel (mois/month) ou un libellé loyer/prix84_NUM = r"(\d{1,2}[\s,.]?\d{3}|\d{3,4})"85_PRICE_TXT_RE = re.compile(86    r"\b(?:loyer|prix|rent|price)\s*:?\s*(?:est\s+de\s+|de\s+|à partir de\s+)?"87    + _NUM + r"(?:[.,]\d{2})?\s*\$"88    r"|" + _NUM + r"(?:[.,]\d{2})?\s*\$\s*"89    r"(?:/|par\s+|per\s+)\s*(?:mois|month)"90    r"|\$\s*" + _NUM + r"(?:\.\d{2})?\s*(?:/|per\s+|a\s+)\s*month"91    # fourchette « $1,100 to $1,300/month » : capter la borne BASSE aussi92    r"|\$\s*" + _NUM + r"(?:\.\d{2})?\s*(?:to|à|[-–])\s*\$\s*[\d ,.]+"93    r"\s*(?:/|per\s+)\s*month",94    re.I)959697def _price_from_text(text: str) -> float | None:98    """Loyer mensuel plausible (300–12 000 $) déduit du texte de l'annonce.99100    Conservateur : montant collé à un « $ » et à un contexte mensuel101    (loyer/prix/rent ou /mois, /month). Le plus BAS des montants trouvés102    (« à partir de… ») ; None si rien de plausible — jamais inventé.103    """104    vals = []105    for m in _PRICE_TXT_RE.finditer(text or ""):106        raw = next(g for g in m.groups() if g)107        try:108            val = float(re.sub(r"[\s,.]", "", raw))109        except ValueError:110            continue111        if 300 <= val <= 12000:112            vals.append(val)113    return min(vals) if vals else None114115116def _fix_city(raw: str) -> str:117    key = (raw or "").strip().lower()118    if key in _CITY_FIX:119        return _CITY_FIX[key]120    return " ".join(w.capitalize() for w in key.replace("-", " ").split())121122123def _attr_value(a: dict) -> str:124    """Première valeur d'un attribut Apollo (canonique, sinon affichée)."""125    for k in ("canonicalValues", "values"):126        vals = a.get(k) or []127        if vals:128            return str(vals[0])129    return ""130131132def _apply_attrs(attrs: list[dict], out: dict) -> None:133    """Interprète les attributs Kijiji (mêmes clés en liste et en fiche)."""134    amenities = out.setdefault("amenities", [])135    details = out.setdefault("details", {})136    for a in attrs or []:137        cn = a.get("canonicalName") or ""138        val = _attr_value(a)139        if not val:140            continue141        if cn in _AMENITY_LABELS:142            if val == "1":143                amenities.append(_AMENITY_LABELS[cn])144        elif cn == "furnished":145            out["furnished"] = val == "1"146        elif cn == "petsallowed":147            out["pets"] = "yes" if val == "1" else "no"148        elif cn == "numberbedrooms":149            out["bedrooms"] = val          # '0' = studio, else bedroom count150        elif cn == "numberbathrooms":151            try:                            # canonical in tenths: '15' = 1.5152                n = int(val) / 10153                details["bathrooms"] = f"{n:g}"154            except ValueError:155                pass156        elif cn in ("areainfeet", "sizesqft"):157            m = re.search(r"[\d.]+", val.replace(",", ""))158            if m and float(m.group(0)) > 0:159                out["area_sqft"] = float(m.group(0))160        elif cn == "dateavailable":161            m = re.match(r"(\d{4}-\d{2}-\d{2})", val)162            if m:163                out["availability_date"] = m.group(1)164        elif cn == "unittype":165            details["Unit type"] = _UNIT_TYPES.get(val, val)166        elif cn == "agreementtype":167            lease = _AGREEMENTS.get(val, val)168            if lease:169                details["Lease"] = lease170        elif cn == "numberparkingspots" and val.isdigit() and int(val) > 0:171            amenities.append(f"Parking ({val})")172173174def _parse_kijiji_detail(html: str) -> dict:175    """Fiche Kijiji : description complète, attributs, galerie haute résolution."""176    m = _NEXT_RE.search(html)177    if not m:178        return {}179    try:180        data = json.loads(m.group(1))181    except ValueError:182        return {}183    apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {})184    it = next((v for k, v in apollo.items()185               if _LISTING_KEY_RE.match(k) and isinstance(v, dict)186               and v.get("description")), None)187    if not it:188        return {}189    out: dict = {}190    if it.get("description"):191        out["description"] = str(it["description"]).strip()[:6000]192    imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u)193            for u in it.get("imageUrls") or []]194    if imgs:195        out["images"] = imgs196    _apply_attrs((it.get("attributes") or {}).get("all") or [], out)197    out.pop("bedrooms", None)   # le type d'unité est déjà fixé au niveau liste198    loc = it.get("location") or {}199    addr = (loc.get("address") or "").replace(", Canada", "")200    if re.match(r"\s*\d", addr):201        out["address"] = addr.split(",")[0]202    return out203204205class KijijiConnector(BaseConnector):206    source_id = "kijiji"207    request_delay = 1.2208209    def _page(self, seg: str, cat: int, page: int, prov_slug: str,210              loc_id: str) -> list[dict]:211        """Ads (Apollo state) from one category page of one province."""212        path = (f"{seg}/{prov_slug}/c{cat}l{loc_id}" if page == 1213                else f"{seg}/{prov_slug}/page-{page}/c{cat}l{loc_id}")214        html = self.get(f"{BASE}/{path}").text215        m = _NEXT_RE.search(html)216        data = json.loads(m.group(1)) if m else {}217        apollo = (data.get("props", {}).get("pageProps", {})218                  .get("__APOLLO_STATE__", {}))219        return [v for k, v in apollo.items()220                if _LISTING_KEY_RE.match(k) and isinstance(v, dict)]221222    def _to_listing(self, it: dict, unit_default: str,223                    province: str = "ON") -> Listing | None:224        lid = str(it.get("id") or "")225        url = it.get("url") or ""226        if not lid or not url:227            return None228        price = None229        pr = it.get("price") or {}230        if isinstance(pr, dict) and pr.get("amount"):231            price = round(pr["amount"] / 100.0, 0)   # cents → $/mois232        loc = it.get("location") or {}233        coords = loc.get("coordinates") or {}234        address = (loc.get("address") or "").replace(", Canada", "")235        parts = [p.strip() for p in address.split(",") if p.strip()]236        # adresse à la française « 89, rue Dartois, Montréal » : le n° civique237        # arrive seul en tête — le recoller à la rue, sinon la rue devenait238        # la « ville » et polluait les filtres239        if len(parts) >= 2 and re.fullmatch(r"\d+[A-Za-z]?", parts[0]):240            parts = [f"{parts[0]} {parts[1]}"] + parts[2:]241        street = parts[0] if parts and re.match(r"\s*\d", parts[0]) else ""242        # the city = first element after the street that is neither the243        # province nor a postal code («street, city, ON M5V 1J1» formats)244        rest = [p for p in (parts[1:] if street else parts)245                if not re.match(r"(?i)^(on|bc|ab|sk|mb|nb|ns|pe|nl|yt|nt|nu|"246                                r"ontario|british columbia|alberta|"247                                r"saskatchewan|manitoba|new brunswick|"248                                r"nova scotia|prince edward island|"249                                r"newfoundland)\b", p)250                and not re.match(r"(?i)^[a-z]\d[a-z]", p)]251        city = _fix_city(rest[0] if rest else (loc.get("name") or ""))252        images = [re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u)253                  for u in it.get("imageUrls") or []]254        extra: dict = {}255        _apply_attrs((it.get("attributes") or {}).get("all") or [], extra)256        unit_type = unit_default257        beds = extra.pop("bedrooms", None)258        if not unit_type and beds:259            try:                     # Kijiji sometimes codes «2.5» (2 bed + den)260                n = int(float(beds))261            except ValueError:262                n = 0263            unit_type = ("Studio" if n == 0264                         else f"{n} bedroom" + ("s" if n > 1 else ""))265        lst = Listing(266            source=self.source_id,267            external_id=lid,268            url=url,269            title=it.get("title") or "",270            address=street,271            city=city,272            province=province,273            unit_type=unit_type,274            price=price,275            price_label=(f"${price:,.0f}/month" if price else ""),276            description=(it.get("description") or "")[:2000],277            amenities=extra.get("amenities") or [],278            details=extra.get("details") or {},279            images=images,280            lat=coords.get("latitude"),281            lng=coords.get("longitude"),282        )283        if extra.get("availability_date"):284            lst.availability_date = extra["availability_date"]285            lst.availability = f"Available {extra['availability_date']}"286        if extra.get("furnished") is not None:287            lst.furnished = extra["furnished"]288        if extra.get("pets"):289            lst.pets = extra["pets"]290        if extra.get("area_sqft"):291            lst.area_sqft = extra["area_sqft"]292        return lst293294    def fetch(self) -> list[Listing]:295        out: dict[str, Listing] = {}296        for prov_slug, loc_id, prov in PROVINCES:297            for cat, seg, unit_default in CATEGORIES:298                for page in range(1, MAX_PAGES + 1):299                    try:300                        items = self._page(seg, cat, page, prov_slug, loc_id)301                    except Exception:302                        break303                    fresh = 0304                    for it in items:305                        lst = self._to_listing(it, unit_default, province=prov)306                        if lst is not None and lst.uid not in out:307                            out[lst.uid] = lst308                            fresh += 1309                    # nothing new (end page filled with repeated topAds)310                    if fresh == 0 or len(items) < 10:311                        break312        listings = list(out.values())313        du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1")314        # «please contact» prices: try the amount written in the ad text315        # (AFTER enrich: the full description comes from the detail page)316        for lst in listings:317            if lst.price is None:318                p = _price_from_text(f"{lst.title}\n{lst.description}")319                if p is not None:320                    lst.price = p321                    lst.price_label = f"${p:,.0f}/month (from description)"322                elif not lst.price_label:323                    lst.price_label = "On request"324        return listings325