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%
8.2 KB · 202 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/corporatestays.py : Corporate Stays (corporatestays.com)4#5# Meublés corporatifs (siège à Montréal), ~450 unités dans les Amériques dont6# ~47 au Québec (Montréal, Québec, Laval). Site WordPress (plugin HomeRunner) ;7# l'API REST WP expose tout, aucun anti-bot :8#   1. TAXONOMIE : GET /wp-json/wp/v2/property-states?per_page=100 →9#      ids des états québécois (slugs « pq » et « qc », les deux coexistent).10#   2. LISTE : GET /wp-json/wp/v2/properties?property-states=<ids>&11#      per_page=100&page=N — chaque fiche est complète : titre, description12#      (content.rendered), acf.{bedrooms, bathrooms, maximum_guests,13#      minimum_stay, geolocation lat/lng/adresse, currency, base_daily_rate},14#      lowest_rent/average_rent ($/nuit CAD), photos (acf.photos[].sizes),15#      taxonomies embarquées cities/types, subtitle = nom de l'immeuble.16#   3. COMMODITÉS : GET /wp-json/wp/v2/property_amenities?include=… pour17#      traduire les ids en libellés (une passe, mise en cache mémoire).18# Séjours de 30 nuits minimum en général, mais le prix affiché est bien un19# tarif À LA NUIT en CAD (base_daily_rate) → price_night renseigné.20#21# Réglage env : LOUKA_CORPORATESTAYS_LIMIT (nb max d'annonces, 0 = tout).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import html as _html26import os27import re2829from ..schema import StListing30from .airbnb import _region_from_latlng31from .base import StConnector3233API = "https://corporatestays.com/wp-json/wp/v2"3435# slugs de la taxonomie property-states considérés québécois36_QC_STATE_SLUGS = {"qc", "pq", "quebec"}3738# ville → (ville affichée, région touristique)39_CITIES = {40    "montreal": ("Montréal", "Montréal"),41    "quebec city": ("Québec", "Québec"),42    "quebec": ("Québec", "Québec"),43    "laval": ("Laval", "Laval"),44    "gatineau": ("Gatineau", "Outaouais"),45}4647_TYPES = {48    "apartment": "Appartement", "condo": "Condo", "studio": "Studio",49    "loft": "Loft", "house": "Maison", "townhouse": "Maison",50    "penthouse": "Appartement", "suite": "Appartement",51}5253_TAG_RE = re.compile(r"<[^>]+>")545556def _num(v) -> float | None:57    try:58        return float(v) if v not in (None, "") else None59    except (TypeError, ValueError):60        return None616263def _strip_html(txt: str) -> str:64    return re.sub(r"\s+", " ", _TAG_RE.sub(" ", _html.unescape(txt or ""))).strip()656667class CorporateStays(StConnector):68    source_id = "corporatestays"69    request_delay = 0.67071    def _get_json(self, url: str):72        return self.get(url, headers={"Accept": "application/json"}).json()7374    # -- taxonomies -----------------------------------------------------------75    def _qc_state_ids(self) -> list[int]:76        states = self._get_json(77            f"{API}/property-states?per_page=100&_fields=id,slug,count")78        return [s["id"] for s in states79                if (s.get("slug") or "").lower() in _QC_STATE_SLUGS80                and s.get("count")]8182    def _amenity_names(self, ids: set[int]) -> dict[int, str]:83        names: dict[int, str] = {}84        todo = sorted(ids)85        for i in range(0, len(todo), 100):86            chunk = ",".join(str(x) for x in todo[i:i + 100])87            try:88                for a in self._get_json(f"{API}/property_amenities?per_page=100"89                                        f"&_fields=id,name&include={chunk}"):90                    names[a["id"]] = _html.unescape(a.get("name") or "").strip()91            except Exception:  # noqa: BLE001 — libellés manquants ≠ blocage92                break93        return names9495    # -- liste ----------------------------------------------------------------96    def _all_items(self, state_ids: list[int]) -> list[dict]:97        items: list[dict] = []98        states = ",".join(str(i) for i in state_ids)99        page = 1100        while page <= 20:101            batch = self._get_json(f"{API}/properties?property-states={states}"102                                   f"&per_page=100&page={page}")103            if not isinstance(batch, list) or not batch:104                break105            items.extend(batch)106            if len(batch) < 100:107                break108            page += 1109        return items110111    # -- contrat --------------------------------------------------------------112    def fetch(self) -> list[StListing]:113        limit = int(os.environ.get("LOUKA_CORPORATESTAYS_LIMIT", "0") or 0)114        state_ids = self._qc_state_ids()115        if not state_ids:116            return []117        items = self._all_items(state_ids)118        if limit:119            items = items[:limit]120121        amen_ids = {a for it in items for a in (it.get("property_amenities")122                                                or []) if isinstance(a, int)}123        amen_names = self._amenity_names(amen_ids) if amen_ids else {}124125        listings: list[StListing] = []126        seen: set[str] = set()127        for it in items:128            pid = str(it.get("id") or "").strip()129            url = (it.get("link") or "").strip()130            title = _strip_html((it.get("title") or {}).get("rendered") or "")131            if not pid or pid in seen or not url or not title:132                continue133            seen.add(pid)134135            acf = it.get("acf") or {}136            geo = acf.get("geolocation") or {}137            cities = it.get("cities") or []138            raw_city = (cities[0].get("name") or "").strip() if cities else ""139            city, region = _CITIES.get(raw_city.lower(), (raw_city, ""))140            lat, lng = _num(geo.get("lat")), _num(geo.get("lng"))141            if not region:142                region = _region_from_latlng(lat, lng)143144            types = it.get("types") or []145            raw_type = (types[0].get("name") or "").strip() if types else ""146            ptype = _TYPES.get(raw_type.lower(), raw_type)147148            # prix à la nuit (CAD) : tarif de base, sinon plus bas tarif affiché149            price = None150            if (acf.get("currency") or "CAD").upper() == "CAD":151                price = _num(acf.get("base_daily_rate")) \152                    or _num(it.get("lowest_rent"))153            price_label = (f"À partir de {price:g} $/nuit"154                           if price is not None else "")155156            images = []157            for ph in acf.get("photos") or []:158                sizes = (ph or {}).get("sizes") or {}159                u = (sizes.get("large") or sizes.get("homelocal-medium")160                     or sizes.get("medium") or "")161                if u.startswith("https://") and u not in images:162                    images.append(u)163                if len(images) >= 15:164                    break165166            amenities = [amen_names[a] for a in (it.get("property_amenities")167                                                 or []) if amen_names.get(a)]168            rating = _num(it.get("average_rating"))169            reviews = it.get("total_reviews")170            details = {k: v for k, v in {171                "building": _strip_html(it.get("subtitle") or ""),172                "min_stay": _num(acf.get("minimum_stay")),173                "avg_rate_night": _num(it.get("average_rent")),174                "corporate": True,175            }.items() if v not in (None, "", 0)}176177            listings.append(StListing(178                source=self.source_id,179                external_id=pid,180                url=url,181                title=title,182                property_type=ptype,183                address=(geo.get("address") or "").strip(),184                city=city,185                region=region,186                price_night=price,187                price_label=price_label,188                capacity=_num(acf.get("maximum_guests")),189                bedrooms=_num(acf.get("bedrooms")),190                bathrooms=_num(acf.get("bathrooms")),191                rating=rating if rating else None,192                reviews=int(reviews) if reviews else None,193                description=_strip_html((it.get("content") or {})194                                        .get("rendered") or "")[:3000],195                amenities=amenities,196                details=details,197                images=images,198                lat=lat,199                lng=lng,200            ).finalize())201        return listings202