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%
10.1 KB · 231 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/groupe_maurice.py : Le Groupe Maurice — 37 résidences pour aînés5#   (RPA) au Québec (Montréal, Laval, Rive-Sud/Nord, Québec, Gatineau…).6#   WordPress avec API REST maison : /wp-json/api/v1/residences retourne les7#   37 résidences géolocalisées (adresse, lat/lng, ville, url, vignette).8#   Chaque page résidence (SSR) expose la grille des types d'unités dans des9#   <article class="y-bordered"> : « Studio | 408 pi.ca à 628 pi.ca |10#   À partir de 2 286 $ par mois ». Une annonce Lou-Ka par type × résidence,11#   prix plancher « à partir de », étiquetée Résidence pour aînés.12#   La même page (déjà téléchargée) fournit aussi la galerie de la résidence13#   (photos des appartements et des aires communes — carousel360 +14#   gallery--large + alentours), le texte de présentation et la liste des15#   caractéristiques des unités (« Le confort à votre mesure ») : on les16#   applique à chaque annonce sans aucune requête supplémentaire.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type25from .base import BaseConnector2627API = "https://www.legroupemaurice.com/wp-json/api/v1/residences"2829_ROW_RE = re.compile(30    r"^(?P<type>.+?)\s+(?P<a1>[\d\s]+)\s*pi\.?\s*ca(?:\s*à\s*(?P<a2>[\d\s]+)\s*pi\.?\s*ca)?"31    r"\s*À partir de\s*(?P<prix>[\d\s]+)\s*\$\s*par mois", re.S)32_TAG_RE = re.compile(r"<[^>]+>")33_IMG_EXT_RE = re.compile(r"\.(?:jpe?g|webp|png)$", re.I)34# visuels génériques partagés entre résidences (le carrousel « Les35# appartements » mélange des photos SEV/LJM/STH… d'autres résidences) et UI36_IMG_SKIP_RE = re.compile(r"(?:Carrousel_Appartements|Bloc_Votre_Appartement|"37                          r"logo|icon|favicon|picto)", re.I)38_SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+(?=\.)")39# « …_3.5-Salon-… » dans le nom de fichier : photo d'un type d'unité précis,40# priorisée pour l'annonce du type correspondant41_TYPE_IN_FN_RE = re.compile(r"[_-](\d\.\d)-")424344class GroupeMauriceConnector(BaseConnector):45    source_id = "groupe_maurice"46    request_delay = 0.647    max_images = 304849    def _rows(self, soup: BeautifulSoup) -> list[dict]:50        out = []51        for art in soup.find_all("article"):52            if "y-bordered" not in (art.get("class") or []):53                continue54            text = re.sub(r"\s+", " ", art.get_text(" ", strip=True))55            m = _ROW_RE.match(text)56            if not m:57                continue58            try:59                price = float(m.group("prix").replace(" ", ""))60                area = float(m.group("a1").replace(" ", ""))61            except ValueError:62                continue63            area_max = None64            if m.group("a2"):65                try:66                    area_max = float(m.group("a2").replace(" ", ""))67                except ValueError:68                    area_max = None69            out.append({"type": m.group("type").strip(),70                        "price": price, "area": area, "area_max": area_max})71        return out7273    # -- galerie de la résidence -------------------------------------------------74    def _gallery(self, soup: BeautifulSoup) -> list[str]:75        """Photos de la résidence, dans l'ordre des carrousels de la page :76        visite « carousel360 » (pièces des appartements types + communs),77        galerie des aires communes, puis les alentours. Les visuels78        génériques inter-résidences et les variantes redimensionnées sont79        écartés (dédoublonnage par nom de fichier)."""80        imgs: list[str] = []81        seen: set[str] = set()82        for sel in ("div.carousel360 img", "div.gallery.gallery--large img",83                    "div.carousel-residence__carousel img"):84            for im in soup.select(sel):85                src = (im.get("src") or im.get("data-src") or "").split("?")[0]86                fn = src.rsplit("/", 1)[-1]87                if not src.startswith("http") or not _IMG_EXT_RE.search(fn):88                    continue89                if _IMG_SKIP_RE.search(fn):90                    continue91                key = _SIZE_SUFFIX_RE.sub("", fn).lower()92                if key in seen:93                    continue94                seen.add(key)95                imgs.append(src)96        return imgs9798    # -- présentation + caractéristiques des unités --------------------------------99    @staticmethod100    def _presentation(soup: BeautifulSoup) -> tuple[str, str, list[str]]:101        """(intro, alentours, caractéristiques des unités) de la page.102103        Intro = premier paragraphe long de la page (texte d'accueil de la104        résidence) ; alentours = paragraphe sous « Aux alentours de la105        résidence » ; caractéristiques = liste « Le confort à votre mesure »106        (cuisine, balcon, insonorisation…)."""107        intro = alentours = ""108        for p in soup.find_all("p"):109            t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()110            if len(t) >= 120:111                intro = t112                break113        for h in soup.find_all("h2"):114            if "alentours" not in h.get_text(" ", strip=True).lower():115                continue116            sec = h117            for _ in range(5):118                sec = sec.parent119                if sec is None:120                    break121                for p in sec.find_all("p"):122                    t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()123                    if len(t) >= 120:124                        alentours = t125                        break126                if alentours:127                    break128            break129        feats: list[str] = []130        for h in soup.find_all("h2"):131            if "confort à votre mesure" not in h.get_text(" ", strip=True).lower():132                continue133            sec = h134            for _ in range(5):135                sec = sec.parent136                if sec is None:137                    break138                lis = sec.find_all("li")139                if lis:140                    for li in lis[:20]:141                        t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()142                        if 3 <= len(t) <= 90 and t not in feats:143                            feats.append(t)144                    break145            break146        return intro, alentours, feats147148    def fetch(self) -> list[Listing]:149        data = self.get(API).json()150        residences = (data.get("result") or {})151        if isinstance(residences, dict):152            residences = list(residences.values())153        out: list[Listing] = []154        for res in residences:155            url = res.get("url") or ""156            if not url:157                continue158            slug = url.rstrip("/").rsplit("/", 1)[-1]159            try:160                soup = BeautifulSoup(self.get(url).text, "html.parser")161            except Exception:162                continue163            rows = self._rows(soup)164            gallery = self._gallery(soup)165            intro, alentours, feats = self._presentation(soup)166167            excerpt = _TAG_RE.sub("", res.get("excerpt") or "").strip()168            # description : accueil de la page + extrait API + quartier —169            # sans doublon (l'extrait reprend parfois le début de l'intro)170            paras = []171            for t in (intro, excerpt, alentours):172                if t and all(t[:80] not in p for p in paras):173                    paras.append(t)174            desc = "\n\n".join(paras)175176            try:177                lat, lng = float(res.get("latitude")), float(res.get("longitude"))178            except (TypeError, ValueError):179                lat = lng = None180            for row in rows:181                raw_type = row["type"]182                unit_type = normalize_unit_type(raw_type)183                details = {"Résidence pour aînés": "oui"}184                if "den" in raw_type.lower() or "+" in raw_type:185                    details["Type d'unité (résidence)"] = raw_type186                if row.get("area_max") and row["area_max"] > row["area"]:187                    details["Superficie"] = (f"{row['area']:.0f} à "188                                             f"{row['area_max']:.0f} pi²")189                eid = f"{slug}-{re.sub(r'[^a-z0-9]+', '-', raw_type.lower()).strip('-')}"190191                # galerie : vignette (façade) d'abord, puis les photos du type192                # d'unité de l'annonce (« 3.5-Salon »…), puis le reste193                images: list[str] = []194                if res.get("thumbnail"):195                    images.append(res["thumbnail"])196                token = ""197                m = re.search(r"(\d)\s*[½]", raw_type)198                if m:199                    token = f"{m.group(1)}.5"200                matched, rest = [], []201                for u in gallery:202                    tm = _TYPE_IN_FN_RE.search(u.rsplit("/", 1)[-1])203                    if token and tm and tm.group(1) == token:204                        matched.append(u)205                    else:206                        rest.append(u)207                for u in matched + rest:208                    if u not in images:209                        images.append(u)210211                out.append(Listing(212                    source=self.source_id,213                    external_id=eid,214                    url=url,215                    title=f"{raw_type} — Résidence {res.get('residence', slug)}",216                    address=res.get("address") or "",217                    city=res.get("city") or "",218                    unit_type=unit_type,219                    price=row["price"],220                    price_label=f"À partir de {row['price']:,.0f} $ par mois"221                                .replace(",", " "),222                    area_sqft=row["area"],223                    description=desc,224                    amenities=list(feats),225                    details=details,226                    images=images[: self.max_images],227                    lat=lat,228                    lng=lng,229                ))230        return out231