spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/brivia_1sp.py : connecteur 1 Square Phillips (Groupe Brivia)5# (1squarephillips.ca/locatif — tour locative au centre-ville de Montréal,6# 1205 rue du Square-Phillips, Ville-Marie). La page /locatif présente les7# trois typologies offertes (Studio / 1 Chambre / 2 Chambres) avec loyer8# "à partir de" -> une annonce par typologie (uid stables). L'inventaire9# unité par unité est exposé par l'API AJAX des plans (2022/php/10# ajax_load_unit_selector/_floor/_unit.php, phase=rental) : on y lit les11# unités réellement disponibles (« onsale »), leur étage, leur superficie12# (pi²) et le plan — utilisés pour enrichir chaque typologie.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re17import time1819from bs4 import BeautifulSoup2021from ..schema import Listing, parse_price22from .base import BaseConnector2324BASE = "https://www.1squarephillips.ca"25LOCATIF_URL = f"{BASE}/locatif"26GALERIE_URL = f"{BASE}/galerie"27PLANS_AJAX = f"{BASE}/2022/php/"2829# Adresse du pied de page (avec code postal)30ADDRESS = "1205, rue du Square-Phillips, Montréal, QC H3B 3C9"3132_TYPE_MAP = {"studio": "Studio", "1 chambre": "3½", "2 chambres": "4½",33 "3 chambres": "5½"}34# Typologie -> data-type de l'API des plans (phase locative)35_PLAN_TYPE = {"studio": 11, "1 chambre": 12, "2 chambres": 13}3637DESCRIPTION = ("Condos locatifs de luxe au centre-ville de Montréal, formule "38 "tout inclus : électroménagers, climatisation, chauffage, "39 "électricité, eau chaude et Wi-Fi.")4041# Repli si la section « Caractéristiques » du site devenait illisible42AMENITIES = ["Tout inclus (électricité, chauffage, climatisation, eau chaude, "43 "Wi-Fi)", "Électroménagers inclus", "Piscine, sauna et bain "44 "vapeur", "Salles d'entraînement", "Espace de cotravail",45 "Salle de cinéma", "Terrasse", "Gardien 24 h", "Lounge du 21e "46 "étage", "Stationnement souterrain"]4748_ONSALE_RE = re.compile(r'id="unit(\d+)" class="unit onsale"')49_FLOOR_RE = re.compile(r'data-floor="(\d+)"')50_AREA_RE = re.compile(r"Superficie\s*</span><span>([\d\s ,]+)\s*pi", re.I)51_BALCONY_RE = re.compile(r"Balcon\s*</span><span>([\d\s ,]+)\s*pi", re.I)525354def _unit_type(label: str) -> str:55 key = re.sub(r"\s+", " ", (label or "").strip().lower())56 return _TYPE_MAP.get(key, label.strip())575859def _num(txt: str) -> float | None:60 try:61 return float(re.sub(r"[\s ,]", "", txt))62 except (TypeError, ValueError):63 return None646566class Brivia1SPConnector(BaseConnector):67 source_id = "brivia_1sp"68 request_delay = 0.669 max_unit_details = 150 # plafond de fiches unité par sync7071 # -- helpers ---------------------------------------------------------------72 def _post_json(self, path: str, data: dict) -> dict:73 """POST throttlé vers l'API AJAX des plans (réponses JSON)."""74 wait = self.request_delay - (time.time() - self._last_request)75 if wait > 0:76 time.sleep(wait)77 resp = self.session.post(PLANS_AJAX + path, data=data,78 timeout=self.timeout)79 self._last_request = time.time()80 resp.raise_for_status()81 return resp.json()8283 def _fetch_unit(self, unit: str) -> dict:84 """Fiche d'une unité (type, étage, superficie, plan) via l'API."""85 d = self._post_json("ajax_load_plans_unit.php",86 {"lang": "fr", "phase": "rental", "unit": unit})87 html = d.get("unit_details") or ""88 out: dict = {"unit": unit, "type": int(d.get("type") or 0),89 "floor": int(d.get("floor") or 0)}90 m = _AREA_RE.search(html)91 if m:92 out["area_sqft"] = _num(m.group(1))93 m = _BALCONY_RE.search(html)94 if m:95 out["balcony_sqft"] = _num(m.group(1))96 # « 1 chambre / 1 salle de bain »97 m = re.search(r'<p class="uppercase">([^<]+)</p>', html)98 if m:99 out["rooms"] = m.group(1).strip()100 m = re.search(r"<h3>\s*Plan\s*([^<]+)</h3>", html)101 if m:102 out["plan"] = m.group(1).strip()103 m = re.search(r'<img src="([^"]+)"', d.get("unit_plan") or "")104 if m:105 out["plan_img"] = m.group(1)106 return out107108 def _rental_units(self) -> dict[int, list[dict]]:109 """Unités disponibles (« onsale ») par type (11/12/13), via l'API."""110 sel = self._post_json("ajax_load_unit_selector.php",111 {"lang": "fr", "phase": "rental"})112 floors = sorted({int(f) for f in113 _FLOOR_RE.findall(sel.get("unit_selector") or "")})114 onsale: list[str] = []115 for f in floors[:40]:116 d = self._post_json("ajax_load_plans_floor.php",117 {"lang": "fr", "phase": "rental",118 "type": 11, "floor": f})119 onsale.extend(_ONSALE_RE.findall(d.get("floor") or ""))120 by_type: dict[int, list[dict]] = {}121 for u in onsale[: self.max_unit_details]:122 # la fiche d'une unité (plan) est immuable -> clé de cache fixe123 info = self.detail(f"unit-{u}", "plan-v1",124 lambda u=u: self._fetch_unit(u))125 if info.get("unit"):126 by_type.setdefault(int(info.get("type") or 0), []).append(info)127 return by_type128129 # -- fetch -----------------------------------------------------------------130 def fetch(self) -> list[Listing]:131 listings: list[Listing] = []132 try:133 html = self.get(LOCATIF_URL).text134 except Exception:135 return listings136 soup = BeautifulSoup(html, "html.parser")137138 # Photos : perspectives de la page locatif + galerie du site139 images = self._collect_images(html)140 try:141 images += self._collect_images(self.get(GALERIE_URL).text)142 except Exception:143 pass144 images = list(dict.fromkeys(images))[:30]145146 # Caractéristiques réelles de l'immeuble (section .features)147 amenities = self._collect_features(soup) or list(AMENITIES)148149 # Contact structuré (liens tel:/mailto: du pied de page)150 contact: dict = {}151 tel = soup.select_one('a[href^="tel:"]')152 if tel:153 digits = re.sub(r"\D", "", tel["href"])[-10:]154 if len(digits) == 10:155 contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"156 mail = soup.select_one('a[href^="mailto:"]')157 if mail:158 contact["email"] = mail["href"].removeprefix("mailto:").strip()159160 # Inventaire unité par unité (API AJAX des plans, phase locative)161 inventory: dict[int, list[dict]] = {}162 try:163 inventory = self._rental_units()164 except Exception:165 pass166167 # Typologies (ul.grid3cols : h4 = type, p = "à partir de X $/mois")168 for li in soup.select("ul.grid3cols li"):169 try:170 h4 = li.select_one("h4")171 p = li.select_one("p")172 if not h4 or not p:173 continue174 typology = h4.get_text(" ", strip=True).replace("\xa0", " ")175 price_label = re.sub(r"\s+", " ",176 p.get_text(" ", strip=True))177 if "$" not in price_label:178 continue179 type_slug = re.sub(r"[^a-z0-9]+", "-",180 typology.lower()).strip("-")181182 # Enrichissement avec les unités disponibles de la typologie183 tkey = re.sub(r"\s+", " ", typology.strip().lower())184 units = sorted(inventory.get(_PLAN_TYPE.get(tkey, -1), []),185 key=lambda u: u["unit"])186 availability = "Disponible (tour locative en location)"187 description = DESCRIPTION188 area = None189 unit_images: list[str] = []190 if units:191 n = len(units)192 availability = (f"{n} unité{'s' if n > 1 else ''} "193 f"disponible{'s' if n > 1 else ''}")194 areas = [u["area_sqft"] for u in units195 if u.get("area_sqft")]196 area = min(areas) if areas else None197 # nota : pas de mention « étage N » ici, sinon la198 # normalisation centrale déduirait un faux details.floor199 dispo = ", ".join(200 f"unité {u['unit']}"201 + (f" ({u['area_sqft']:.0f} pi²"202 + (f" + balcon {u['balcony_sqft']:.0f} pi²"203 if u.get("balcony_sqft") else "") + ")"204 if u.get("area_sqft") else "")205 for u in units)206 description = f"{DESCRIPTION} Unités disponibles : {dispo}."207 unit_images = [u["plan_img"] for u in units208 if u.get("plan_img")]209210 details: dict = {}211 if contact:212 details["contact"] = dict(contact)213214 listings.append(Listing(215 source=self.source_id,216 external_id=f"1sp-{type_slug}",217 url=LOCATIF_URL,218 title=f"1 Square Phillips — {typology} locatif",219 address=ADDRESS,220 sector="Centre-ville (Ville-Marie)",221 city="Montréal",222 unit_type=_unit_type(typology),223 price=parse_price(price_label),224 price_label=price_label,225 availability=availability,226 area_sqft=area,227 description=description,228 amenities=amenities,229 details=details,230 images=list(dict.fromkeys(unit_images + images))[:40],231 ))232 except Exception:233 continue234 return listings235236 @staticmethod237 def _collect_features(soup: BeautifulSoup) -> list[str]:238 """Caractéristiques de l'immeuble (section .features, div.back)."""239 out: list[str] = []240 for el in soup.select("section.features li div.back"):241 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))242 if t and t not in out:243 out.append(t)244 if out:245 # inclusions énoncées dans l'intro de la page locatif246 out.insert(0, "Tout inclus (électricité, chauffage, climatisation, "247 "eau chaude, Wi-Fi)")248 out.insert(1, "Électroménagers inclus")249 return out[:40]250251 @staticmethod252 def _collect_images(html: str) -> list[str]:253 """Images pleine taille du site (perspectives + galerie)."""254 urls = re.findall(255 r'(?:https://www\.1squarephillips\.ca)?/?2022/images/'256 r'[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)', html)257 out = []258 for u in urls:259 if not u.startswith("http"):260 u = f"{BASE}/{u.lstrip('/')}"261 # exclure variantes portrait (doublons) et visuels non pertinents262 if re.search(r"-portrait\.|ico-|logo|favicon|bckg-contact|"263 r"bckg-project-(1|4)\b", u, re.I):264 continue265 if re.search(r"gallery|rental|persp|condo", u, re.I):266 out.append(u)267 return list(dict.fromkeys(out))268