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/dma_locago.py : connecteur DMA / Locago (Douville, Moffet & Associés)5# locago.ca présente les complexes; les unités (avec prix/disponibilité) sont6# embarquées dans les pages MapSVG de v1.dma.immo (JSON `data_db.objects` :7# superficie, étage, orientation, mois/année de disponibilité, prix).8# La Tour Frontenac a son propre sélecteur JetEngine sur dma.immo9# (/frontenac-selecteur-pub/ : cartes avec prix, étage, pi², orientation).10# Une annonce par unité disponible; une annonce « projet » pour les complexes11# sans sélecteur d'unités (IDOLA, Le Pivot).12# Luxo Place (Ottawa) est exclu — hors région Québec/Lévis.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, infer_city, normalize_unit_type, parse_price22from .base import BaseConnector2324V1 = "https://www.v1.dma.immo"25DMA = "https://dma.immo"26LOCAGO = "https://locago.ca"27FRONTENAC_URL = f"{DMA}/frontenac-selecteur-pub/"28FRONTENAC_ADDRESS = "1175, avenue de Germain-des-Prés, Québec"2930_MONTHS = {31 "01": "janvier", "02": "février", "03": "mars", "04": "avril",32 "05": "mai", "06": "juin", "07": "juillet", "08": "août",33 "09": "septembre", "10": "octobre", "11": "novembre", "12": "décembre",34}3536# Complexes avec sélecteur d'unités MapSVG sur v1.dma.immo37# (slug, nom, secteur, mot-clé image locago, site vitrine pour photos, adresse)38# L'adresse n'est renseignée que si le site du projet publie une adresse39# civique unique et sans ambiguïté (complexes multi-immeubles -> vide).40_UNIT_PAGES = [41 ("le-wow-unites", "Le WOW", "Sainte-Foy", "WOW", "https://lewow.ca",42 "930, rue Samuel-King, Québec"),43 ("la-suite-unites", "La Suite", "Sainte-Foy", "SUITE",44 "https://lasuite.ca", ""),45 ("domaine-des-meandres-unites", "Domaine des Méandres", "Lebourgneuf",46 "DDM", "https://info.domainedesmeandres.com", ""),47 ("vc-unites", "Villas Cortina", "Charlesbourg", "CORTINA",48 "https://info.villascortina.com", ""),49 ("le-divin-unites", "Le Divin", "Beauport", "DIVIN",50 "https://ledivin.ca", ""),51 ("le-divin-2-unites", "Le Divin (phase 2)", "Beauport", "DIVIN",52 "https://ledivin.ca", ""),53 ("ar-unites-01-02", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",54 "https://info.laristocrate.ca", ""),55 ("ar-unites-03", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",56 "https://info.laristocrate.ca", ""),57 ("ar-unites-04", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",58 "https://info.laristocrate.ca", ""),59]6061# Complexes présentés sur locago.ca sans sélecteur d'unités public62# (id, nom, secteur, mot-clé image locago, url projet, adresse)63_PROJECT_ONLY = [64 ("idola", "IDOLA", "Sainte-Foy", "IDOLA", "https://idola.ca/",65 "2689, boulevard Hochelaga, Québec"),66 ("le-pivot", "Le Pivot", "Vanier", "PIVOT", "https://pivotqc.com/",67 "1695, rue Monseigneur-Plessis, Québec"),68]6970_IMG_RE = re.compile(71 r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)',72 re.I)73_SKIP_IMG = re.compile(r"logo|icon|favicon|dma-|-\d{2,3}x\d{2,3}\.", re.I)747576class DMALocagoConnector(BaseConnector):77 source_id = "dma_locago"78 request_delay = 0.679 max_site_images = 128081 # -- helpers ---------------------------------------------------------------82 def _page_images(self, url: str) -> list[str]:83 """Photos (wp-content/uploads) d'une page, sans logos ni vignettes."""84 try:85 html = self.get(url).text86 except Exception:87 return []88 imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))89 if not _SKIP_IMG.search(u)]90 return imgs[: self.max_site_images]9192 @staticmethod93 def _mapsvg_objects(html: str) -> list[dict]:94 """Extrait les unités des blocs `mapsvg_options = {...};` (data_db)."""95 objects: list[dict] = []96 dec = json.JSONDecoder()97 for m in re.finditer(r"mapsvg_options\s*=\s*", html):98 try:99 opts, _ = dec.raw_decode(html[m.end():])100 except Exception:101 continue102 data_db = opts.get("data_db") or {}103 if isinstance(data_db, dict):104 objects.extend(data_db.get("objects") or [])105 return objects106107 @staticmethod108 def _unit_type(obj: dict) -> str:109 """'3.5' -> '3½' (avec repli sur typlog/descript)."""110 nb = str(obj.get("nbpiece") or "").strip().replace(",", ".")111 m = re.match(r"^(\d+)(\.5)?$", nb)112 if m:113 return f"{m.group(1)}½"114 raw = obj.get("descript") or obj.get("typlog") or ""115 if re.search(r"studio", str(raw), re.I):116 return "Studio"117 return normalize_unit_type(str(raw))118119 @staticmethod120 def _availability(obj: dict) -> str:121 # Le mois/année de disponibilité est renseigné pour toutes les unités122 # offertes (statuts « 1 » et « 2 ») ; il prime sur le statut brut.123 month = _MONTHS.get(str(obj.get("available_month") or ""), "")124 year = str(obj.get("available_year") or "").strip()125 if month and year:126 return f"{month} {year}"127 if obj.get("available") == "1":128 return "Libre immédiatement"129 return "Disponible prochainement"130131 # -- fetch -----------------------------------------------------------------132 def fetch(self) -> list[Listing]:133 # 1) Images « héro » des complexes depuis locago.ca134 img_keys = {row[3] for row in _UNIT_PAGES + _PROJECT_ONLY}135 locago_imgs: dict[str, str] = {}136 try:137 home = self.get(LOCAGO).text138 for u in _IMG_RE.findall(home):139 if _SKIP_IMG.search(u):140 continue141 for key in img_keys:142 if key.lower() in u.lower() and key not in locago_imgs:143 locago_imgs[key] = u144 except Exception:145 pass146147 # Photos supplémentaires par site vitrine (1 requête par site)148 site_imgs: dict[str, list[str]] = {}149150 listings: dict[str, Listing] = {}151152 # 2) Unités disponibles des pages MapSVG (v1.dma.immo)153 for slug, name, sector, img_key, site, address in _UNIT_PAGES:154 try:155 html = self.get(f"{V1}/{slug}/").text156 except Exception:157 continue158 if site not in site_imgs:159 site_imgs[site] = self._page_images(site)160 images = ([locago_imgs[img_key]] if img_key in locago_imgs else [])161 images += [u for u in site_imgs[site] if u not in images]162163 for obj in self._mapsvg_objects(html):164 if str(obj.get("available")) not in ("1", "2"):165 continue # unité louée166 uid = str(obj.get("title") or "").strip()167 unite = str(obj.get("unite") or "").strip()168 if not uid or uid in listings:169 continue170 # superficie structurée (pi²) -> area_sqft explicite171 sup = str(obj.get("superficie") or "").strip()172 area_sqft = None173 m = re.match(r"^(\d{2,5}(?:[.,]\d+)?)$", sup)174 if m:175 area_sqft = float(m.group(1).replace(",", "."))176 desc_parts = [str(obj.get("descript") or "").strip()]177 if sup:178 desc_parts.append(f"{sup} pi²")179 elif obj.get("sup_range"):180 desc_parts.append(str(obj["sup_range"]))181 if obj.get("orientation"):182 desc_parts.append(f"Orientation : {obj['orientation']}")183 # étage structuré (« 0 » = non renseigné chez la source)184 details: dict = {}185 etage = str(obj.get("etage") or "").strip()186 if etage.isdigit() and int(etage) >= 1:187 details["floor"] = int(etage)188 startfrom = str(obj.get("startfrom") or "").strip()189 listings[uid] = Listing(190 source=self.source_id,191 external_id=uid,192 url=f"{V1}/{slug}/#unite-{unite or uid}",193 title=f"{name} — Unité {unite or uid}",194 address=address,195 sector=sector,196 city=infer_city(sector),197 unit_type=self._unit_type(obj),198 price=parse_price(startfrom),199 price_label=f"À partir de {startfrom}" if startfrom else "",200 availability=self._availability(obj),201 area_sqft=area_sqft,202 description=" — ".join(p for p in desc_parts if p)[:600],203 details=details,204 images=images,205 )206207 # 3) Tour Frontenac : sélecteur JetEngine sur dma.immo (cartes d'unités)208 try:209 self._fetch_frontenac(listings, locago_imgs)210 except Exception:211 pass212213 # 4) Annonces « projet » pour les complexes sans sélecteur d'unités214 for pid, name, sector, img_key, url, address in _PROJECT_ONLY:215 try:216 images = ([locago_imgs[img_key]] if img_key in locago_imgs217 else [])218 images += [u for u in self._page_images(url)219 if u not in images]220 listings[f"projet-{pid}"] = Listing(221 source=self.source_id,222 external_id=f"projet-{pid}",223 url=url,224 title=name,225 address=address,226 sector=sector,227 city=infer_city(sector),228 unit_type="",229 price=None,230 price_label="",231 availability="",232 description=(f"Complexe locatif {name} ({sector}) géré par "233 "DMA / Locago — voir le site du projet pour "234 "les unités disponibles."),235 images=images,236 )237 except Exception:238 continue239240 return list(listings.values())241242 # -- Tour Frontenac (sélecteur JetEngine sur dma.immo) ----------------------243 def _fetch_frontenac(self, listings: dict[str, Listing],244 locago_imgs: dict[str, str]) -> None:245 """Cartes d'unités disponibles : no, type, étage, pi², orientation,246 prix « À partir de » — pas de date de disponibilité affichée."""247 html = self.get(FRONTENAC_URL).text248 soup = BeautifulSoup(html, "html.parser")249 images = ([locago_imgs["frontenac"]] if "frontenac" in locago_imgs250 else [])251 images += [u for u in self._page_images("https://appartsfrontenac.ca/")252 if u not in images]253254 for card in soup.select(".jet-listing-grid__item"):255 text = card.get_text(" ", strip=True)256 m = re.search(r"Unité\s+(\w+)", text)257 if not m:258 continue259 unit = m.group(1)260 uid = f"FRONTENAC-{unit}"261 if uid in listings:262 continue263264 type_el = card.select_one("span.subtitle")265 type_raw = type_el.get_text(" ", strip=True) if type_el else ""266 # types en toutes lettres chez la source (« Une chambre »…)267 key = type_raw.strip().lower()268 unit_type = {269 "studio": "Studio", "une chambre": "3½",270 "deux chambres": "4½", "trois chambres": "5½",271 }.get(key) or normalize_unit_type(type_raw)272273 details: dict = {}274 fm = re.search(r"[ÉE]tage\s+(\d{1,2})\b", text)275 if fm:276 details["floor"] = int(fm.group(1))277 area_sqft = None278 am = re.search(r"(\d[\d\s]{1,6})\s*pi", text)279 if am:280 area_sqft = float(am.group(1).replace(" ", ""))281282 desc_parts = [type_raw]283 if am:284 desc_parts.append(f"{am.group(1).strip()} pi²")285 om = re.search(r"Orientation\s*:\s*([A-Za-zÀ-ÿ -]+?)(?:\s{2,}|$|T[ÉE]L)",286 text)287 if om:288 desc_parts.append(f"Orientation : {om.group(1).strip()}")289290 pm = re.search(r"\$\s*([\d\s]{3,10})", text)291 price_label = f"À partir de {pm.group(1).strip()} $" if pm else ""292293 listings[uid] = Listing(294 source=self.source_id,295 external_id=uid,296 url=FRONTENAC_URL,297 title=f"Tour Frontenac — Unité {unit}",298 address=FRONTENAC_ADDRESS,299 sector="Sainte-Foy",300 city=infer_city("Sainte-Foy"),301 unit_type=unit_type,302 price=parse_price(price_label),303 price_label=price_label,304 availability="",305 area_sqft=area_sqft,306 description=" — ".join(p for p in desc_parts if p)[:600],307 details=details,308 images=images,309 )310