SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.3 KB · 147 lines python
Raw Blame History
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