# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/fluet.py : connecteur Groupe Fluet (groupefluet.com) # Gestionnaire de Saint-Hyacinthe — logements pour préretraités/retraités # autonomes (Manoir des Cascades, Seigneurie des Pères, Le Notre-Dame # phases I-II-III) + triplex rue Noiseux à Sainte-Marie-Madeleine. # Site Wix MAIS rendu serveur : le HTML brut contient le texte des pages — # pas besoin de rendu JS. Une page par immeuble, typologies dans le texte # (« Offre des logements : 3½, 4½ et 5 ½ »). # Granularité : une annonce par immeuble × typologie (aucun prix publié). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.groupefluet.com" # (slug de page, nom d'immeuble, adresse repli, ville, typologies repli) PAGES = [ ("manoirdescascades", "Manoir des Cascades", "Saint-Hyacinthe", "Saint-Hyacinthe", ["2½", "3½", "4½"]), ("seigneurie", "Seigneurie des Pères", "2250, rue Girouard Ouest, Saint-Hyacinthe", "Saint-Hyacinthe", ["3½", "4½", "5½"]), ("copie-de-le-notre-dame", "Le Notre-Dame — Phase 1", "900, avenue Raymond, Saint-Hyacinthe", "Saint-Hyacinthe", ["3½", "4½", "5½"]), ("lenotredame2", "Le Notre-Dame — Phase 2", "920, avenue Raymond, Saint-Hyacinthe", "Saint-Hyacinthe", ["3½", "4½", "5½"]), ("lenotredame3", "Le Notre-Dame — Phase 3", "910, avenue Raymond, Saint-Hyacinthe", "Saint-Hyacinthe", ["3½", "4½", "5½"]), ("general-6", "Triplex rue Noiseux", "Rue Noiseux, Sainte-Marie-Madeleine", "Sainte-Marie-Madeleine", ["4½"]), ] # « Offre des logements : 3½, 4½ et 5 ½ » / « 19 unités de 2½, 3½ et 4½ » OFFER_RE = re.compile( r"(?:Offre des logements\s*:?|unit[ée]s de)\s*([\d\s½,/et]+)", re.I) UNIT_TOKEN_RE = re.compile(r"\b(\d)\s*(?:½|1/2)") IMG_RE = re.compile( r"https://static\.wixstatic\.com/media/[\w~%]+\.(?:jpe?g|png|webp)" r"/v1/fill/[^\"\s\\)]+\.(?:jpe?g|png|webp)", re.I) PHONE_RE = re.compile(r"\(?(\d{3})\)?[\s.\-](\d{3})[\s.\-](\d{4})") class FluetConnector(BaseConnector): source_id = "fluet" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for slug, name, addr_fallback, city, types_fallback in PAGES: try: listings.extend(self._fetch_building( slug, name, addr_fallback, city, types_fallback)) except Exception: continue return listings def _fetch_building(self, slug: str, name: str, addr_fallback: str, city: str, types_fallback: list[str]) -> list[Listing]: url = f"{BASE}/{slug}" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # Wix : retirer les scripts/JSON embarqués avant d'extraire le texte for tag in soup(["script", "style", "noscript"]): tag.decompose() text = re.sub(r"[​]", "", soup.get_text("\n", strip=True)) flat = re.sub(r"\s+", " ", text) # typologies offertes (texte de la page, sinon repli configuré) — # attention au pied de page générique « … à Saint-Hyacinthe 3 ½, 4 ½, # 5 ½ » : on ne lit que le motif « Offre des logements/unités de » m = OFFER_RE.search(flat) unit_types = list(dict.fromkeys( normalize_unit_type(f"{n}½") for n in UNIT_TOKEN_RE.findall(m.group(1)))) if m else [] if not unit_types: unit_types = list(types_fallback) # adresse civique dans la prose (« Situé 920 Avenue Raymond à # Saint-Hyacinthe », « situé au 900, 910, 920 avenue Raymond ») address = addr_fallback m = re.search( r"[Ss]itu[ée]?e?\s+(?:au\s+)?(\d{1,5}[^.!<>]{3,70}?)\s+" r"[àa]\s+(Saint-Hyacinthe|Sainte-Marie-Madeleine)", flat) if m: address = f"{m.group(1).strip().rstrip(',')}, {m.group(2)}" # description : premier paragraphe substantiel après le nom description = "" for chunk in text.split("\n"): if len(chunk) > 140 and "cookie" not in chunk.lower(): description = chunk.strip()[:900] break # commodités : puces après « Ce que nos logements vous réservent » # (Manoir/Seigneurie/Triplex) ou après « Offre des logements » (LND) amenities: list[str] = [] seen_head = False for chunk in text.split("\n"): c = chunk.strip() if "vous réservent" in c or OFFER_RE.search(c): seen_head = True amenities = [] # repartir de l'en-tête le plus proche continue if seen_head and 8 < len(c) < 160 and not c.startswith("©") \ and "press to zoom" not in c.lower(): amenities.append(c) if len(amenities) >= 14 or (seen_head and c.startswith("©")): break if re.search(r"pr[ée]retrait[ée]s|retrait[ée]s autonomes", flat, re.I): amenities.insert(0, "Clientèle préretraitée/retraitée autonome") contact: dict = {} m = PHONE_RE.search(flat) if m: contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"w_(?:1?\d?\d|2\d\d)[,%]|logo", u, re.I)][:16] out: list[Listing] = [] for ut in unit_types: out.append(Listing( source=self.source_id, external_id=f"{slug}-{ut.replace('½', '.5')}", url=url, title=f"{name} — {ut}", address=address, city=city, unit_type=ut, description=description, amenities=list(dict.fromkeys(amenities))[:15], details={"contact": dict(contact)} if contact else {}, images=images, )) return out