# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/corporatestays.py : Corporate Stays (corporatestays.com) # # Meublés corporatifs (siège à Montréal), ~450 unités dans les Amériques dont # ~47 au Québec (Montréal, Québec, Laval). Site WordPress (plugin HomeRunner) ; # l'API REST WP expose tout, aucun anti-bot : # 1. TAXONOMIE : GET /wp-json/wp/v2/property-states?per_page=100 → # ids des états québécois (slugs « pq » et « qc », les deux coexistent). # 2. LISTE : GET /wp-json/wp/v2/properties?property-states=& # per_page=100&page=N — chaque fiche est complète : titre, description # (content.rendered), acf.{bedrooms, bathrooms, maximum_guests, # minimum_stay, geolocation lat/lng/adresse, currency, base_daily_rate}, # lowest_rent/average_rent ($/nuit CAD), photos (acf.photos[].sizes), # taxonomies embarquées cities/types, subtitle = nom de l'immeuble. # 3. COMMODITÉS : GET /wp-json/wp/v2/property_amenities?include=… pour # traduire les ids en libellés (une passe, mise en cache mémoire). # Séjours de 30 nuits minimum en général, mais le prix affiché est bien un # tarif À LA NUIT en CAD (base_daily_rate) → price_night renseigné. # # Réglage env : LOUKA_CORPORATESTAYS_LIMIT (nb max d'annonces, 0 = tout). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from ..schema import StListing from .airbnb import _region_from_latlng from .base import StConnector API = "https://corporatestays.com/wp-json/wp/v2" # slugs de la taxonomie property-states considérés québécois _QC_STATE_SLUGS = {"qc", "pq", "quebec"} # ville → (ville affichée, région touristique) _CITIES = { "montreal": ("Montréal", "Montréal"), "quebec city": ("Québec", "Québec"), "quebec": ("Québec", "Québec"), "laval": ("Laval", "Laval"), "gatineau": ("Gatineau", "Outaouais"), } _TYPES = { "apartment": "Appartement", "condo": "Condo", "studio": "Studio", "loft": "Loft", "house": "Maison", "townhouse": "Maison", "penthouse": "Appartement", "suite": "Appartement", } _TAG_RE = re.compile(r"<[^>]+>") def _num(v) -> float | None: try: return float(v) if v not in (None, "") else None except (TypeError, ValueError): return None def _strip_html(txt: str) -> str: return re.sub(r"\s+", " ", _TAG_RE.sub(" ", _html.unescape(txt or ""))).strip() class CorporateStays(StConnector): source_id = "corporatestays" request_delay = 0.6 def _get_json(self, url: str): return self.get(url, headers={"Accept": "application/json"}).json() # -- taxonomies ----------------------------------------------------------- def _qc_state_ids(self) -> list[int]: states = self._get_json( f"{API}/property-states?per_page=100&_fields=id,slug,count") return [s["id"] for s in states if (s.get("slug") or "").lower() in _QC_STATE_SLUGS and s.get("count")] def _amenity_names(self, ids: set[int]) -> dict[int, str]: names: dict[int, str] = {} todo = sorted(ids) for i in range(0, len(todo), 100): chunk = ",".join(str(x) for x in todo[i:i + 100]) try: for a in self._get_json(f"{API}/property_amenities?per_page=100" f"&_fields=id,name&include={chunk}"): names[a["id"]] = _html.unescape(a.get("name") or "").strip() except Exception: # noqa: BLE001 — libellés manquants ≠ blocage break return names # -- liste ---------------------------------------------------------------- def _all_items(self, state_ids: list[int]) -> list[dict]: items: list[dict] = [] states = ",".join(str(i) for i in state_ids) page = 1 while page <= 20: batch = self._get_json(f"{API}/properties?property-states={states}" f"&per_page=100&page={page}") if not isinstance(batch, list) or not batch: break items.extend(batch) if len(batch) < 100: break page += 1 return items # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_CORPORATESTAYS_LIMIT", "0") or 0) state_ids = self._qc_state_ids() if not state_ids: return [] items = self._all_items(state_ids) if limit: items = items[:limit] amen_ids = {a for it in items for a in (it.get("property_amenities") or []) if isinstance(a, int)} amen_names = self._amenity_names(amen_ids) if amen_ids else {} listings: list[StListing] = [] seen: set[str] = set() for it in items: pid = str(it.get("id") or "").strip() url = (it.get("link") or "").strip() title = _strip_html((it.get("title") or {}).get("rendered") or "") if not pid or pid in seen or not url or not title: continue seen.add(pid) acf = it.get("acf") or {} geo = acf.get("geolocation") or {} cities = it.get("cities") or [] raw_city = (cities[0].get("name") or "").strip() if cities else "" city, region = _CITIES.get(raw_city.lower(), (raw_city, "")) lat, lng = _num(geo.get("lat")), _num(geo.get("lng")) if not region: region = _region_from_latlng(lat, lng) types = it.get("types") or [] raw_type = (types[0].get("name") or "").strip() if types else "" ptype = _TYPES.get(raw_type.lower(), raw_type) # prix à la nuit (CAD) : tarif de base, sinon plus bas tarif affiché price = None if (acf.get("currency") or "CAD").upper() == "CAD": price = _num(acf.get("base_daily_rate")) \ or _num(it.get("lowest_rent")) price_label = (f"À partir de {price:g} $/nuit" if price is not None else "") images = [] for ph in acf.get("photos") or []: sizes = (ph or {}).get("sizes") or {} u = (sizes.get("large") or sizes.get("homelocal-medium") or sizes.get("medium") or "") if u.startswith("https://") and u not in images: images.append(u) if len(images) >= 15: break amenities = [amen_names[a] for a in (it.get("property_amenities") or []) if amen_names.get(a)] rating = _num(it.get("average_rating")) reviews = it.get("total_reviews") details = {k: v for k, v in { "building": _strip_html(it.get("subtitle") or ""), "min_stay": _num(acf.get("minimum_stay")), "avg_rate_night": _num(it.get("average_rent")), "corporate": True, }.items() if v not in (None, "", 0)} listings.append(StListing( source=self.source_id, external_id=pid, url=url, title=title, property_type=ptype, address=(geo.get("address") or "").strip(), city=city, region=region, price_night=price, price_label=price_label, capacity=_num(acf.get("maximum_guests")), bedrooms=_num(acf.get("bedrooms")), bathrooms=_num(acf.get("bathrooms")), rating=rating if rating else None, reviews=int(reviews) if reviews else None, description=_strip_html((it.get("content") or {}) .get("rendered") or "")[:3000], amenities=amenities, details=details, images=images, lat=lat, lng=lng, ).finalize()) return listings