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.4 KB · 158 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/solis_levis.py : Solis Lévis (solislevis.com) — RPA 55+ autonomes5#   Immeuble unique de 7 étages sur la rue Weyman à Lévis (Studio, 3½, 4½,6#   dès ~1 231 $/mois, plusieurs inclusions : eau chaude, électricité,7#   chauffage, climatisation). WordPress rendu serveur : la page8#   /appartements-retraites-autonomes/ embarque le sélecteur d'unités complet9#   dans un <script> « units = [[…]] » — un tableau par étage, chaque unité10#   avec numéro, état (Available/Reserved/Sold), typologie (0=Studio, 1=3½,11#   2=4½), type de plan, prix mensuel, superficies (logement + balcon) et12#   image du plan. Une annonce Lou-Ka par unité DISPONIBLE, prix exact.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing22from .base import BaseConnector2324PAGE_URL = "https://solislevis.com/appartements-retraites-autonomes/"25ADDRESS = "Rue Weyman, Lévis"2627# typologie du sélecteur (data-typology des filtres de la page)28_TYPOLOGY = {0: "Studio", 1: "3½", 2: "4½"}2930_UNITS_RE = re.compile(r"units\s*=\s*(\[\[[\s\S]*?\]\])\s*;")31_MAP_SOLIS_RE = re.compile(32    r'\{"nom":"Solis","adresse":"[^"]*","latitude":"([\d.\-]+)",'33    r'"longitude":"([\d.\-]+)"')34_IMG_RE = re.compile(35    r"https://solislevis\.com/app/uploads/[^\"\s\\']+\.(?:jpe?g|webp|png)", re.I)363738class SolisLevisConnector(BaseConnector):39    source_id = "solis_levis"40    request_delay = 0.641    max_images = 124243    # -- inclusions de l'immeuble (listes sous « Tous nos appartements44    #    offrent » et « Le prix mensuel inclut ») --------------------------------45    @staticmethod46    def _inclusions(soup: BeautifulSoup) -> list[str]:47        feats: list[str] = []48        # « Tous nos appartements offrent : » — liste <li> qui suit le titre49        el = soup.find(string=re.compile("appartements offrent", re.I))50        if el is not None:51            ul = el.parent.find_next("ul")52            if ul is not None:53                for li in ul.find_all("li")[:8]:54                    t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()55                    if 3 <= len(t) <= 80 and t not in feats:56                        feats.append(t)57        # « Le prix mensuel inclut : » — grille d'icônes (p.icon-descrpition,58        # coquille du thème incluse)59        for p in soup.select("p.icon-descrpition")[:8]:60            t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()61            if 2 <= len(t) <= 80 and t not in feats:62                feats.append(t)63        return feats6465    def fetch(self) -> list[Listing]:66        listings: list[Listing] = []67        try:68            html = self.get(PAGE_URL).text69        except Exception:70            return listings7172        m = _UNITS_RE.search(html)73        if not m:74            return listings75        try:76            floors = json.loads(m.group(1))77        except ValueError:78            return listings7980        soup = BeautifulSoup(html, "html.parser")8182        # coordonnées : point « Solis » de la carte du quartier (repli : centre83        # du secteur Weyman/Lévis relevé sur la page)84        lat, lng = 46.79696, -71.1587485        gm = _MAP_SOLIS_RE.search(html)86        if gm:87            try:88                lat, lng = float(gm.group(1)), float(gm.group(2))89            except ValueError:90                pass9192        # description : intro de la page + promotion en cours le cas échéant93        paras = []94        for p in soup.find_all("p"):95            t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()96            if len(t) >= 100:97                paras.append(t)98                break99        promo = ""100        pm = re.search(r"Promotion en cours\s*:\s*([^<]{5,120})", html)101        if pm:102            promo = "Promotion en cours : " + pm.group(1).strip()103        desc = "\n\n".join(x for x in (paras[0] if paras else "", promo) if x)104105        inclusions = self._inclusions(soup)106107        # photos de l'immeuble (héro/galerie de la page ; les plans d'unités108        # viennent en plus, annonce par annonce)109        building_imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))110                         if not re.search(r"logo|icon|favicon|plan|spinner|"111                                          r"\d{3}(?:@\d{3})?\.png", u, re.I)]112113        for floor in floors:114            for unit in floor:115                if (unit.get("state") or "") != "Available":116                    continue117                number = unit.get("number")118                price = unit.get("price")119                if number is None or not price:120                    continue121                typology = _TYPOLOGY.get(unit.get("typology"), "")122                if not typology:123                    continue124                level = int(number) // 100125                floor_label = "RDC" if level == 1 else f"{level}e étage"126                details = {127                    "Résidence pour aînés": "oui",128                    "Étage": floor_label,129                    "Type de plan": unit.get("type") or "",130                }131                area = unit.get("indoor_area")132                balcony = unit.get("balcony_area")133                if balcony:134                    details["Balcon"] = f"{balcony} pi²"135                images: list[str] = []136                if unit.get("plan_img_src"):137                    images.append(unit["plan_img_src"])138                images += building_imgs139                listings.append(Listing(140                    source=self.source_id,141                    external_id=f"solis-{number}",142                    url=PAGE_URL,143                    title=f"{typology} — Solis Lévis, unité {number}",144                    address=ADDRESS,145                    city="Lévis",146                    unit_type=typology,147                    price=float(price),148                    availability="Disponible",149                    area_sqft=float(area) if area else None,150                    description=desc,151                    amenities=list(inclusions),152                    details=details,153                    images=images[: self.max_images],154                    lat=lat,155                    lng=lng,156                ))157        return listings158