Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces, catégorie4# « Locations de vacances » à destination du Québec (c814 : les annonces y5# sont classées par province de LA PROPRIÉTÉ, pas de l'annonceur — la6# recherche « quebec/c800 » retournait des condos en Floride affichés7# depuis Québec).8#9# Méthode (Kijiji est un Next.js derrière un anti-bot : HTML via Bright Data10# Web Unlocker, Scrapfly ASP en secours — même recette que le connecteur11# Airbnb) :12# 1. LISTE : /b-vacation-rentals-quebec/canada/c814l0 (+ /page-N/) —13# __NEXT_DATA__ → __APOLLO_STATE__ → searchResultsPageByUrl → results14# (topListings + mainListings, ~40/page, totalCount ≈ 75). Chaque entité15# StandardListing donne titre, prix (cents), photos, attributs canoniques.16# 2. DÉTAIL (cache self.detail) : la page /v-…/<id> embarque le même17# APOLLO_STATE avec en plus la description complète, les coordonnées18# GPS, toutes les photos et les attributs en clair (« 2 bedrooms and19# den », région touristique dans l'attribut « city », animaux…).20#21# Filtres court terme : on ne garde que les annonces OFFER qui ressemblent à22# un hébergement (attributs chambres/personnes/type de vacances présents —23# la catégorie contient aussi maillots de bain, machines à espresso… ; si24# les attributs manquent sur la liste mais que le titre évoque un25# hébergement, la fiche détail tranche) et on écarte les locations au mois26# (« 31 jours et plus », monthly, minnights >= 28…).27#28# Prix : le formulaire de la catégorie demande un prix À LA NUIT — un texte29# « X $/nuit » ou « $X/night » dans l'annonce prime (minimum des saisons),30# sinon le montant affiché est pris comme prix/nuit s'il est plausible31# (<= 2 000 $ et séjour min < 28 nuits), sinon details.prix_affiche.32# Salles de bain : la valeur canonique Kijiji est en dixièmes (« 20 » = 2) —33# normalisée. Commodités : aucune dans les attributs de la catégorie — on34# les dérive des mentions explicites du texte (spa, sauna, foyer, BBQ…).35#36# Réglage env : LOUKA_KIJIJI_LIMIT (nb max d'annonces, pour tester petit).37# -----------------------------------------------------------------------------38from __future__ import annotations3940import json41import os42import re43import time4445import requests4647from ...normalize import strip_accents48from ..schema import StListing, normalize_region, parse_price_night, REGIONS49from .airbnb import _region_from_latlng50from .base import StConnector5152BRIGHTDATA_API = "https://api.brightdata.com/request"53BASE = "https://www.kijiji.ca"54LISTE = BASE + "/b-vacation-rentals-quebec/canada/{page}c814l0"5556# attributs canoniques qui signent un vrai hébergement57_ATTRS_HEBERGEMENT = {"numberbedrooms", "maxpeople", "vacationtype",58 "numberbathrooms", "minnights"}5960# location au mois (ou plus) : hors mandat court terme61_MENSUEL_RE = re.compile(62 r"au mois|par mois|/\s*mois|mensuel|monthly|per\s+month|/\s*month"63 r"|3[01]\s*jours\s*(?:et plus|minimum|min)|month(?:ly)?\s+rental", re.I)6465# « 265 $ / nuit » (fr) comme « $265/night » (en) — $ avant ou après le montant66_NUIT_RE = re.compile(67 r"(?:\$\s*(\d[\d\s,.]{0,8}\d|\d)|(\d[\d\s,.]{0,8}\d|\d)\s*\$)\s*"68 r"(?:/|par|la|per)?\s*(?:nuit|night)", re.I)69_SEMAINE_RE = re.compile(70 r"(?:\$\s*(\d[\d\s,.]{0,8}\d|\d)|(\d[\d\s,.]{0,8}\d|\d)\s*\$)\s*"71 r"(?:/|par|la|per)?\s*(?:sem(?:aine)?|week)", re.I)727374def _prix_min(texte: str, rx: re.Pattern) -> float | None:75 """Le plus bas des montants d'une période (les annonces listent souvent76 plusieurs saisons : « $265/night … $298/night »)."""77 vals = []78 for m in rx.finditer(texte or ""):79 raw = (m.group(1) or m.group(2) or "").strip()80 v = parse_price_night(f"{raw} $")81 if v:82 vals.append(v)83 return min(vals) if vals else None848586# mentions explicites du texte → commodité affichable (la catégorie Kijiji87# n'a aucun attribut de commodités) ; clés en minuscules sans accents88_AMEN_HINTS = [89 ("spa", "Spa"), ("jacuzzi", "Spa"), ("hot tub", "Spa"),90 ("sauna", "Sauna"), ("piscine", "Piscine"), ("pool", "Piscine"),91 ("foyer", "Foyer"), ("fireplace", "Foyer"),92 ("poele a bois", "Poêle à bois"), ("wood stove", "Poêle à bois"),93 ("bbq", "BBQ"), ("barbecue", "BBQ"),94 ("wifi", "Wi-Fi"), ("wi-fi", "Wi-Fi"), ("internet", "Wi-Fi"),95 ("lave-vaisselle", "Lave-vaisselle"), ("dishwasher", "Lave-vaisselle"),96 ("laveuse", "Laveuse/sécheuse"), ("washer", "Laveuse/sécheuse"),97 ("climatis", "Air climatisé"), ("air conditioning", "Air climatisé"),98 ("kayak", "Kayak"), ("canot", "Canot"), ("canoe", "Canot"),99 ("stationnement", "Stationnement"), ("parking", "Stationnement"),100 ("bord de l'eau", "Bord de l'eau"), ("bord du lac", "Bord de l'eau"),101 ("waterfront", "Bord de l'eau"), ("lakefront", "Bord de l'eau"),102 ("plage", "Plage à proximité"), ("beach", "Plage à proximité"),103]104105106def _amenities_texte(texte: str) -> list[str]:107 hay = strip_accents(texte or "").lower().replace("’", "'")108 out: list[str] = []109 for needle, label in _AMEN_HINTS:110 if needle in hay and label not in out:111 out.append(label)112 return out113114_TYPE_HINTS = [115 ("chalet", "Chalet"), ("cottage", "Chalet"), ("cabin", "Chalet"),116 ("chaumière", "Chalet"), ("condo", "Condo"), ("appartement", "Appartement"),117 ("apartment", "Appartement"), ("loft", "Loft"), ("studio", "Studio"),118 ("maison", "Maison"), ("house", "Maison"), ("gîte", "Gîte"),119 ("gite", "Gîte"), ("auberge", "Auberge"), ("yourte", "Yourte"),120 ("yurt", "Yourte"), ("dôme", "Dôme"), ("dome", "Dôme"),121 ("chambre", "Chambre"), ("room", "Chambre"), ("camping", "Camping"),122 ("roulotte", "Prêt-à-camper"), ("trailer", "Prêt-à-camper"),123]124125126def _num(texts: list[str]) -> float | None:127 """Premier nombre d'une liste de valeurs Kijiji (« 2 bedrooms and den »)."""128 for t in texts or []:129 m = re.search(r"(\d+(?:[.,]5)?)", str(t))130 if m:131 return float(m.group(1).replace(",", "."))132 return None133134135def _sdb(attrs: dict) -> float | None:136 """Salles de bain : la valeur canonique Kijiji est en dixièmes137 (« 20 » = 2, « 25 » = 2,5) ; la valeur humaine (« 2 bathrooms ») est138 déjà correcte."""139 v = _num(attrs.get("numberbathrooms"))140 if v is not None and v >= 10 and v % 5 == 0:141 v /= 10142 return v143144145def _attrs(entity: dict) -> dict[str, list[str]]:146 """{canonicalName: values (humaines si présentes, sinon canoniques)}."""147 out: dict[str, list[str]] = {}148 for a in ((entity.get("attributes") or {}).get("all") or []):149 name = (a or {}).get("canonicalName") or ""150 vals = a.get("values") or a.get("canonicalValues") or []151 if name:152 out[name] = [str(v) for v in vals]153 return out154155156class KijijiCt(StConnector):157 source_id = "kijiji_ct"158 request_delay = 0.5159160 # -- fetch HTML (anti-bot) ----------------------------------------------161 def _brightdata(self, url: str) -> str:162 key = os.environ.get("BRIGHTDATA_API_KEY")163 if not key:164 return ""165 wait = self.request_delay - (time.time() - self._last_request)166 if wait > 0:167 time.sleep(wait)168 try:169 resp = requests.post(170 BRIGHTDATA_API,171 headers={"Authorization": f"Bearer {key}",172 "Content-Type": "application/json"},173 json={"zone": os.environ.get("BRIGHTDATA_ZONE", "web_unlocker1"),174 "url": url, "format": "raw"},175 timeout=150)176 except requests.RequestException:177 return ""178 finally:179 self._last_request = time.time()180 return resp.text if resp.status_code == 200 else ""181182 def _html(self, url: str) -> str:183 html = self._brightdata(url)184 if "__NEXT_DATA__" in html:185 return html186 return self.get_scrapfly(url, render_js=False, asp=True)187188 # -- parse APOLLO_STATE ---------------------------------------------------189 @staticmethod190 def _apollo(html: str) -> dict:191 m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>',192 html, re.S)193 if not m:194 return {}195 try:196 data = json.loads(m.group(1))197 except ValueError:198 return {}199 return (data.get("props") or {}).get("pageProps", {}) \200 .get("__APOLLO_STATE__") or {}201202 @staticmethod203 def _search_page(apollo: dict) -> tuple[list[dict], int]:204 """(entités StandardListing de la page, totalCount)."""205 root = apollo.get("ROOT_QUERY") or {}206 for key, srp in root.items():207 if not key.startswith("searchResultsPageByUrl"):208 continue209 res = (srp or {}).get("results") or {}210 total = ((srp or {}).get("pagination") or {}).get("totalCount") or 0211 refs: list[str] = []212 for rk, rv in res.items():213 if rk.startswith(("mainListings", "topListings")) \214 and isinstance(rv, list):215 refs.extend(x.get("__ref") for x in rv216 if isinstance(x, dict) and x.get("__ref"))217 return [apollo[r] for r in refs if r in apollo], int(total)218 return [], 0219220 # -- détail ----------------------------------------------------------------221 def _detail(self, url: str, eid: str) -> dict:222 apollo = self._apollo(self._html(url))223 e = apollo.get(f"StandardListing:{eid}") or {}224 if not e:225 return {}226 attrs = _attrs(e)227 loc = e.get("location") or {}228 coords = loc.get("coordinates") or {}229 return {230 "description": (e.get("description") or "")[:5000],231 "images": [u for u in (e.get("imageUrls") or [])232 if isinstance(u, str) and u.startswith("https://")][:20],233 "address": loc.get("address") or "",234 "lat": coords.get("latitude"),235 "lng": coords.get("longitude"),236 "attrs": attrs,237 "region": (attrs.get("city") or [""])[0], # région touristique QC238 }239240 # -- contrat ------------------------------------------------------------241 def fetch(self) -> list[StListing]:242 limit = int(os.environ.get("LOUKA_KIJIJI_LIMIT", "0") or 0)243244 entities: list[dict] = []245 vus: set[str] = set()246 page, total = 1, None247 while page <= 10:248 seg = "" if page == 1 else f"page-{page}/"249 ents, tot = self._search_page(self._apollo(250 self._html(LISTE.format(page=seg))))251 if not ents:252 break253 total = tot or total254 nouveaux = 0255 for e in ents:256 eid = str(e.get("id") or "")257 if eid and eid not in vus:258 vus.add(eid)259 entities.append(e)260 nouveaux += 1261 if nouveaux == 0 or (total and len(vus) >= total):262 break263 if limit and len(entities) >= limit * 3: # marge pour les filtres264 break265 page += 1266267 listings: list[StListing] = []268 for e in entities:269 eid = str(e.get("id") or "")270 url = e.get("url") or ""271 title = (e.get("title") or "").strip()272 if not eid or not url or not title:273 continue274 if (e.get("type") or "OFFER") != "OFFER":275 continue276 attrs = _attrs(e)277 hay = title.lower()278 if not (_ATTRS_HEBERGEMENT & set(attrs)) \279 and not any(n in hay for n, _ in _TYPE_HINTS):280 continue # maillots de bain, cafetières, vans…281 texte = f"{title}\n{e.get('description') or ''}"282 if _MENSUEL_RE.search(texte):283 continue # location au mois : hors mandat284285 key = json.dumps([title, e.get("imageCount"),286 (e.get("price") or {}).get("amount")],287 ensure_ascii=False)288 try:289 det = self.detail(eid, key,290 lambda u=url, i=eid: self._detail(u, i))291 except Exception: # une fiche cassée ≠ annonce perdue292 det = {}293 if det.get("attrs"):294 attrs = det["attrs"]295 if not (_ATTRS_HEBERGEMENT & set(attrs)):296 continue # le détail confirme : pas un hébergement297 texte = (f"{title}\n"298 f"{det.get('description') or e.get('description') or ''}")299 if _MENSUEL_RE.search(texte):300 continue301 nuits_min = _num(attrs.get("minnights")) or 0302 if nuits_min >= 28:303 continue # séjour min d'un mois : hors mandat304305 # prix : « X $/nuit » du texte (minimum des saisons) prime ;306 # sinon le montant affiché (en cents) est un prix à la nuit307 # (convention de la catégorie) s'il est plausible308 price_night = None309 price_label = ""310 amount = (e.get("price") or {}).get("amount")311 montant = round(amount / 100, 2) if isinstance(312 amount, (int, float)) and amount else None313 nuit_val = _prix_min(texte, _NUIT_RE)314 sem_val = _prix_min(texte, _SEMAINE_RE)315 if nuit_val:316 price_night = nuit_val317 price_label = f"{nuit_val:g} $ / nuit"318 elif sem_val:319 price_night = round(sem_val / 7, 2)320 price_label = f"{sem_val:g} $ / semaine"321 elif montant and montant <= 2000:322 price_night = montant323 price_label = f"{montant:g} $"324325 hay = title.lower()326 ptype = next((canon for needle, canon in _TYPE_HINTS327 if needle in hay), "")328329 pets = None330 if attrs.get("petsallowed"):331 v = attrs["petsallowed"][0].lower()332 pets = "oui" if v in ("1", "yes", "oui") else "non"333334 address = det.get("address") or (e.get("location") or {}).get(335 "address") or ""336 # « 60 Rue Quaile, Otter Lake, QC J0X 2P0 » → ville = Otter Lake337 m = re.search(r"([^,]+),\s*(?:QC|Qu[ée]bec)\b", address)338 city = m.group(1).strip() if m else ""339 coords = ((e.get("location") or {}).get("coordinates") or {})340 lat = det.get("lat") if det.get("lat") is not None \341 else coords.get("latitude")342 lng = det.get("lng") if det.get("lng") is not None \343 else coords.get("longitude")344345 # région : attribut « city » de Kijiji (souvent la région346 # touristique), sinon le point GPS (centroïde le plus proche)347 region = normalize_region(det.get("region") or "")348 if region not in REGIONS:349 region = _region_from_latlng(lat, lng)350351 details = {k: v for k, v in {352 "prix_affiche": montant if price_night is None else None,353 "min_nights": (attrs.get("minnights") or [None])[0],354 "vacation_type": (attrs.get("vacationtype") or [None])[0],355 "disponible_du": (attrs.get("availabilitystartdate")356 or [None])[0],357 "disponible_au": (attrs.get("availabilityenddate")358 or [None])[0],359 }.items() if v}360361 listings.append(StListing(362 source=self.source_id,363 external_id=eid,364 url=url,365 title=title,366 property_type=ptype,367 address=address,368 city=city,369 region=region,370 price_night=price_night,371 price_label=price_label,372 capacity=_num(attrs.get("maxpeople")),373 bedrooms=_num(attrs.get("numberbedrooms")),374 bathrooms=_sdb(attrs),375 pets=pets,376 description=det.get("description") or "",377 amenities=_amenities_texte(texte),378 details=details,379 images=det.get("images")380 or [u for u in (e.get("imageUrls") or [])381 if isinstance(u, str)][:20],382 lat=lat,383 lng=lng,384 ))385 if limit and len(listings) >= limit:386 break387 return listings388