SPB Git

spb/lou-ka Public

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

HTML 99.7%
4.9 KB · 120 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/novelo.py : connecteur Les Appartements Novelo inc.5#   (appartementsnovelo.com — Saguenay, secteur Jonquière/Arvida ; projet du6#   Groupe Dumont, ~50-100 appartements rénovés). WordPress + Elementor + WPML,7#   tout rendu serveur sur une page unique /louer-un-appartement/ : une section8#   par type d'unité (« Appartement — Loft », « — 3 1/2 », « — 4 1/2 »,9#   « — 5 1/2 ») avec prix « À partir de 901$/ mois », description, liste des10#   services offerts et galerie. Granularité par TYPE (pas d'unités11#   individuelles ni de dates) — types, prix et photos réels, précédent12#   immeubles_guillot. Adresse du complexe : rue Langelier, Saguenay.13#   robots.txt WP standard (wp-admin seulement), sitemap.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price, strip_accents22from .base import BaseConnector2324BASE = "https://appartementsnovelo.com"25LIST_URL = f"{BASE}/louer-un-appartement/"2627_HEAD_RE = re.compile(r"^Appartement\s*[—–-]\s*(.+)$", re.I)28_PRICE_RE = re.compile(r"À partir de\s*\d[\d\s]*\$\s*(?:/\s*mois)?", re.I)29# suffixe de redimensionnement WordPress (« -300x200.jpg » -> pleine taille)30_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)313233def _slugify(label: str) -> str:34    """« 4 1/2 » -> « 4-1-2 » ; « Loft » -> « loft » (identifiant stable)."""35    return re.sub(r"[^a-z0-9]+", "-", strip_accents(label.lower())).strip("-")363738class NoveloConnector(BaseConnector):39    source_id = "novelo"40    request_delay = 0.64142    def fetch(self) -> list[Listing]:43        html = self.get(LIST_URL).text44        soup = BeautifulSoup(html, "html.parser")4546        listings: dict[str, Listing] = {}47        # une section Elementor de premier niveau par type d'appartement48        for h in soup.find_all("h2"):49            m = _HEAD_RE.match(h.get_text(" ", strip=True))50            if not m:51                continue52            try:53                self._parse_section(h, m.group(1).strip(), listings)54            except Exception:55                continue56        return list(listings.values())5758    # -- section « Appartement — <type> » ---------------------------------------------59    def _parse_section(self, heading, unit_label: str,60                       listings: dict[str, Listing]) -> None:61        ext_id = _slugify(unit_label)62        if not ext_id or ext_id in listings:63            return6465        # remonter à la section Elementor de premier niveau66        sec = heading67        while sec.parent is not None and sec.name != "section":68            sec = sec.parent69        if sec.name != "section":70            return7172        # prix « À partir de 901$/ mois » (le plus bas du type)73        price_label = ""74        m = _PRICE_RE.search(sec.get_text(" ", strip=True))75        if m:76            price_label = re.sub(r"\s+", " ", m.group(0)).strip()7778        # description : paragraphes de la section (hors mentions de contact)79        paras: list[str] = []80        for p in sec.find_all("p"):81            txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))82            if not txt or _PRICE_RE.search(txt):83                continue84            if re.search(r"contactez|enquête de crédit|418-|courriel", txt, re.I):85                continue86            paras.append(txt)87        description = "\n".join(paras)[:1500]8889        # services offerts / caractéristiques : items de liste de la section90        amenities: list[str] = []91        for li in sec.find_all("li"):92            txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))93            if txt and txt not in amenities:94                amenities.append(txt)9596        # galerie de la section (photos réelles, pleine taille)97        images: list[str] = []98        for img in sec.find_all("img", src=True):99            u = img["src"]100            if not u.startswith("http") or "/wp-content/uploads/" not in u:101                continue102            u = _SIZE_SUFFIX.sub("", u)103            if u not in images:104                images.append(u)105106        listings[ext_id] = Listing(107            source=self.source_id,108            external_id=ext_id,109            url=f"{LIST_URL}#{ext_id}",110            title=f"Appartement {unit_label} — Les Appartements Novelo",111            address="2469, rue Langelier",      # complexe (bureau de location)112            city="Saguenay",113            unit_type=normalize_unit_type(unit_label),114            price=parse_price(price_label.replace("À partir de", "").strip()),115            price_label=price_label,116            description=description,117            amenities=amenities[:20],118            images=images[:25],119        )120