Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/tours_st_martin.py : connecteur Les Tours Saint-Martin5# (lestourssaintmartin.ca) — deux tours locatives de 12 étages au6# 2976, boulevard Saint-Martin Ouest, Laval (Chomedey). Même thème WordPress7# maison que Le L Laval (l_laval.py) : pages /plans/ (phase 1) et8# /plans-phase-2/ avec une ligne <tr id="rowUnitNNN"> par unité — classe9# « not-available » = louée ; onclick=loadPage(unité, prix, pi², étage, état,10# modèle, typologie). Les prix par unité ne sont pas publiés : on récupère les11# « à partir de » affichés sur la page d'accueil (ex. « Grands 4½ à partir de12# 1972$/mois ») comme price_label par typologie. Granularité : unité.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from ..schema import Listing, normalize_unit_type19from .base import BaseConnector2021BASE = "https://lestourssaintmartin.ca"22PHASES = [("1", f"{BASE}/plans/"), ("2", f"{BASE}/plans-phase-2/")]2324ADDRESS = "2976, boulevard Saint-Martin Ouest, Laval"25CITY = "Laval"26SECTOR = "Chomedey"2728ROW_RE = re.compile(29 r'<tr\s+id="rowUnit(\w+)"[^>]*class="([^"]*)"[^>]*'30 r"onclick='loadPage\(([^)]*)\)'", re.I)31ARG_RE = re.compile(r'"([^"]*)"')32# « Grands 4½ à partir de 1972$/mois » (page d'accueil)33FROM_RE = re.compile(34 r"(\d\s*(?:½|1/2|½)|[Ss]tudios?)[^<$]{0,40}?"35 r"à\s+partir\s+de\s+(\d[\d\s,]*)\s*\$", re.I)36IMG_RE = re.compile(37 r"https://lestourssaintmartin\.ca/wp-content/uploads/"38 r"[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I)39SKIP_IMG_RE = re.compile(r"logo|favicon|icon|plan|-\d{2,4}x\d{2,4}\.", re.I)404142class ToursStMartinConnector(BaseConnector):43 source_id = "tours_st_martin"44 request_delay = 0.645 max_images = 104647 def _from_prices(self) -> dict[str, str]:48 """{'4½': 'à partir de 1972$/mois'} depuis la page d'accueil."""49 prices: dict[str, str] = {}50 try:51 html = self.get(f"{BASE}/").text52 except Exception:53 return prices54 for typo, amount in FROM_RE.findall(html):55 key = normalize_unit_type(56 typo.replace("½", "½").replace("1/2", "½"))57 amount = amount.strip().replace(" ", "")58 if key and key not in prices:59 prices[key] = f"à partir de {amount}$/mois"60 return prices6162 def _site_images(self) -> list[str]:63 try:64 html = self.get(f"{BASE}/appartements-laval/").text65 except Exception:66 return []67 return [u for u in dict.fromkeys(IMG_RE.findall(html))68 if not SKIP_IMG_RE.search(u)][: self.max_images]6970 def fetch(self) -> list[Listing]:71 from_prices = self._from_prices()72 images = self._site_images()7374 listings: list[Listing] = []75 for phase, url in PHASES:76 try:77 html = self.get(url).text78 except Exception:79 continue80 seen: set[str] = set()81 for unit, classes, raw_args in ROW_RE.findall(html):82 if "not-available" in classes or unit in seen:83 continue84 seen.add(unit)85 # loadPage(unité, prix, pi², étage, état, modèle, typologie)86 args = ARG_RE.findall(raw_args)87 if len(args) < 5:88 continue89 _, price_raw, sqft_raw, floor, etat = args[:5]90 if etat.strip() not in ("", "0"):91 continue92 model = args[5] if len(args) > 5 else ""93 typo = args[6] if len(args) > 6 else ""94 typo = typo.replace("½", "½").replace("1/2", "½")95 unit_type = normalize_unit_type(typo)9697 price = None98 price_label = ""99 m = re.search(r"(\d[\d\s,]*)\s*\$", price_raw)100 if m:101 try:102 val = float(m.group(1).replace(" ", "")103 .replace(",", ""))104 if 300 <= val <= 20000:105 price = val106 price_label = price_raw.strip()107 except ValueError:108 pass109 if price is None:110 price_label = from_prices.get(unit_type,111 "Prix sur demande")112113 area = None114 try:115 v = float(sqft_raw)116 if 100 <= v <= 10000:117 area = v118 except ValueError:119 pass120121 desc_bits = [122 f"Phase {phase}",123 f"Unité {unit}, {floor}e étage" if floor else "",124 f"Modèle {model}" if model else "",125 ]126 listings.append(Listing(127 source=self.source_id,128 external_id=f"p{phase}-{unit}",129 url=f"{url}#unite-{unit}",130 title=f"Les Tours Saint-Martin (phase {phase})"131 f" — Unité {unit}",132 address=ADDRESS,133 sector=SECTOR,134 city=CITY,135 unit_type=unit_type,136 price=price,137 price_label=price_label,138 availability="Disponible",139 area_sqft=area,140 description=" — ".join(b for b in desc_bits if b),141 images=list(images),142 ))143 return listings144