# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/tours_st_martin.py : connecteur Les Tours Saint-Martin # (lestourssaintmartin.ca) — deux tours locatives de 12 étages au # 2976, boulevard Saint-Martin Ouest, Laval (Chomedey). Même thème WordPress # maison que Le L Laval (l_laval.py) : pages /plans/ (phase 1) et # /plans-phase-2/ avec une ligne par unité — classe # « not-available » = louée ; onclick=loadPage(unité, prix, pi², étage, état, # modèle, typologie). Les prix par unité ne sont pas publiés : on récupère les # « à partir de » affichés sur la page d'accueil (ex. « Grands 4½ à partir de # 1972$/mois ») comme price_label par typologie. Granularité : unité. # ----------------------------------------------------------------------------- from __future__ import annotations import re from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://lestourssaintmartin.ca" PHASES = [("1", f"{BASE}/plans/"), ("2", f"{BASE}/plans-phase-2/")] ADDRESS = "2976, boulevard Saint-Martin Ouest, Laval" CITY = "Laval" SECTOR = "Chomedey" ROW_RE = re.compile( r']*class="([^"]*)"[^>]*' r"onclick='loadPage\(([^)]*)\)'", re.I) ARG_RE = re.compile(r'"([^"]*)"') # « Grands 4½ à partir de 1972$/mois » (page d'accueil) FROM_RE = re.compile( r"(\d\s*(?:½|1/2|½)|[Ss]tudios?)[^<$]{0,40}?" r"à\s+partir\s+de\s+(\d[\d\s,]*)\s*\$", re.I) IMG_RE = re.compile( r"https://lestourssaintmartin\.ca/wp-content/uploads/" r"[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I) SKIP_IMG_RE = re.compile(r"logo|favicon|icon|plan|-\d{2,4}x\d{2,4}\.", re.I) class ToursStMartinConnector(BaseConnector): source_id = "tours_st_martin" request_delay = 0.6 max_images = 10 def _from_prices(self) -> dict[str, str]: """{'4½': 'à partir de 1972$/mois'} depuis la page d'accueil.""" prices: dict[str, str] = {} try: html = self.get(f"{BASE}/").text except Exception: return prices for typo, amount in FROM_RE.findall(html): key = normalize_unit_type( typo.replace("½", "½").replace("1/2", "½")) amount = amount.strip().replace(" ", "") if key and key not in prices: prices[key] = f"à partir de {amount}$/mois" return prices def _site_images(self) -> list[str]: try: html = self.get(f"{BASE}/appartements-laval/").text except Exception: return [] return [u for u in dict.fromkeys(IMG_RE.findall(html)) if not SKIP_IMG_RE.search(u)][: self.max_images] def fetch(self) -> list[Listing]: from_prices = self._from_prices() images = self._site_images() listings: list[Listing] = [] for phase, url in PHASES: try: html = self.get(url).text except Exception: continue seen: set[str] = set() for unit, classes, raw_args in ROW_RE.findall(html): if "not-available" in classes or unit in seen: continue seen.add(unit) # loadPage(unité, prix, pi², étage, état, modèle, typologie) args = ARG_RE.findall(raw_args) if len(args) < 5: continue _, price_raw, sqft_raw, floor, etat = args[:5] if etat.strip() not in ("", "0"): continue model = args[5] if len(args) > 5 else "" typo = args[6] if len(args) > 6 else "" typo = typo.replace("½", "½").replace("1/2", "½") unit_type = normalize_unit_type(typo) price = None price_label = "" m = re.search(r"(\d[\d\s,]*)\s*\$", price_raw) if m: try: val = float(m.group(1).replace(" ", "") .replace(",", "")) if 300 <= val <= 20000: price = val price_label = price_raw.strip() except ValueError: pass if price is None: price_label = from_prices.get(unit_type, "Prix sur demande") area = None try: v = float(sqft_raw) if 100 <= v <= 10000: area = v except ValueError: pass desc_bits = [ f"Phase {phase}", f"Unité {unit}, {floor}e étage" if floor else "", f"Modèle {model}" if model else "", ] listings.append(Listing( source=self.source_id, external_id=f"p{phase}-{unit}", url=f"{url}#unite-{unit}", title=f"Les Tours Saint-Martin (phase {phase})" f" — Unité {unit}", address=ADDRESS, sector=SECTOR, city=CITY, unit_type=unit_type, price=price, price_label=price_label, availability="Disponible", area_sqft=area, description=" — ".join(b for b in desc_bits if b), images=list(images), )) return listings