# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/martin_roy.py : connecteur Construction Martin Roy # (constructionmartinroy.com — Saint-Georges, Beauce). WordPress (WP Engine) # rendu serveur : la page /logement-a-louer/ liste 18 fiches par adresse # (cartes .v-logements-louer__logements-modeles-item) avec statut # Loué/Disponible, prix ($/mois, « À partir de », « Sur demande »), # chambres, salles de bain, superficie et photo (background-image). # On n'ingère que les unités NON louées (statut ≠ Loué). Les pages détail # /logement// sont derrière un WAF Cloudflare agressif : on s'en # tient à la liste, qui contient déjà tous les champs utiles. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://constructionmartinroy.com" LIST_URL = f"{BASE}/logement-a-louer/" CITY = "Saint-Georges" P = "v-logements-louer__logements-modeles-" # préfixe des classes du thème BG_IMG_RE = re.compile(r"url\((['\"]?)(https?://[^'\")]+)\1\)", re.I) AREA_RE = re.compile(r"(\d[\d\s]{2,5})\s*pi", re.I) PHONE_RE = re.compile(r"(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})") class MartinRoyConnector(BaseConnector): source_id = "martin_roy" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") # téléphone du promoteur (lien tel: global du site) details_common: dict = {} tel = soup.select_one('a[href^="tel:"]') if tel: digits = re.sub(r"\D", "", tel.get("href", ""))[-10:] if len(digits) == 10: details_common["contact"] = {"phone": "-".join( (digits[:3], digits[3:6], digits[6:]))} for item in soup.select(f"li.{P}item"): try: statut_el = item.select_one(f".{P}statut") statut = statut_el.get_text(strip=True) if statut_el else "" # on n'ingère que les unités encore offertes if re.search(r"lou[ée]", statut, re.I): continue a = item.select_one(f"a.{P}lien") if not a or not a.get("href"): continue url = a["href"].split("?")[0] slug = url.rstrip("/").split("/")[-1] if not slug: continue titre_el = item.select_one(f".{P}image-titre") street = titre_el.get_text(strip=True) if titre_el else slug address = street if re.search(r"saint-georges", street, re.I) \ else f"{street}, Saint-Georges" prix_el = item.select_one(f".{P}contenu-prix") price_label = re.sub(r"\s+", " ", prix_el.get_text( " ", strip=True)) if prix_el else "" price = parse_price(price_label) \ if "demande" not in price_label.lower() else None def _num(cls: str) -> float | None: el = item.select_one(f".{P}contenu-{cls} span") v = el.get_text(strip=True) if el else "" return float(v) if re.fullmatch(r"\d+(\.\d+)?", v) else None bedrooms = _num("chambre") bathrooms = _num("salle-bain") area = None sup_el = item.select_one(f".{P}contenu-superficie span") sup = sup_el.get_text(strip=True) if sup_el else "" am = AREA_RE.search(sup) if am: val = float(am.group(1).replace(" ", "")) if 200 <= val <= 3000: # le site affiche parfois le lot area = val images: list[str] = [] bg = BG_IMG_RE.search(a.get("style") or "") if bg: images.append(bg.group(2)) listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=address, address=address, city=CITY, bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=price_label if "$" in price_label else "", availability=statut or "Disponible", area_sqft=area, details=dict(details_common), images=images, )) except Exception: continue return listings