# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/kijiji.py : Kijiji (kijiji.ca) — FOR-RENT classifieds ONLY, # every province EXCEPT Québec (province location ids verified live # 2026-08-27): # c37 apartments & condos for rent · c36 room rentals & roommates # Pages list 40+ ads in __NEXT_DATA__ (Apollo state) with title, price, # GPS, address, availability date and attributes (furnished, pets, # inclusions…) — no private API needed. Adapted from Immo-Ka's for-sale # connector. # «Please contact» prices (price.type=CONTACT, ~3% of ads): the amount is # often written in the description («Rent: $1,550/month») — recovered # conservatively (amount glued to $ + a monthly word), otherwise the label # shows «On request» rather than being empty. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re from ..schema import Listing from .base import BaseConnector from . import _detailutil as du BASE = "https://www.kijiji.ca" # (category code, URL segment, default unit type) CATEGORIES = [ (37, "b-apartments-condos", ""), # unit derived from attributes (36, "b-room-rental-roommate", "Room"), ] # provinces outside Québec: (URL slug, Kijiji location id, province code) PROVINCES = [ ("ontario", "9004", "ON"), ("british-columbia", "9007", "BC"), ("alberta", "9003", "AB"), ("manitoba", "9006", "MB"), ("saskatchewan", "9009", "SK"), ("nova-scotia", "9002", "NS"), ("new-brunswick", "9005", "NB"), ("newfoundland", "9008", "NL"), ("prince-edward-island", "9011", "PE"), ] MAX_PAGES = int(os.environ.get("RENTKA_KIJIJI_MAX_PAGES", "12")) # per prov/cat DETAIL_LIMIT = int(os.environ.get("RENTKA_KIJIJI_DETAIL_LIMIT", "400")) # les annonces vivent sous des clés Apollo « RealEstateListing:123 » (c37) # ou « StandardListing:123 » (c36) _LISTING_KEY_RE = re.compile(r"^(?:RealEstate|Standard)Listing:\d+$") _NEXT_RE = re.compile( r'', re.S) # binary attributes -> displayable amenity (only when the value is true) _AMENITY_LABELS = { "heat": "Heat included", "hydro": "Electricity included", "water": "Water included", "internet": "Internet included", "cabletv": "Cable TV included", "laundryinunit": "In-unit laundry", "laundryinbuilding": "Laundry in building", "dishwasher": "Dishwasher", "fridgefreezer": "Fridge/freezer", "airconditioning": "Air conditioning", "balcony": "Balcony", "elevator": "Elevator", "gym": "Gym", "pool": "Pool", "concierge": "Concierge", "twentyfourhoursecurity": "24-hour security", "storagelocker": "Storage locker", "bicycleparking": "Bicycle parking", "yard": "Yard", "wheelchairaccessible": "Wheelchair accessible", } _UNIT_TYPES = { "apartment": "Apartment", "condo": "Condo", "basement-apartment": "Basement apartment", "house": "House", "townhouse": "Townhouse", "duplex-triplex": "Duplex/Triplex", } _AGREEMENTS = {"one-year": "1-year lease", "month-to-month": "Month-to-month", "not-available": ""} # common city fixes in Kijiji addresses _CITY_FIX = { "st. johns": "St. John's", "st johns": "St. John's", } # loyer mensuel écrit dans le texte (annonces « Sur demande ») : un montant # DOIT toucher un « $ » ET un mot mensuel (mois/month) ou un libellé loyer/prix _NUM = r"(\d{1,2}[\s,.]?\d{3}|\d{3,4})" _PRICE_TXT_RE = re.compile( r"\b(?:loyer|prix|rent|price)\s*:?\s*(?:est\s+de\s+|de\s+|à partir de\s+)?" + _NUM + r"(?:[.,]\d{2})?\s*\$" r"|" + _NUM + r"(?:[.,]\d{2})?\s*\$\s*" r"(?:/|par\s+|per\s+)\s*(?:mois|month)" r"|\$\s*" + _NUM + r"(?:\.\d{2})?\s*(?:/|per\s+|a\s+)\s*month" # fourchette « $1,100 to $1,300/month » : capter la borne BASSE aussi r"|\$\s*" + _NUM + r"(?:\.\d{2})?\s*(?:to|à|[-–])\s*\$\s*[\d ,.]+" r"\s*(?:/|per\s+)\s*month", re.I) def _price_from_text(text: str) -> float | None: """Loyer mensuel plausible (300–12 000 $) déduit du texte de l'annonce. Conservateur : montant collé à un « $ » et à un contexte mensuel (loyer/prix/rent ou /mois, /month). Le plus BAS des montants trouvés (« à partir de… ») ; None si rien de plausible — jamais inventé. """ vals = [] for m in _PRICE_TXT_RE.finditer(text or ""): raw = next(g for g in m.groups() if g) try: val = float(re.sub(r"[\s,.]", "", raw)) except ValueError: continue if 300 <= val <= 12000: vals.append(val) return min(vals) if vals else None def _fix_city(raw: str) -> str: key = (raw or "").strip().lower() if key in _CITY_FIX: return _CITY_FIX[key] return " ".join(w.capitalize() for w in key.replace("-", " ").split()) def _attr_value(a: dict) -> str: """Première valeur d'un attribut Apollo (canonique, sinon affichée).""" for k in ("canonicalValues", "values"): vals = a.get(k) or [] if vals: return str(vals[0]) return "" def _apply_attrs(attrs: list[dict], out: dict) -> None: """Interprète les attributs Kijiji (mêmes clés en liste et en fiche).""" amenities = out.setdefault("amenities", []) details = out.setdefault("details", {}) for a in attrs or []: cn = a.get("canonicalName") or "" val = _attr_value(a) if not val: continue if cn in _AMENITY_LABELS: if val == "1": amenities.append(_AMENITY_LABELS[cn]) elif cn == "furnished": out["furnished"] = val == "1" elif cn == "petsallowed": out["pets"] = "yes" if val == "1" else "no" elif cn == "numberbedrooms": out["bedrooms"] = val # '0' = studio, else bedroom count elif cn == "numberbathrooms": try: # canonical in tenths: '15' = 1.5 n = int(val) / 10 details["bathrooms"] = f"{n:g}" except ValueError: pass elif cn in ("areainfeet", "sizesqft"): m = re.search(r"[\d.]+", val.replace(",", "")) if m and float(m.group(0)) > 0: out["area_sqft"] = float(m.group(0)) elif cn == "dateavailable": m = re.match(r"(\d{4}-\d{2}-\d{2})", val) if m: out["availability_date"] = m.group(1) elif cn == "unittype": details["Unit type"] = _UNIT_TYPES.get(val, val) elif cn == "agreementtype": lease = _AGREEMENTS.get(val, val) if lease: details["Lease"] = lease elif cn == "numberparkingspots" and val.isdigit() and int(val) > 0: amenities.append(f"Parking ({val})") def _parse_kijiji_detail(html: str) -> dict: """Fiche Kijiji : description complète, attributs, galerie haute résolution.""" m = _NEXT_RE.search(html) if not m: return {} try: data = json.loads(m.group(1)) except ValueError: return {} apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {}) it = next((v for k, v in apollo.items() if _LISTING_KEY_RE.match(k) and isinstance(v, dict) and v.get("description")), None) if not it: return {} out: dict = {} if it.get("description"): out["description"] = str(it["description"]).strip()[:6000] imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u) for u in it.get("imageUrls") or []] if imgs: out["images"] = imgs _apply_attrs((it.get("attributes") or {}).get("all") or [], out) out.pop("bedrooms", None) # le type d'unité est déjà fixé au niveau liste loc = it.get("location") or {} addr = (loc.get("address") or "").replace(", Canada", "") if re.match(r"\s*\d", addr): out["address"] = addr.split(",")[0] return out class KijijiConnector(BaseConnector): source_id = "kijiji" request_delay = 1.2 def _page(self, seg: str, cat: int, page: int, prov_slug: str, loc_id: str) -> list[dict]: """Ads (Apollo state) from one category page of one province.""" path = (f"{seg}/{prov_slug}/c{cat}l{loc_id}" if page == 1 else f"{seg}/{prov_slug}/page-{page}/c{cat}l{loc_id}") html = self.get(f"{BASE}/{path}").text m = _NEXT_RE.search(html) data = json.loads(m.group(1)) if m else {} apollo = (data.get("props", {}).get("pageProps", {}) .get("__APOLLO_STATE__", {})) return [v for k, v in apollo.items() if _LISTING_KEY_RE.match(k) and isinstance(v, dict)] def _to_listing(self, it: dict, unit_default: str, province: str = "ON") -> Listing | None: lid = str(it.get("id") or "") url = it.get("url") or "" if not lid or not url: return None price = None pr = it.get("price") or {} if isinstance(pr, dict) and pr.get("amount"): price = round(pr["amount"] / 100.0, 0) # cents → $/mois loc = it.get("location") or {} coords = loc.get("coordinates") or {} address = (loc.get("address") or "").replace(", Canada", "") parts = [p.strip() for p in address.split(",") if p.strip()] # adresse à la française « 89, rue Dartois, Montréal » : le n° civique # arrive seul en tête — le recoller à la rue, sinon la rue devenait # la « ville » et polluait les filtres if len(parts) >= 2 and re.fullmatch(r"\d+[A-Za-z]?", parts[0]): parts = [f"{parts[0]} {parts[1]}"] + parts[2:] street = parts[0] if parts and re.match(r"\s*\d", parts[0]) else "" # the city = first element after the street that is neither the # province nor a postal code («street, city, ON M5V 1J1» formats) rest = [p for p in (parts[1:] if street else parts) if not re.match(r"(?i)^(on|bc|ab|sk|mb|nb|ns|pe|nl|yt|nt|nu|" r"ontario|british columbia|alberta|" r"saskatchewan|manitoba|new brunswick|" r"nova scotia|prince edward island|" r"newfoundland)\b", p) and not re.match(r"(?i)^[a-z]\d[a-z]", p)] city = _fix_city(rest[0] if rest else (loc.get("name") or "")) images = [re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u) for u in it.get("imageUrls") or []] extra: dict = {} _apply_attrs((it.get("attributes") or {}).get("all") or [], extra) unit_type = unit_default beds = extra.pop("bedrooms", None) if not unit_type and beds: try: # Kijiji sometimes codes «2.5» (2 bed + den) n = int(float(beds)) except ValueError: n = 0 unit_type = ("Studio" if n == 0 else f"{n} bedroom" + ("s" if n > 1 else "")) lst = Listing( source=self.source_id, external_id=lid, url=url, title=it.get("title") or "", address=street, city=city, province=province, unit_type=unit_type, price=price, price_label=(f"${price:,.0f}/month" if price else ""), description=(it.get("description") or "")[:2000], amenities=extra.get("amenities") or [], details=extra.get("details") or {}, images=images, lat=coords.get("latitude"), lng=coords.get("longitude"), ) if extra.get("availability_date"): lst.availability_date = extra["availability_date"] lst.availability = f"Available {extra['availability_date']}" if extra.get("furnished") is not None: lst.furnished = extra["furnished"] if extra.get("pets"): lst.pets = extra["pets"] if extra.get("area_sqft"): lst.area_sqft = extra["area_sqft"] return lst def fetch(self) -> list[Listing]: out: dict[str, Listing] = {} for prov_slug, loc_id, prov in PROVINCES: for cat, seg, unit_default in CATEGORIES: for page in range(1, MAX_PAGES + 1): try: items = self._page(seg, cat, page, prov_slug, loc_id) except Exception: break fresh = 0 for it in items: lst = self._to_listing(it, unit_default, province=prov) if lst is not None and lst.uid not in out: out[lst.uid] = lst fresh += 1 # nothing new (end page filled with repeated topAds) if fresh == 0 or len(items) < 10: break listings = list(out.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1") # «please contact» prices: try the amount written in the ad text # (AFTER enrich: the full description comes from the detail page) for lst in listings: if lst.price is None: p = _price_from_text(f"{lst.title}\n{lst.description}") if p is not None: lst.price = p lst.price_label = f"${p:,.0f}/month (from description)" elif not lst.price_label: lst.price_label = "On request" return listings