# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/quartier_mosaique.py : connecteur Quartier Mosaïque (Constrobourg) # (quartiermosaique.com — 5 complexes : Rubis, Saphir, Opale 1-2, Perle, # secteur Lebourgneuf près des Galeries de la Capitale, Québec). # Page /unites-disponibles/ rendue serveur (WordPress, thème Browsy) : une # carte par plan d'unité disponible (div.cardib.unites-item) avec titre # (« Rubis – 3 ½ »), type (data-type), prix « À partir de X $ par mois » # (cardib-excerpt), description (data-desc, JSON) et galerie complète # (data-gallery, JSON). Aucune page détail nécessaire : tout est sur la liste. # Adresse du quartier publiée sur /contact/ : 2013, boulevard Lebourgneuf. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from urllib.parse import unquote from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://quartiermosaique.com" LIST_URL = f"{BASE}/unites-disponibles/" # Adresse du projet telle que publiée sur https://quartiermosaique.com/contact/ # (les 5 bâtiments partagent le même quadrilatère, aucune adresse par bâtiment # n'est affichée sur le site). PROJECT_ADDRESS = "2013, boulevard Lebourgneuf, Québec, G2K 0N8" def _strip_tags(html: str) -> str: """'

Condos locatifs…

' -> texte brut, espaces normalisés.""" txt = re.sub(r"<[^>]+>", " ", html or "") return re.sub(r"\s+", " ", txt).strip() class QuartierMosaiqueConnector(BaseConnector): source_id = "quartier_mosaique" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} # les cartes sont dupliquées (grille + carrousel swiper) : dédupliquer for card in soup.select("div.cardib"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte « unité disponible » (thème Browsy) ------------------------------ def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('a[href*="/unites/"]') title = " ".join((card.get("data-title") or "").split()) if not link or not title: return url = link["href"] m = re.search(r"/unites/([^/]+)/?", url) if not m: return ext_id = unquote(m.group(1)) # slug WordPress stable (« rubis-3-½ ») if ext_id in listings: return # prix rendu dans la page : « À partir de 1 457 $ par mois » exc = card.select_one(".cardib-excerpt") price_label = exc.get_text(" ", strip=True) if exc else "" # type d'unité structuré (data-type : « 3 ½ », « 3 ½ Bureau », « Loft ») raw_type = " ".join((card.get("data-type") or "").split()) unit_type = normalize_unit_type(raw_type or title) # description (attribut data-desc : chaîne JSON contenant du HTML) description = "" raw_desc = card.get("data-desc") or "" if raw_desc: try: description = _strip_tags(json.loads(raw_desc)) except (ValueError, TypeError): description = _strip_tags(raw_desc) # galerie complète (attribut data-gallery : tableau JSON de médias) images: list[str] = [] raw_gallery = card.get("data-gallery") or "" if raw_gallery: try: for media in json.loads(raw_gallery): u = (media or {}).get("url") or "" if u.startswith("http") and u not in images: images.append(u) except (ValueError, TypeError): pass if not images: img = card.select_one("img[src]") if img and img["src"].startswith("http"): images = [img["src"]] # bâtiment : préfixe du titre (« Rubis – 3 ½ » -> « Rubis ») building = title.split("–")[0].strip() details: dict = {} if building: details["building"] = building if re.search(r"\bbureau\b", raw_type, re.I): details["office_nook"] = True # variante avec coin bureau listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=PROJECT_ADDRESS, sector="Lebourgneuf", city="Québec", unit_type=unit_type, price=parse_price(price_label), price_label=price_label, description=description, details=details, images=images[:20], )