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%
5.9 KB · 150 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/construction_app.py : connecteur Construction APP / Les5#   Habitations APP (constructionappinc.com) — constructeur de Portneuf qui6#   loue ses immeubles à Pont-Rouge, Donnacona, Saint-Marc-des-Carrières,7#   Portneuf, Saint-Basile, Val-Bélair et Sainte-Catherine-de-la-J.-Cartier.8#   WordPress/Divi rendu serveur : /vente-et-location/ empile des blocs9#   `.et_pb_row` — h1 « Ville (Québec) », libellé « LOGEMENT À LOUER »/10#   « MAISONS DE VILLE EN LOCATION », adresse, caractéristiques (chambres,11#   sdb, types n ½) et photos. Ni prix ni date (Tier B).12#   Granularité : immeuble (un bloc = une annonce).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, strip_accents21from .base import BaseConnector2223BASE = "https://www.constructionappinc.com"24LIST_URL = f"{BASE}/vente-et-location/"2526LABEL_RE = re.compile(r"(LOGEMENTS?|MAISONS? DE VILLE)"27                      r".{0,40}(À LOUER|EN LOCATION)", re.I | re.S)28CITY_RE = re.compile(r"^(.+?)\s*\(Qu[ée]bec\)$", re.I)29ADDR_RE = re.compile(r"^\d[\d\s\wàâéèêëîïôùûç.'-]*,\s*.+", re.I)30ADDR2_RE = re.compile(r"^(rue|avenue|av\.|boul)", re.I)31BED_RE = re.compile(r"Chambre\(?s?\)?\s*:\s*(\d+)", re.I)32BATH_RE = re.compile(r"Salle\(?s?\)?\s*de\s*bain\s*:\s*(\d+)", re.I)33TYPE_RE = re.compile(r"\b(\d)\s*1/2\b")3435CONTACT = {"email": "info@habitationsapp.com", "phone": "418-410-0715"}363738def _slug(text: str) -> str:39    return re.sub(r"-+", "-",40                  re.sub(r"[^a-z0-9]+", "-", strip_accents(text.lower()))41                  ).strip("-")424344class ConstructionAppConnector(BaseConnector):45    source_id = "construction_app"46    request_delay = 0.64748    def fetch(self) -> list[Listing]:49        listings: list[Listing] = []50        try:51            html = self.get(LIST_URL).text52        except Exception:53            return listings54        soup = BeautifulSoup(html, "html.parser")5556        seen: set[str] = set()57        for row in soup.select("div.et_pb_row"):58            try:59                lst = self._parse_row(row, seen)60            except Exception:61                continue62            if lst is not None:63                listings.append(lst)64        return listings6566    def _parse_row(self, row, seen: set[str]) -> Listing | None:67        txt = row.get_text("\n", strip=True)68        if not LABEL_RE.search(txt):69            return None70        lines = [ln.strip() for ln in txt.split("\n") if ln.strip()]7172        city = ""73        for ln in lines:74            m = CITY_RE.match(ln)75            if m:76                city = m.group(1).strip()77                break7879        address = next(80            (ln for ln in lines81             if ADDR_RE.match(ln) and not ln.lower().startswith(("t 4", "418"))82             and "1/2" not in ln), "")83        if not address:84            address = next((ln for ln in lines85                            if ADDR2_RE.match(ln) and "," in ln), "")86        if not address:87            return None                # bloc générique sans adresse88        if not city:                   # ville = dernier segment de l'adresse89            tail = address.split(",")[-1].strip()90            if tail and not re.match(r"^suite", tail, re.I):91                city = tail9293        ext_id = _slug(address)94        if not ext_id or ext_id in seen:95            return None96        seen.add(ext_id)9798        blob = " | ".join(lines)99        bedrooms = bathrooms = None100        m = BED_RE.search(blob)101        if m:102            bedrooms = float(m.group(1))103        m = BATH_RE.search(blob)104        if m:105            bathrooms = float(m.group(1))106        types = list(dict.fromkeys(f"{n}½" for n in TYPE_RE.findall(blob)))107        unit_type = types[0] if len(types) == 1 else ""108109        # commodités : lignes courtes ni adresse/ville/label/contacts110        amenities = [ln for ln in lines111                     if 3 < len(ln) <= 60112                     and ln not in (address, city)113                     and not CITY_RE.match(ln)114                     and not LABEL_RE.search(ln)115                     and not re.search(r"(chambre|salle|1/2|@|\d{3}[ -]\d{4}"116                                       r"|nouveau projet|contactez|photos?\b"117                                       r"|plans? des|en savoir)",118                                       ln, re.I)][:12]119120        images = [img.get("src", "") for img in row.find_all("img")121                  if img.get("src", "").startswith("http")122                  and "icon" not in img.get("src", "").lower()123                  and "logo" not in img.get("src", "").lower()][:10]124125        label = "MAISONS DE VILLE" if re.search(r"maisons? de ville",126                                                blob, re.I) else "Logement"127        return Listing(128            source=self.source_id,129            external_id=ext_id,130            url=LIST_URL,131            title=f"{address}",132            address=f"{address}" + (f", {city}" if city133                                    and city.lower() not in address.lower()134                                    else ""),135            city=city,136            unit_type=unit_type,137            bedrooms=bedrooms,138            bathrooms=bathrooms,139            price_label="Prix sur demande",140            availability="Disponible",141            description=f"{label} à louer au {address} ({city}) — "142                        "Les Habitations APP (Construction APP). "143                        + (f"Types offerts : {', '.join(types)}. "144                           if types else "") + " ".join(amenities[:6]),145            amenities=amenities,146            details={"contact": dict(CONTACT),147                     **({"types": types} if len(types) > 1 else {})},148            images=images,149        )150