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.7 KB · 167 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/pro_urbain.py : connecteur Gestion Pro-Urbain5#   (gestionpro-urbain.com). Gestionnaire locatif de Lanaudière / Rive-Nord /6#   Grand Montréal (Charlemagne, Repentigny, Berthierville, Saint-Paul,7#   Saint-Jacques, Saint-Charles-Borromée…). Site October CMS (plugin maison8#   « nerd/realestate ») rendu serveur : /fr/proprietes liste des cartes par9#   UNITÉ (adresse, ville, code postal, prix/mois, type n½, sdb, chambres,10#   stationnements, disponibilité), page /fr/propriete/<slug> pour la11#   description, les inclusions (puces « • ») et les photos (via cache12#   detail). Granularité UNITÉ, external_id = slug de la fiche.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://gestionpro-urbain.com"24LIST_URL = f"{BASE}/fr/proprietes"2526TYPE_RE = re.compile(r"^\d\s*(?:½|1/2)$")27COUNT_RE = re.compile(r"^\d+(?:[.,]\d)?$")28AVAIL_RE = re.compile(r"Disponibilit[ée]\s*:\s*([^|<]{2,40})", re.I)29IMG_RE = re.compile(30    r"https://gestionpro-urbain\.com/storage/app/uploads/[^\"'\s\\)]+?"31    r"\.(?:jpg|jpeg|png|webp)", re.I)32BULLET_RE = re.compile(r"•\s*([^|<>•]{3,90})")333435class ProUrbainConnector(BaseConnector):36    source_id = "pro_urbain"37    request_delay = 0.73839    def _detail_payload(self, url: str) -> dict:40        """Description, inclusions et photos de la fiche /fr/propriete/…"""41        try:42            html = self.get(url).text43        except Exception:44            return {}45        soup = BeautifulSoup(html, "html.parser")46        payload: dict = {}47        images = [u for u in dict.fromkeys(IMG_RE.findall(html))][:20]48        if images:49            payload["images"] = images50        # bloc Description : premier paragraphe substantiel après le titre51        desc = ""52        node = soup.find(string=re.compile(r"^\s*Description\s*$"))53        if node is not None:54            for p in node.find_all_next(["p", "div"]):55                t = p.get_text(" ", strip=True)56                if len(t) > 60:57                    desc = t58                    break59                if "Caractéristiques" in t:60                    break61        if desc:62            payload["description"] = desc[:1500]63        text = soup.get_text("|", strip=True)64        amenities = []65        for b in BULLET_RE.findall(text):66            b = b.strip(" .")67            if b and b not in amenities:68                amenities.append(b)69        if amenities:70            payload["amenities"] = amenities[:25]71        return payload7273    def fetch(self) -> list[Listing]:74        listings: list[Listing] = []75        # Échec du GET de la liste = erreur transitoire à propager (journalisée76        # en échec par ingest), pas un « succès » à 0 annonce.77        html = self.get(LIST_URL).text78        soup = BeautifulSoup(html, "html.parser")7980        seen: set[str] = set()81        for card in soup.select("div.mygrid"):82            link = card.select_one("a[href*='/fr/propriete/']")83            if link is None:84                continue85            url = link["href"]86            slug = url.rstrip("/").rsplit("/", 1)[-1]87            if not slug or slug in seen:88                continue89            seen.add(slug)9091            head = card.select_one(".gsMain h2")92            head_txt = head.get_text(" ", strip=True) if head else ""93            if head_txt and not head_txt.lower().startswith("appartement"):94                continue                     # commercial/autre : hors sujet95            am = AVAIL_RE.search(head_txt)96            availability = f"Disponibilité : {am.group(1).strip()}" if am else ""9798            addr_p = card.select_one(".gsMain p")99            address = city = postal = ""100            if addr_p is not None:101                parts = [x.strip() for x in102                         addr_p.get_text("|", strip=True).split("|") if x.strip()]103                if parts:104                    address = parts[0]105                if len(parts) > 1:106                    city = parts[1]107                if len(parts) > 2:108                    postal = parts[2]109110            price = None111            pr = card.select_one(".currentPrice")112            if pr is not None:113                digits = re.sub(r"[^\d]", "", pr.get_text())114                if digits:115                    price = float(digits)116117            # pictogrammes du pied de carte : type n½, sdb, chambres, autos118            unit_type, bathrooms, bedrooms, parking = "", None, None, None119            for ic in card.select(".gsFooter i"):120                img = ic.find("img")121                span = ic.find("span")122                if img is None or span is None:123                    continue124                src = img.get("src", "")125                val = span.get_text(" ", strip=True)126                if "room-svgrepo" in src and TYPE_RE.match(val):127                    unit_type = normalize_unit_type(val)128                elif "bathtub" in src and COUNT_RE.match(val):129                    bathrooms = float(val.replace(",", "."))130                elif "bed-svgrepo" in src and COUNT_RE.match(val):131                    bedrooms = float(val)132                elif "car-svgrepo" in src and COUNT_RE.match(val):133                    parking = int(val)134135            img = card.select_one(".gsHead img")136            cover = [img["src"]] if img is not None and img.get("src") else []137138            key = f"{price or 0:.0f}-{availability}-{unit_type}"139            payload = self.detail(slug, key,140                                  lambda u=url: self._detail_payload(u))141142            details: dict = {}143            if parking is not None:144                details["parking"] = parking145            if postal:146                details["postal_code"] = postal147            listings.append(Listing(148                source=self.source_id,149                external_id=slug,150                url=url,151                title=(f"{unit_type} — {address}, {city}"152                       if unit_type else f"{address}, {city}"),153                address=address,154                city=city,155                unit_type=unit_type,156                bedrooms=bedrooms,157                bathrooms=bathrooms,158                price=price,159                price_label=f"{price:.0f} $ /mois" if price else "",160                availability=availability,161                description=payload.get("description", ""),162                amenities=payload.get("amenities", []),163                details=details,164                images=payload.get("images") or cover,165            ))166        return listings167