# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/lemay_fleury.py : connecteur Lemay Fleury Immobilier
# (lemayfleury.com) — Victoriaville, Kingsey Falls, Val-des-Sources
# (Centre-du-Québec). Appartements récents de type condo en formule TOUT
# INCLUS. Site Wix très lourd (fiches de ~6,4 Mo) MAIS rendu serveur : le
# HTML brut contient les cartes d'unités en
« Unité 4 1/2 | à partir
# de 1725$ » suivies des inclusions « (électricité, internet et garage
# inclus) » — un simple GET suffit, pas de Scrapfly. On parcourt les fiches
# immeubles de Victoriaville (/monfette /rheault /anemone /gaby /marcoux)
# et les pages villes (/kingsey-falls /val-des-sources). ⚠️ Chaque page
# embarque AUSSI des blocs partagés « … TOUT INCLUS / COMPLET » identiques
# sur tout le site : seuls les h3 « | à partir de » (spécifiques à la page)
# sont retenus. La page /warwick est vide (aucune carte) — ignorée.
# Granularité = typologie par immeuble. external_id = --
# (même convention que trylon : le prix disambiguë deux plans homonymes).
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import re
from ..schema import Listing, normalize_unit_type, strip_accents
from .base import BaseConnector
BASE = "https://www.lemayfleury.com"
# (slug de page, ville, adresse/repère)
PAGES: tuple[tuple[str, str, str], ...] = (
("monfette", "Victoriaville", "66, rue Monfette, Victoriaville"),
("rheault", "Victoriaville", "rue Rheault, Victoriaville"),
("anemone", "Victoriaville", "rue de l'Anémone, Victoriaville"),
("gaby", "Victoriaville", "rue Gaby, Victoriaville"),
("marcoux", "Victoriaville", "rue Marcoux, Victoriaville"),
("kingsey-falls", "Kingsey Falls", "Kingsey Falls"),
("val-des-sources", "Val-des-Sources",
"604, boulevard Simoneau, Val-des-Sources"),
)
H3_RE = re.compile(r"]*>([\s\S]*?)
", re.I)
TAG_RE = re.compile(r"<[^>]+>")
CARD_RE = re.compile(r"^(.{2,40}?)\s*\|\s*à\s+partir\s+de\s*([\d\s ]{3,7})\$",
re.I)
INCL_RE = re.compile(r"^\((.+)\)$")
UNIT_RE = re.compile(r"(\d)\s*(?:1/2|½)")
DELIVERY_RE = re.compile(r"Livraison\s+\w+\s+20\d\d", re.I)
IMG_RE = re.compile(
r"https://static\.wixstatic\.com/media/[\w~%]+\.(?:jpg|jpeg|webp)", re.I)
def _slug(text: str) -> str:
s = strip_accents(text.lower())
return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
def _num(txt: str) -> float | None:
n = re.sub(r"[\s ]", "", txt or "")
try:
return float(n)
except ValueError:
return None
class LemayFleuryConnector(BaseConnector):
source_id = "lemay_fleury"
request_delay = 1.2
timeout = 60 # fiches Wix de ~6,4 Mo
def fetch(self) -> list[Listing]:
listings: list[Listing] = []
for page, city, address in PAGES:
url = f"{BASE}/{page}"
try:
html = self.get(url).text
except Exception:
continue
listings.extend(self._parse_page(page, city, address, url, html))
return listings
def _parse_page(self, page: str, city: str, address: str, url: str,
html: str) -> list[Listing]:
# disponibilité de niveau page (ex. « Livraison été 2026 » à l'Anémone)
availability = ""
dm = DELIVERY_RE.search(_html.unescape(html))
if dm:
availability = re.sub(r"\s+", " ", dm.group(0))
images = [u for u in dict.fromkeys(IMG_RE.findall(html))][:10]
# flux des en ordre du document : carte prix puis inclusions
h3s = [re.sub(r"\s+", " ",
_html.unescape(TAG_RE.sub(" ", m))).strip()
for m in H3_RE.findall(html)]
listings: list[Listing] = []
seen: set[tuple[str, float]] = set()
for i, text in enumerate(h3s):
m = CARD_RE.match(text)
if not m:
continue
label = re.sub(r"\s+", " ", m.group(1)).strip()
price = _num(m.group(2))
if price is None or not (300 <= price <= 5000):
continue
if (label, price) in seen: # rendus dupliqués (desktop/mobile)
continue
seen.add((label, price))
amenities: list[str] = []
if i + 1 < len(h3s):
im = INCL_RE.match(h3s[i + 1])
if im:
amenities = [a.strip(" .").capitalize() for a in
re.split(r",|\+| et ", im.group(1))
if a.strip(" .") and "inclus" not in a.lower()]
amenities = [a for a in amenities if len(a) > 2]
unit_type = ""
um = UNIT_RE.search(label)
if um:
unit_type = normalize_unit_type(f"{um.group(1)} ½")
elif re.search(r"studio", label, re.I):
unit_type = "Studio"
place = address.split(",")[-2].strip() if address.count(",") >= 2 \
else address
ext_id = f"{page}-{_slug(label)}-{int(price)}"
listings.append(Listing(
source=self.source_id,
external_id=ext_id,
url=url,
title=f"{label} — Lemay Fleury ({place})",
address=address,
sector="",
city=city,
unit_type=unit_type,
price=price,
price_label=f"à partir de {int(price)}$ tout inclus",
availability=availability,
description=f"{label} en formule tout inclus chez Lemay "
f"Fleury Immobilier ({address}).",
amenities=amenities,
images=list(images),
))
return listings