# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ocartier.py : connecteur OCartier 2 (ocartier.ca — TDR # Développements). Tour locative de 200 appartements au 30, rue St-Hubert # à Laval (métro Cartier, secteur Pont-Viau), loyer tout inclus # (électricité, chauffage, climatisation, eau, internet). # WordPress/Elementor rendu serveur : la page /plans/ affiche une # fourchette de prix par TYPOLOGIE (« Studios / 3 1/2 / 4 1/2 / 5 1/2 », # « À partir de X $ jusqu'à Y $ »). Granularité TYPOLOGIE. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://ocartier.ca" PAGE_URL = f"{BASE}/plans/" ADDRESS = "30, rue St-Hubert" CITY = "Laval" SECTOR = "Pont-Viau" BUILDING_AMENITIES = [ "Électricité incluse", "Chauffage inclus", "Climatisation incluse", "Eau incluse", "Internet inclus", ] TYPE_RE = re.compile(r"^(?:Studios?|\d\s*(?:1/2|½))$", re.I) RANGE_RE = re.compile( r"À partir de\s*([\d\s ,]+?)\s*\$.*?jusqu[’']à\s*([\d\s ,]+?)\s*\$", re.I | re.S) BED_RE = re.compile(r"(\d)\s*chambres?", re.I) IMG_RE = re.compile( r"https://ocartier\.ca/wp-content/uploads/[^\"'\s\\)]+?" r"\.(?:jpg|jpeg|png|webp)", re.I) def _num(txt: str) -> float | None: digits = re.sub(r"[^\d]", "", txt or "") return float(digits) if digits else None class OCartierConnector(BaseConnector): source_id = "ocartier" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(PAGE_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") # photos : la page /plans/ est pauvre en visuels, on complète avec la # galerie de la page /appartements/ (rendue serveur elle aussi) pool = IMG_RE.findall(html) try: pool += IMG_RE.findall(self.get(f"{BASE}/appartements/").text) except Exception: pass images = [u for u in dict.fromkeys(pool) if not re.search(r"logo|favicon|icon|metro|-\d+x\d+\.", u, re.I)][:20] seen: set[str] = set() # blocs typologie :

Studios

… « À partir de 1 630 $ jusqu'à … » # — le prix est dans un widget texte qui SUIT le heading dans l'ordre # du document (structure de conteneurs Elementor variable) : on # concatène le texte jusqu'au prochain heading de typologie. headings = [x for x in soup.select(".elementor-heading-title") if TYPE_RE.match(x.get_text(" ", strip=True))] for heading in headings: label = heading.get_text(" ", strip=True) texts: list[str] = [] for node in heading.find_all_next(string=True): t = str(node).strip() if not t: continue if TYPE_RE.match(t) and t != label: break texts.append(t) if len(texts) > 40: break blob = " ".join(texts) m = RANGE_RE.search(blob) if not m: continue lo, hi = _num(m.group(1)), _num(m.group(2)) if not lo: continue unit_type = ("Studio" if label.lower().startswith("studio") else normalize_unit_type(label)) ext_id = ("ocartier2-" + unit_type.replace("½", ".5").replace(" ", "").lower()) if ext_id in seen: # les blocs sont répétés desktop/mobile continue seen.add(ext_id) bedrooms = None bm = BED_RE.search(blob.split("À partir")[0]) if bm: bedrooms = float(bm.group(1)) price_label = (f"À partir de {lo:.0f} $" + (f" jusqu'à {hi:.0f} $" if hi else "") + " /mois tout inclus") listings.append(Listing( source=self.source_id, external_id=ext_id, url=PAGE_URL, title=f"{unit_type} — OCartier 2, appartements locatifs à Laval", address=ADDRESS, sector=SECTOR, city=CITY, unit_type=unit_type, bedrooms=bedrooms, price=lo, price_label=price_label, description=("OCartier 2 propose 200 appartements locatifs " "haut de gamme à Laval, du studio au 5½, à deux " "pas du métro Cartier. Prix tout inclus : " "électricité, chauffage, climatisation, eau et " "internet."), amenities=list(BUILDING_AMENITIES), images=images, )) return listings