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%
7.4 KB · 173 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/sept_elements.py : connecteur Les 7 Éléments5#   (condoles7elements.com — 1998, rue Marie-Anne-Gaudreau, Lebourgneuf,6#    Québec ; condos locatifs Propriétés Mach). Plateforme Vortex Solution7#    rendue serveur : la page d'accueil liste les unités disponibles en blocs8#    <div class="uniteItem"> (type 3½/4½, numéro d'unité, date de9#    disponibilité, plan PDF DATA/UNITE/<n>.pdf). Pas de prix affiché sur le10#    site — le plan PDF de chaque unité fournit par contre les superficies11#    (nette/brute/balcon), extraites par position avec pdfplumber via le12#    cache BD des pages détail. Sert aussi de classe de base au connecteur13#    `consolata` (La Consolata, même plateforme Vortex).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import io19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type24from .base import BaseConnector2526# éléments de navigation à exclure des listes <li> de commodités27_NAV_RE = re.compile(r"accueil|nous joindre|galerie|unit[ée]s|plans|"28                     r"disponibilit|english|fran[çc]ais|politique", re.I)29PHONE_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b")303132class SeptElementsConnector(BaseConnector):33    source_id = "sept_elements"34    request_delay = 0.635    max_details = 30     # garde-fou plans PDF (vraies requêtes par sync)3637    BASE = "https://www.condoles7elements.com"38    NAME = "Les 7 Éléments"39    ADDRESS = "1998, rue Marie-Anne-Gaudreau, Québec, G2K 0M3"40    SECTOR = "Lebourgneuf"41    CITY = "Québec"4243    def fetch(self) -> list[Listing]:44        listings: list[Listing] = []45        html = self.get(f"{self.BASE}/").text46        soup = BeautifulSoup(html, "html.parser")47        self._fetched = 04849        # photos de l'immeuble (DATA/PHOTO/*.jpg, hors logos/icônes)50        images = []51        for u in dict.fromkeys(re.findall(52                r'(?:src|href)="((?:DATA/PHOTO|gx/m)/[^"]+'53                r"\.(?:jpe?g|png|webp))\"", html, re.I)):54            if re.search(r"logo|icon|favicon|Mob~", u, re.I):55                continue56            images.append(f"{self.BASE}/{u.lstrip('/')}")57        images = images[:20]5859        # commodités : listes <li> du contenu (filtrées de la navigation)60        amenities = []61        for li in soup.find_all("li"):62            txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))63            if txt and len(txt) < 80 and not li.find("a") \64                    and not _NAV_RE.search(txt) and txt not in amenities:65                amenities.append(txt)66        amenities = amenities[:20]6768        # contact (téléphone affiché en texte)69        details_common: dict = {}70        pm = PHONE_RE.search(soup.get_text(" ", strip=True))71        if pm:72            details_common["contact"] = {73                "phone": f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}"}7475        for item in soup.select("div.uniteItem"):76            try:77                type_el = item.select_one(".uniteType")78                unit_type = normalize_unit_type(79                    type_el.get_text(strip=True)) if type_el else ""80                vals = [v.get_text(strip=True)81                        for v in item.select(".uniteItemValeur")82                        if v.get_text(strip=True)]83                unit_no = next((v for v in vals84                                if re.fullmatch(r"[\dA-Za-z\-]{1,12}", v)), "")85                if not unit_no:86                    continue87                availability = next(88                    (v for v in vals89                     if re.search(r"occupation|imm[ée]diate|libre|\d{4}",90                                  v, re.I) and v != unit_no), "")9192                plan_el = item.select_one('a[href*="DATA/UNITE/"]')93                plan_url = f"{self.BASE}/{plan_el['href'].lstrip('/')}" \94                    if plan_el and plan_el.get("href") else ""9596                details = dict(details_common)97                area = None98                if plan_url:99                    details["plan_pdf"] = plan_url100                    key = hashlib.sha1(101                        f"{plan_url}|{availability}".encode()).hexdigest()102                    try:103                        d = self.detail(unit_no, key,104                                        lambda u=plan_url: self._plan_pdf(u))105                        area = d.get("area_net")106                        if d.get("area_gross"):107                            details["superficie_brute_pi2"] = d["area_gross"]108                        if d.get("balcony"):109                            details["balcon_pi2"] = d["balcony"]110                    except Exception:111                        pass112113                listings.append(Listing(114                    source=self.source_id,115                    external_id=unit_no,116                    url=f"{self.BASE}/",117                    title=f"{unit_type} — unité {unit_no}, {self.NAME}",118                    address=self.ADDRESS,119                    sector=self.SECTOR,120                    city=self.CITY,121                    unit_type=unit_type,122                    availability=availability,123                    area_sqft=area,124                    amenities=list(amenities),125                    details=details,126                    images=images,127                ))128            except Exception:129                continue130        return listings131132    # -- plan PDF d'une unité : superficies extraites par position ------------------133    def _plan_pdf(self, url: str) -> dict:134        """Superficie Nette / Brute / Balcon (pi²) du cartouche du plan.135136        Les libellés sont dans une colonne fixe ; la valeur est le nombre137        aligné verticalement (±6 pt) à droite du libellé.138        """139        if self._fetched >= self.max_details:140            raise RuntimeError("budget de plans PDF atteint")141        self._fetched += 1142        import pdfplumber143        raw = self.get(url).content144        if not raw.startswith(b"%PDF"):    # lien mort servi en page HTML 200145            return {}146        out: dict = {}147        with pdfplumber.open(io.BytesIO(raw)) as pdf:148            words = pdf.pages[0].extract_words()149        for label, field, lo, hi in (("Nette", "area_net", 300, 3000),150                                     ("Brute", "area_gross", 300, 3000),151                                     ("Balcon", "balcony", 20, 500)):152            anchor = next((w for w in words153                           if w["text"].startswith(label)), None)154            if anchor is None:155                continue156            frags = sorted((w for w in words157                            if anchor["x1"] - 2 < w["x0"] < anchor["x1"] + 160158                            and abs(w["top"] - anchor["top"]) < 6159                            and re.fullmatch(r"\d{1,4}", w["text"])),160                           key=lambda w: w["x0"])161            # certains cartouches scindent la valeur (« 1 » + « 189 » = 1189) :162            # recoller les fragments de chiffres contigus (< 8 pt d'écart)163            num = ""164            prev_x1 = None165            for w in frags:166                if prev_x1 is not None and w["x0"] - prev_x1 > 8:167                    break168                num += w["text"]169                prev_x1 = w["x1"]170            if num and lo <= float(num) <= hi:171                out[field] = float(num)172        return out173