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/quartier_mosaique.py : connecteur Quartier Mosaïque (Constrobourg)5# (quartiermosaique.com — 5 complexes : Rubis, Saphir, Opale 1-2, Perle,6# secteur Lebourgneuf près des Galeries de la Capitale, Québec).7# Page /unites-disponibles/ rendue serveur (WordPress, thème Browsy) : une8# carte par plan d'unité disponible (div.cardib.unites-item) avec titre9# (« Rubis – 3 ½ »), type (data-type), prix « À partir de X $ par mois »10# (cardib-excerpt), description (data-desc, JSON) et galerie complète11# (data-gallery, JSON). Aucune page détail nécessaire : tout est sur la liste.12# Adresse du quartier publiée sur /contact/ : 2013, boulevard Lebourgneuf.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re18from urllib.parse import unquote1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://quartiermosaique.com"26LIST_URL = f"{BASE}/unites-disponibles/"2728# Adresse du projet telle que publiée sur https://quartiermosaique.com/contact/29# (les 5 bâtiments partagent le même quadrilatère, aucune adresse par bâtiment30# n'est affichée sur le site).31PROJECT_ADDRESS = "2013, boulevard Lebourgneuf, Québec, G2K 0N8"323334def _strip_tags(html: str) -> str:35 """'<p>Condos locatifs…</p>' -> texte brut, espaces normalisés."""36 txt = re.sub(r"<[^>]+>", " ", html or "")37 return re.sub(r"\s+", " ", txt).strip()383940class QuartierMosaiqueConnector(BaseConnector):41 source_id = "quartier_mosaique"42 request_delay = 0.64344 def fetch(self) -> list[Listing]:45 html = self.get(LIST_URL).text46 soup = BeautifulSoup(html, "html.parser")4748 listings: dict[str, Listing] = {}49 # les cartes sont dupliquées (grille + carrousel swiper) : dédupliquer50 for card in soup.select("div.cardib"):51 try:52 self._parse_card(card, listings)53 except Exception:54 continue55 return list(listings.values())5657 # -- carte « unité disponible » (thème Browsy) ------------------------------58 def _parse_card(self, card, listings: dict[str, Listing]) -> None:59 link = card.select_one('a[href*="/unites/"]')60 title = " ".join((card.get("data-title") or "").split())61 if not link or not title:62 return63 url = link["href"]64 m = re.search(r"/unites/([^/]+)/?", url)65 if not m:66 return67 ext_id = unquote(m.group(1)) # slug WordPress stable (« rubis-3-½ »)68 if ext_id in listings:69 return7071 # prix rendu dans la page : « À partir de 1 457 $ par mois »72 exc = card.select_one(".cardib-excerpt")73 price_label = exc.get_text(" ", strip=True) if exc else ""7475 # type d'unité structuré (data-type : « 3 ½ », « 3 ½ Bureau », « Loft »)76 raw_type = " ".join((card.get("data-type") or "").split())77 unit_type = normalize_unit_type(raw_type or title)7879 # description (attribut data-desc : chaîne JSON contenant du HTML)80 description = ""81 raw_desc = card.get("data-desc") or ""82 if raw_desc:83 try:84 description = _strip_tags(json.loads(raw_desc))85 except (ValueError, TypeError):86 description = _strip_tags(raw_desc)8788 # galerie complète (attribut data-gallery : tableau JSON de médias)89 images: list[str] = []90 raw_gallery = card.get("data-gallery") or ""91 if raw_gallery:92 try:93 for media in json.loads(raw_gallery):94 u = (media or {}).get("url") or ""95 if u.startswith("http") and u not in images:96 images.append(u)97 except (ValueError, TypeError):98 pass99 if not images:100 img = card.select_one("img[src]")101 if img and img["src"].startswith("http"):102 images = [img["src"]]103104 # bâtiment : préfixe du titre (« Rubis – 3 ½ » -> « Rubis »)105 building = title.split("–")[0].strip()106107 details: dict = {}108 if building:109 details["building"] = building110 if re.search(r"\bbureau\b", raw_type, re.I):111 details["office_nook"] = True # variante avec coin bureau112113 listings[ext_id] = Listing(114 source=self.source_id,115 external_id=ext_id,116 url=url,117 title=title,118 address=PROJECT_ADDRESS,119 sector="Lebourgneuf",120 city="Québec",121 unit_type=unit_type,122 price=parse_price(price_label),123 price_label=price_label,124 description=description,125 details=details,126 images=images[:20],127 )128