SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
16.5 KB · 388 lines python
Raw Blame History
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