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/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