Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/fluet.py : connecteur Groupe Fluet (groupefluet.com)5# Gestionnaire de Saint-Hyacinthe — logements pour préretraités/retraités6# autonomes (Manoir des Cascades, Seigneurie des Pères, Le Notre-Dame7# phases I-II-III) + triplex rue Noiseux à Sainte-Marie-Madeleine.8# Site Wix MAIS rendu serveur : le HTML brut contient le texte des pages —9# pas besoin de rendu JS. Une page par immeuble, typologies dans le texte10# (« Offre des logements : 3½, 4½ et 5 ½ »).11# Granularité : une annonce par immeuble × typologie (aucun prix publié).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type20from .base import BaseConnector2122BASE = "https://www.groupefluet.com"2324# (slug de page, nom d'immeuble, adresse repli, ville, typologies repli)25PAGES = [26 ("manoirdescascades", "Manoir des Cascades",27 "Saint-Hyacinthe", "Saint-Hyacinthe", ["2½", "3½", "4½"]),28 ("seigneurie", "Seigneurie des Pères",29 "2250, rue Girouard Ouest, Saint-Hyacinthe", "Saint-Hyacinthe",30 ["3½", "4½", "5½"]),31 ("copie-de-le-notre-dame", "Le Notre-Dame — Phase 1",32 "900, avenue Raymond, Saint-Hyacinthe", "Saint-Hyacinthe",33 ["3½", "4½", "5½"]),34 ("lenotredame2", "Le Notre-Dame — Phase 2",35 "920, avenue Raymond, Saint-Hyacinthe", "Saint-Hyacinthe",36 ["3½", "4½", "5½"]),37 ("lenotredame3", "Le Notre-Dame — Phase 3",38 "910, avenue Raymond, Saint-Hyacinthe", "Saint-Hyacinthe",39 ["3½", "4½", "5½"]),40 ("general-6", "Triplex rue Noiseux",41 "Rue Noiseux, Sainte-Marie-Madeleine", "Sainte-Marie-Madeleine", ["4½"]),42]4344# « Offre des logements : 3½, 4½ et 5 ½ » / « 19 unités de 2½, 3½ et 4½ »45OFFER_RE = re.compile(46 r"(?:Offre des logements\s*:?|unit[ée]s de)\s*([\d\s½,/et]+)", re.I)47UNIT_TOKEN_RE = re.compile(r"\b(\d)\s*(?:½|1/2)")48IMG_RE = re.compile(49 r"https://static\.wixstatic\.com/media/[\w~%]+\.(?:jpe?g|png|webp)"50 r"/v1/fill/[^\"\s\\)]+\.(?:jpe?g|png|webp)", re.I)51PHONE_RE = re.compile(r"\(?(\d{3})\)?[\s.\-](\d{3})[\s.\-](\d{4})")525354class FluetConnector(BaseConnector):55 source_id = "fluet"56 request_delay = 0.85758 def fetch(self) -> list[Listing]:59 listings: list[Listing] = []60 for slug, name, addr_fallback, city, types_fallback in PAGES:61 try:62 listings.extend(self._fetch_building(63 slug, name, addr_fallback, city, types_fallback))64 except Exception:65 continue66 return listings6768 def _fetch_building(self, slug: str, name: str, addr_fallback: str,69 city: str, types_fallback: list[str]) -> list[Listing]:70 url = f"{BASE}/{slug}"71 html = self.get(url).text72 soup = BeautifulSoup(html, "html.parser")73 # Wix : retirer les scripts/JSON embarqués avant d'extraire le texte74 for tag in soup(["script", "style", "noscript"]):75 tag.decompose()76 text = re.sub(r"[]", "", soup.get_text("\n", strip=True))77 flat = re.sub(r"\s+", " ", text)7879 # typologies offertes (texte de la page, sinon repli configuré) —80 # attention au pied de page générique « … à Saint-Hyacinthe 3 ½, 4 ½,81 # 5 ½ » : on ne lit que le motif « Offre des logements/unités de »82 m = OFFER_RE.search(flat)83 unit_types = list(dict.fromkeys(84 normalize_unit_type(f"{n}½")85 for n in UNIT_TOKEN_RE.findall(m.group(1)))) if m else []86 if not unit_types:87 unit_types = list(types_fallback)8889 # adresse civique dans la prose (« Situé 920 Avenue Raymond à90 # Saint-Hyacinthe », « situé au 900, 910, 920 avenue Raymond »)91 address = addr_fallback92 m = re.search(93 r"[Ss]itu[ée]?e?\s+(?:au\s+)?(\d{1,5}[^.!<>]{3,70}?)\s+"94 r"[àa]\s+(Saint-Hyacinthe|Sainte-Marie-Madeleine)", flat)95 if m:96 address = f"{m.group(1).strip().rstrip(',')}, {m.group(2)}"9798 # description : premier paragraphe substantiel après le nom99 description = ""100 for chunk in text.split("\n"):101 if len(chunk) > 140 and "cookie" not in chunk.lower():102 description = chunk.strip()[:900]103 break104105 # commodités : puces après « Ce que nos logements vous réservent »106 # (Manoir/Seigneurie/Triplex) ou après « Offre des logements » (LND)107 amenities: list[str] = []108 seen_head = False109 for chunk in text.split("\n"):110 c = chunk.strip()111 if "vous réservent" in c or OFFER_RE.search(c):112 seen_head = True113 amenities = [] # repartir de l'en-tête le plus proche114 continue115 if seen_head and 8 < len(c) < 160 and not c.startswith("©") \116 and "press to zoom" not in c.lower():117 amenities.append(c)118 if len(amenities) >= 14 or (seen_head and c.startswith("©")):119 break120 if re.search(r"pr[ée]retrait[ée]s|retrait[ée]s autonomes", flat, re.I):121 amenities.insert(0, "Clientèle préretraitée/retraitée autonome")122123 contact: dict = {}124 m = PHONE_RE.search(flat)125 if m:126 contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"127128 images = [u for u in dict.fromkeys(IMG_RE.findall(html))129 if not re.search(r"w_(?:1?\d?\d|2\d\d)[,%]|logo", u, re.I)][:16]130131 out: list[Listing] = []132 for ut in unit_types:133 out.append(Listing(134 source=self.source_id,135 external_id=f"{slug}-{ut.replace('½', '.5')}",136 url=url,137 title=f"{name} — {ut}",138 address=address,139 city=city,140 unit_type=ut,141 description=description,142 amenities=list(dict.fromkeys(amenities))[:15],143 details={"contact": dict(contact)} if contact else {},144 images=images,145 ))146 return out147