# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces, catégorie # « Locations de vacances » à destination du Québec (c814 : les annonces y # sont classées par province de LA PROPRIÉTÉ, pas de l'annonceur — la # recherche « quebec/c800 » retournait des condos en Floride affichés # depuis Québec). # # Méthode (Kijiji est un Next.js derrière un anti-bot : HTML via Bright Data # Web Unlocker, Scrapfly ASP en secours — même recette que le connecteur # Airbnb) : # 1. LISTE : /b-vacation-rentals-quebec/canada/c814l0 (+ /page-N/) — # __NEXT_DATA__ → __APOLLO_STATE__ → searchResultsPageByUrl → results # (topListings + mainListings, ~40/page, totalCount ≈ 75). Chaque entité # StandardListing donne titre, prix (cents), photos, attributs canoniques. # 2. DÉTAIL (cache self.detail) : la page /v-…/ embarque le même # APOLLO_STATE avec en plus la description complète, les coordonnées # GPS, toutes les photos et les attributs en clair (« 2 bedrooms and # den », région touristique dans l'attribut « city », animaux…). # # Filtres court terme : on ne garde que les annonces OFFER qui ressemblent à # un hébergement (attributs chambres/personnes/type de vacances présents — # la catégorie contient aussi maillots de bain, machines à espresso… ; si # les attributs manquent sur la liste mais que le titre évoque un # hébergement, la fiche détail tranche) et on écarte les locations au mois # (« 31 jours et plus », monthly, minnights >= 28…). # # Prix : le formulaire de la catégorie demande un prix À LA NUIT — un texte # « X $/nuit » ou « $X/night » dans l'annonce prime (minimum des saisons), # sinon le montant affiché est pris comme prix/nuit s'il est plausible # (<= 2 000 $ et séjour min < 28 nuits), sinon details.prix_affiche. # Salles de bain : la valeur canonique Kijiji est en dixièmes (« 20 » = 2) — # normalisée. Commodités : aucune dans les attributs de la catégorie — on # les dérive des mentions explicites du texte (spa, sauna, foyer, BBQ…). # # Réglage env : LOUKA_KIJIJI_LIMIT (nb max d'annonces, pour tester petit). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time import requests from ...normalize import strip_accents from ..schema import StListing, normalize_region, parse_price_night, REGIONS from .airbnb import _region_from_latlng from .base import StConnector BRIGHTDATA_API = "https://api.brightdata.com/request" BASE = "https://www.kijiji.ca" LISTE = BASE + "/b-vacation-rentals-quebec/canada/{page}c814l0" # attributs canoniques qui signent un vrai hébergement _ATTRS_HEBERGEMENT = {"numberbedrooms", "maxpeople", "vacationtype", "numberbathrooms", "minnights"} # location au mois (ou plus) : hors mandat court terme _MENSUEL_RE = re.compile( r"au mois|par mois|/\s*mois|mensuel|monthly|per\s+month|/\s*month" r"|3[01]\s*jours\s*(?:et plus|minimum|min)|month(?:ly)?\s+rental", re.I) # « 265 $ / nuit » (fr) comme « $265/night » (en) — $ avant ou après le montant _NUIT_RE = re.compile( r"(?:\$\s*(\d[\d\s,.]{0,8}\d|\d)|(\d[\d\s,.]{0,8}\d|\d)\s*\$)\s*" r"(?:/|par|la|per)?\s*(?:nuit|night)", re.I) _SEMAINE_RE = re.compile( r"(?:\$\s*(\d[\d\s,.]{0,8}\d|\d)|(\d[\d\s,.]{0,8}\d|\d)\s*\$)\s*" r"(?:/|par|la|per)?\s*(?:sem(?:aine)?|week)", re.I) def _prix_min(texte: str, rx: re.Pattern) -> float | None: """Le plus bas des montants d'une période (les annonces listent souvent plusieurs saisons : « $265/night … $298/night »).""" vals = [] for m in rx.finditer(texte or ""): raw = (m.group(1) or m.group(2) or "").strip() v = parse_price_night(f"{raw} $") if v: vals.append(v) return min(vals) if vals else None # mentions explicites du texte → commodité affichable (la catégorie Kijiji # n'a aucun attribut de commodités) ; clés en minuscules sans accents _AMEN_HINTS = [ ("spa", "Spa"), ("jacuzzi", "Spa"), ("hot tub", "Spa"), ("sauna", "Sauna"), ("piscine", "Piscine"), ("pool", "Piscine"), ("foyer", "Foyer"), ("fireplace", "Foyer"), ("poele a bois", "Poêle à bois"), ("wood stove", "Poêle à bois"), ("bbq", "BBQ"), ("barbecue", "BBQ"), ("wifi", "Wi-Fi"), ("wi-fi", "Wi-Fi"), ("internet", "Wi-Fi"), ("lave-vaisselle", "Lave-vaisselle"), ("dishwasher", "Lave-vaisselle"), ("laveuse", "Laveuse/sécheuse"), ("washer", "Laveuse/sécheuse"), ("climatis", "Air climatisé"), ("air conditioning", "Air climatisé"), ("kayak", "Kayak"), ("canot", "Canot"), ("canoe", "Canot"), ("stationnement", "Stationnement"), ("parking", "Stationnement"), ("bord de l'eau", "Bord de l'eau"), ("bord du lac", "Bord de l'eau"), ("waterfront", "Bord de l'eau"), ("lakefront", "Bord de l'eau"), ("plage", "Plage à proximité"), ("beach", "Plage à proximité"), ] def _amenities_texte(texte: str) -> list[str]: hay = strip_accents(texte or "").lower().replace("’", "'") out: list[str] = [] for needle, label in _AMEN_HINTS: if needle in hay and label not in out: out.append(label) return out _TYPE_HINTS = [ ("chalet", "Chalet"), ("cottage", "Chalet"), ("cabin", "Chalet"), ("chaumière", "Chalet"), ("condo", "Condo"), ("appartement", "Appartement"), ("apartment", "Appartement"), ("loft", "Loft"), ("studio", "Studio"), ("maison", "Maison"), ("house", "Maison"), ("gîte", "Gîte"), ("gite", "Gîte"), ("auberge", "Auberge"), ("yourte", "Yourte"), ("yurt", "Yourte"), ("dôme", "Dôme"), ("dome", "Dôme"), ("chambre", "Chambre"), ("room", "Chambre"), ("camping", "Camping"), ("roulotte", "Prêt-à-camper"), ("trailer", "Prêt-à-camper"), ] def _num(texts: list[str]) -> float | None: """Premier nombre d'une liste de valeurs Kijiji (« 2 bedrooms and den »).""" for t in texts or []: m = re.search(r"(\d+(?:[.,]5)?)", str(t)) if m: return float(m.group(1).replace(",", ".")) return None def _sdb(attrs: dict) -> float | None: """Salles de bain : la valeur canonique Kijiji est en dixièmes (« 20 » = 2, « 25 » = 2,5) ; la valeur humaine (« 2 bathrooms ») est déjà correcte.""" v = _num(attrs.get("numberbathrooms")) if v is not None and v >= 10 and v % 5 == 0: v /= 10 return v def _attrs(entity: dict) -> dict[str, list[str]]: """{canonicalName: values (humaines si présentes, sinon canoniques)}.""" out: dict[str, list[str]] = {} for a in ((entity.get("attributes") or {}).get("all") or []): name = (a or {}).get("canonicalName") or "" vals = a.get("values") or a.get("canonicalValues") or [] if name: out[name] = [str(v) for v in vals] return out class KijijiCt(StConnector): source_id = "kijiji_ct" request_delay = 0.5 # -- fetch HTML (anti-bot) ---------------------------------------------- def _brightdata(self, url: str) -> str: key = os.environ.get("BRIGHTDATA_API_KEY") if not key: return "" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) try: resp = requests.post( BRIGHTDATA_API, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, json={"zone": os.environ.get("BRIGHTDATA_ZONE", "web_unlocker1"), "url": url, "format": "raw"}, timeout=150) except requests.RequestException: return "" finally: self._last_request = time.time() return resp.text if resp.status_code == 200 else "" def _html(self, url: str) -> str: html = self._brightdata(url) if "__NEXT_DATA__" in html: return html return self.get_scrapfly(url, render_js=False, asp=True) # -- parse APOLLO_STATE --------------------------------------------------- @staticmethod def _apollo(html: str) -> dict: m = re.search(r'', html, re.S) if not m: return {} try: data = json.loads(m.group(1)) except ValueError: return {} return (data.get("props") or {}).get("pageProps", {}) \ .get("__APOLLO_STATE__") or {} @staticmethod def _search_page(apollo: dict) -> tuple[list[dict], int]: """(entités StandardListing de la page, totalCount).""" root = apollo.get("ROOT_QUERY") or {} for key, srp in root.items(): if not key.startswith("searchResultsPageByUrl"): continue res = (srp or {}).get("results") or {} total = ((srp or {}).get("pagination") or {}).get("totalCount") or 0 refs: list[str] = [] for rk, rv in res.items(): if rk.startswith(("mainListings", "topListings")) \ and isinstance(rv, list): refs.extend(x.get("__ref") for x in rv if isinstance(x, dict) and x.get("__ref")) return [apollo[r] for r in refs if r in apollo], int(total) return [], 0 # -- détail ---------------------------------------------------------------- def _detail(self, url: str, eid: str) -> dict: apollo = self._apollo(self._html(url)) e = apollo.get(f"StandardListing:{eid}") or {} if not e: return {} attrs = _attrs(e) loc = e.get("location") or {} coords = loc.get("coordinates") or {} return { "description": (e.get("description") or "")[:5000], "images": [u for u in (e.get("imageUrls") or []) if isinstance(u, str) and u.startswith("https://")][:20], "address": loc.get("address") or "", "lat": coords.get("latitude"), "lng": coords.get("longitude"), "attrs": attrs, "region": (attrs.get("city") or [""])[0], # région touristique QC } # -- contrat ------------------------------------------------------------ def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_KIJIJI_LIMIT", "0") or 0) entities: list[dict] = [] vus: set[str] = set() page, total = 1, None while page <= 10: seg = "" if page == 1 else f"page-{page}/" ents, tot = self._search_page(self._apollo( self._html(LISTE.format(page=seg)))) if not ents: break total = tot or total nouveaux = 0 for e in ents: eid = str(e.get("id") or "") if eid and eid not in vus: vus.add(eid) entities.append(e) nouveaux += 1 if nouveaux == 0 or (total and len(vus) >= total): break if limit and len(entities) >= limit * 3: # marge pour les filtres break page += 1 listings: list[StListing] = [] for e in entities: eid = str(e.get("id") or "") url = e.get("url") or "" title = (e.get("title") or "").strip() if not eid or not url or not title: continue if (e.get("type") or "OFFER") != "OFFER": continue attrs = _attrs(e) hay = title.lower() if not (_ATTRS_HEBERGEMENT & set(attrs)) \ and not any(n in hay for n, _ in _TYPE_HINTS): continue # maillots de bain, cafetières, vans… texte = f"{title}\n{e.get('description') or ''}" if _MENSUEL_RE.search(texte): continue # location au mois : hors mandat key = json.dumps([title, e.get("imageCount"), (e.get("price") or {}).get("amount")], ensure_ascii=False) try: det = self.detail(eid, key, lambda u=url, i=eid: self._detail(u, i)) except Exception: # une fiche cassée ≠ annonce perdue det = {} if det.get("attrs"): attrs = det["attrs"] if not (_ATTRS_HEBERGEMENT & set(attrs)): continue # le détail confirme : pas un hébergement texte = (f"{title}\n" f"{det.get('description') or e.get('description') or ''}") if _MENSUEL_RE.search(texte): continue nuits_min = _num(attrs.get("minnights")) or 0 if nuits_min >= 28: continue # séjour min d'un mois : hors mandat # prix : « X $/nuit » du texte (minimum des saisons) prime ; # sinon le montant affiché (en cents) est un prix à la nuit # (convention de la catégorie) s'il est plausible price_night = None price_label = "" amount = (e.get("price") or {}).get("amount") montant = round(amount / 100, 2) if isinstance( amount, (int, float)) and amount else None nuit_val = _prix_min(texte, _NUIT_RE) sem_val = _prix_min(texte, _SEMAINE_RE) if nuit_val: price_night = nuit_val price_label = f"{nuit_val:g} $ / nuit" elif sem_val: price_night = round(sem_val / 7, 2) price_label = f"{sem_val:g} $ / semaine" elif montant and montant <= 2000: price_night = montant price_label = f"{montant:g} $" hay = title.lower() ptype = next((canon for needle, canon in _TYPE_HINTS if needle in hay), "") pets = None if attrs.get("petsallowed"): v = attrs["petsallowed"][0].lower() pets = "oui" if v in ("1", "yes", "oui") else "non" address = det.get("address") or (e.get("location") or {}).get( "address") or "" # « 60 Rue Quaile, Otter Lake, QC J0X 2P0 » → ville = Otter Lake m = re.search(r"([^,]+),\s*(?:QC|Qu[ée]bec)\b", address) city = m.group(1).strip() if m else "" coords = ((e.get("location") or {}).get("coordinates") or {}) lat = det.get("lat") if det.get("lat") is not None \ else coords.get("latitude") lng = det.get("lng") if det.get("lng") is not None \ else coords.get("longitude") # région : attribut « city » de Kijiji (souvent la région # touristique), sinon le point GPS (centroïde le plus proche) region = normalize_region(det.get("region") or "") if region not in REGIONS: region = _region_from_latlng(lat, lng) details = {k: v for k, v in { "prix_affiche": montant if price_night is None else None, "min_nights": (attrs.get("minnights") or [None])[0], "vacation_type": (attrs.get("vacationtype") or [None])[0], "disponible_du": (attrs.get("availabilitystartdate") or [None])[0], "disponible_au": (attrs.get("availabilityenddate") or [None])[0], }.items() if v} listings.append(StListing( source=self.source_id, external_id=eid, url=url, title=title, property_type=ptype, address=address, city=city, region=region, price_night=price_night, price_label=price_label, capacity=_num(attrs.get("maxpeople")), bedrooms=_num(attrs.get("numberbedrooms")), bathrooms=_sdb(attrs), pets=pets, description=det.get("description") or "", amenities=_amenities_texte(texte), details=details, images=det.get("images") or [u for u in (e.get("imageUrls") or []) if isinstance(u, str)][:20], lat=lat, lng=lng, )) if limit and len(listings) >= limit: break return listings