SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
5.6 KB · 134 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/quanta.py : connecteur Gestion Quanta (gestionquanta.ca)5#   Firme de gestion de l'Outaouais (Gatineau, Hull, Aylmer). Vieux site6#   ASP.NET rendu serveur : la page d'accueil embarque un carrousel7#   (#CAROUSEL) avec TOUTES les fiches de location — titre (« Appartement /8#   Maison / Condo à louer »), lien Location.aspx?RentalId=<id>&rented=<0|1>,9#   secteur/ville, loyer (« 890$ par mois, non chauffé ni éclairé »),10#   description et photo. Les unités louées portent rented=1 et un overlay11#   « rented » : elles sont exclues. Les pages Location.aspx elles-mêmes12#   répondent 404 (liens morts) : l'accueil est la seule source — l'URL13#   d'annonce pointe donc sur l'accueil avec un fragment stable.14#   robots.txt : Disallow /Rentals/ (chemin des photos, jamais visité par le15#   connecteur — seule la page d'accueil, autorisée, est requêtée).16#   external_id = RentalId (H4, G1, CH2…), stable côté site.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re21from urllib.parse import parse_qs, quote, urlparse2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type26from .base import BaseConnector2728BASE = "https://gestionquanta.ca"29HOME_URL = f"{BASE}/"3031# villes de l'Outaouais telles qu'affichées en fin de ligne secteur32_CITIES = ("hull", "gatineau", "aylmer", "buckingham", "masson")333435class QuantaConnector(BaseConnector):36    source_id = "quanta"37    request_delay = 1.038    max_images = 103940    def fetch(self) -> list[Listing]:41        soup = BeautifulSoup(self.get(HOME_URL).text, "html.parser")42        carousel = soup.select_one("#CAROUSEL .items") or soup43        listings: dict[str, Listing] = {}44        for item in carousel.find_all("div", recursive=False):45            try:46                self._parse_item(item, listings)47            except Exception:48                continue49        return list(listings.values())5051    def _parse_item(self, item, listings: dict[str, Listing]) -> None:52        link = item.select_one('a[href*="RentalId="]')53        if not link:54            return55        qs = parse_qs(urlparse(link["href"]).query)56        ext_id = (qs.get("RentalId") or [""])[0]57        if not ext_id or ext_id in listings:58            return59        # unité louée : drapeau rented=1 du lien ou overlay « rented »60        if (qs.get("rented") or ["0"])[0] == "1":61            return62        if item.select_one('img[src*="rented_overlay"]'):63            return6465        h2 = item.find("h2")66        title = h2.get_text(" ", strip=True) if h2 else ""67        title = re.sub(r"\s*\(voir\)\s*$", "", title).strip()6869        # <p>Secteur, Ville<br/><span class="price">890$ par mois, …</span></p>70        price_el = item.select_one("span.price")71        price_label = price_el.get_text(" ", strip=True) if price_el else ""72        location = ""73        p_loc = price_el.find_parent("p") if price_el else item.find("p")74        if p_loc:75            loc_txt = p_loc.get_text("\n", strip=True).split("\n")[0]76            if "$" not in loc_txt:77                location = re.sub(r"\s+", " ", loc_txt).strip()7879        # « Plateau, Hull » -> ville Gatineau (Hull/Aylmer = secteurs fusionnés)80        sector, city = location, ""81        parts = [p.strip() for p in location.split(",") if p.strip()]82        if parts and parts[-1].lower() in _CITIES:83            city = "Gatineau"84            if parts[-1].lower() == "gatineau" and len(parts) > 1:85                sector = ", ".join(parts[:-1])86        elif parts:87            city = parts[-1]88            sector = ", ".join(parts[:-1])8990        # description : les <p> hors ligne secteur/prix91        desc_parts: list[str] = []92        for p in item.find_all("p"):93            txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))94            if not txt or "$" in txt and price_label and price_label in txt:95                continue96            if txt == location or price_el and price_el in p.descendants:97                continue98            if p is p_loc:99                continue100            desc_parts.append(txt)101        description = "\n".join(desc_parts)[:2500]102103        # type d'unité : Maison/Condo du titre ; pour les appartements, le104        # « n chambres à coucher » de la description (jamais deviné sinon)105        unit_type = normalize_unit_type(title)106        if not re.fullmatch(r"\d½|\+|Studio|Loft|Chambre|Maison|Condo",107                            unit_type or ""):108            m = re.search(r"(\d+)\s*chambres?\b", description, re.I)109            unit_type = normalize_unit_type(f"{m.group(1)} chambres") if m else ""110111        # photo : chemin Windows « Rentals\263 Atmosphere\S_IMG.jpg » -> URL112        images: list[str] = []113        for im in item.select("img[src]")[: self.max_images]:114            src = (im.get("src") or "").replace("\\", "/")115            if not src or "rented_overlay" in src or src.startswith("Images/"):116                continue117            if not src.startswith("http"):118                src = f"{BASE}/" + quote(src.lstrip("/"), safe="/")119            if src not in images:120                images.append(src)121122        listings[ext_id] = Listing(123            source=self.source_id,124            external_id=ext_id,125            url=f"{HOME_URL}#location-{ext_id}",126            title=title,127            sector=sector,128            city=city or "Gatineau",129            unit_type=unit_type,130            price_label=price_label,131            description=description,132            images=images,133        )134