# -----------------------------------------------------------------------------
# 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