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/terra.py : connecteur TERRA Condos locatifs (terracondolocatif.ca)5# Projet de 4 phases sur la route Mgr-Bourget (arrondissement Desjardins,6# Lévis). Site Webflow : chaque page de phase liste les unités par immeuble7# avec statut Disponible/Réservée/Louée (classes w-condition-invisible).8# Prix « à partir de » par type affichés en entête (3½+/4½+).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price17from .base import BaseConnector1819BASE = "https://www.terracondolocatif.ca"20PHASES = {21 "phase-1-2": "Phases 1-2",22 "phase-3": "Phase 3",23 "phase-4": "Phase 4",24}25SECTOR = "Desjardins (Lévis)"2627# images générales du projet (photo + plan d'ensemble)28_GALLERY_RE = re.compile(29 r'https://cdn\.prod\.website-files\.com/[^"\s,]+\.(?:jpg|jpeg|webp)', re.I)303132class TerraConnector(BaseConnector):33 source_id = "terra"34 request_delay = 0.63536 def fetch(self) -> list[Listing]:37 listings: dict[str, Listing] = {}3839 for slug, phase_name in PHASES.items():40 try:41 html = self.get(f"{BASE}/{slug}").text42 except Exception:43 continue44 soup = BeautifulSoup(html, "html.parser")4546 # contact du projet (liens tel:/mailto: de la page)47 contact: dict = {}48 tel = soup.select_one("a[href^='tel:']")49 if tel:50 digits = re.sub(r"\D", "", tel.get("href", ""))[-10:]51 if len(digits) == 10:52 contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"53 mail = soup.select_one("a[href^='mailto:']")54 if mail:55 m = re.match(r"mailto:([^?]+)", mail.get("href", ""))56 if m:57 contact["email"] = m.group(1).strip()5859 # Prix « à partir de » par type (ex. "3 1/2 + à partir de 1185 $/mois")60 type_prices: dict[str, tuple[float | None, str]] = {}61 for m in re.finditer(r"(\d)\s*1/2\s*\+?\s*à partir de\s*"62 r"([\d\s ]+)\$\s*/\s*mois",63 soup.get_text(" ", strip=True)):64 label = (f"{m.group(1)}½ à partir de "65 f"{m.group(2).strip()} $/mois")66 type_prices[m.group(1)] = (parse_price(f"{m.group(2)}$"), label)6768 # Galerie générale (photo du projet)69 gallery = [u for u in dict.fromkeys(_GALLERY_RE.findall(html))70 if not re.search(r"logo|favicon|icon", u, re.I)][:3]7172 # Onglets = immeubles ("Le 939", "Le 943", ...)73 for pane in soup.select("div.w-tab-pane"):74 building = (pane.get("data-w-tab") or "").strip()75 for card in pane.select("div.listeunitecms"):76 try:77 lst = self._parse_card(card, building, slug,78 phase_name, type_prices,79 gallery, contact)80 if lst and lst.external_id not in listings:81 listings[lst.external_id] = lst82 except Exception:83 continue8485 return list(listings.values())8687 def _parse_card(self, card, building, slug, phase_name,88 type_prices, gallery, contact) -> Listing | None:89 # Statut : disponible si .tagdispo n'a PAS la classe w-condition-invisible90 tag = card.select_one(".tagdispo")91 if tag is None or "w-condition-invisible" in tag.get("class", []):92 return None9394 text = card.get_text(" ", strip=True)95 num_m = re.search(r"\(\s*(\d+)\s*\)", text)96 if not num_m:97 return None98 unit_no = num_m.group(1)99100 type_m = re.search(r"(\d\s*1/2\s*\+?)", text)101 raw_type = type_m.group(1) if type_m else ""102 unit_type = normalize_unit_type(raw_type)103104 floor_m = re.search(r"(RDC|Étage\s*\d+)", text)105 sqft_m = re.search(r"(\d{3,4})\s*pi", text)106 beds_m = re.search(r"(\d+)\s*chambres?", text)107 expo_m = re.search(r"Exposition\s+([\w.]+)", text)108 model_m = re.search(r"Type\s+([\w.]+)", text)109110 # Fiche PDF de l'unité (le lien visible), ex. .../Fiche_939-101.pdf,111 # .../FIche_943-402.pdf ou .../68f5..._951-106.pdf112 fiche = ""113 for a in card.select("a.buttonplans[href]"):114 href = a.get("href", "")115 if href.startswith("http") and href.lower().endswith(".pdf"):116 fiche = href117 break118 fm = re.search(r"(\d{3})-(\d{3})\.pdf$", fiche, re.I)119 if fm:120 building = fm.group(1)121 unit_no = fm.group(2)122 elif not building.isdigit():123 # sans fiche ni onglet d'immeuble (onglet d'étage) : doublon124 return None125126 digit = re.search(r"(\d)", raw_type)127 price, price_label = (None, "")128 if digit and digit.group(1) in type_prices:129 price, price_label = type_prices[digit.group(1)]130131 desc_parts = []132 if floor_m:133 desc_parts.append(floor_m.group(1))134 if sqft_m:135 desc_parts.append(f"{sqft_m.group(1)} pi²")136 if beds_m:137 desc_parts.append(f"{beds_m.group(1)} chambre(s)")138 if expo_m:139 desc_parts.append(f"Exposition {expo_m.group(1)}")140 if model_m:141 desc_parts.append(f"Modèle {model_m.group(1)}")142 if fiche:143 desc_parts.append(f"Fiche : {fiche}")144145 ext_id = f"{building or slug}-{unit_no}"146 address = (f"{building}, route Mgr-Bourget, Lévis"147 if building.isdigit() else "route Mgr-Bourget, Lévis")148149 return Listing(150 source=self.source_id,151 external_id=ext_id,152 url=f"{BASE}/{slug}",153 title=f"TERRA {phase_name} — Le {building}, unité {unit_no}"154 f" ({unit_type})",155 address=address,156 sector=SECTOR,157 city=infer_city(SECTOR, default="Lévis"),158 unit_type=unit_type,159 price=price,160 price_label=price_label,161 availability="Disponible",162 description=" | ".join(desc_parts),163 # « Formule tout inclus » affichée sur le site (accueil + pages164 # de phase) : chauffé et climatisé, eau chaude, internet,165 # stationnement intérieur, ascenseur ; « plafonds de 9 pieds,166 # balcons de 134 à 254 pi², salle commune » (pied des pages phase).167 amenities=["Chauffé et climatisé", "Eau chaude incluse",168 "Internet inclus", "Stationnement intérieur",169 "Ascenseur", "Balcon", "Salle commune",170 "Plafonds de 9 pieds"],171 details={"contact": contact} if contact else {},172 images=list(gallery),173 )174