SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.5 KB · 159 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/oxford.py : connecteur Appartements Oxford (appartoxford.com)5#   Complexe locatif de Lennoxville (Sherbrooke), près de l'Université6#   Bishop's. WordPress + Elementor : une fiche par TYPE d'unité (2½ tout7#   inclus, 3½, 4½, 5½), listées dans le menu « Logements » de la page8#   d'accueil. Chaque fiche affiche le prix (« À partir de 760$ / par mois »)9#   et, seulement quand des unités sont libres, un encart de disponibilité10#   (« 1 unité disponible 1er octobre 2026 », widget Elementor à visibilité11#   conditionnelle) : les fiches SANS cet encart ne sont pas des annonces12#   courantes et sont écartées. Caractéristiques (listes à pictos), description13#   et galerie extraites de la fiche. Peu de requêtes (accueil + ~4 fiches) :14#   pas de cache détail nécessaire — la disponibilité doit être relue à chaque15#   synchronisation.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from urllib.parse import unquote2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type26from .base import BaseConnector2728BASE = "https://appartoxford.com"2930# variantes redimensionnées WordPress -> pleine taille31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)3233# « 1 unité disponible 1er octobre 2026 » / « 2 unités disponibles maintenant »34_AVAIL_RE = re.compile(r"(\d+|une?)\s+unit[ée]s?\s+disponibles?\b", re.I)35_NO_AVAIL_RE = re.compile(r"aucune\s+unit[ée]", re.I)3637# bruit d'en-tête dans les listes à pictos (coordonnées du site)38_HEADER_NOISE_RE = re.compile(r"\+?1?\s?8\d{2}[-\s]\d{3}|Lennoxville,\s*Sherbrooke",39                              re.I)4041_MAX_FICHES = 12    # garde-fou (le menu compte ~4 fiches)424344def _clean_unit_type(raw: str) -> str:45    ut = normalize_unit_type(raw or "")46    if re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison", ut or ""):47        return ut48    return ""495051class OxfordConnector(BaseConnector):52    source_id = "oxford"53    request_delay = 0.75455    def fetch(self) -> list[Listing]:56        home = self.get(BASE + "/").text57        soup = BeautifulSoup(home, "html.parser")5859        # fiches du menu « Logements » : liens /appartement/<slug>/60        urls: list[str] = []61        for a in soup.select('a[href*="/appartement/"]'):62            url = (a.get("href") or "").split("#")[0]63            if not url.startswith("http"):64                url = BASE + url65            if url.rstrip("/") not in [u.rstrip("/") for u in urls]:66                urls.append(url)6768        listings: dict[str, Listing] = {}69        for url in urls[:_MAX_FICHES]:70            try:71                lst = self._parse_fiche(url)72            except Exception:73                continue74            if lst and lst.external_id not in listings:75                listings[lst.external_id] = lst76        return list(listings.values())7778    # -- fiche type d'unité -------------------------------------------------------79    def _parse_fiche(self, url: str) -> Listing | None:80        resp = self.get(url)81        url = resp.url or url          # les anciens slugs redirigent (301)82        m = re.search(r"/appartement/([^/?#]+)", url)83        if not m:84            return None85        # slug décodé + minuscule : « tres-grand-4½-a-louer » (dédoublonne86        # les variantes d'encodage %C2%BD / %c2%bd)87        ext_id = unquote(m.group(1)).lower()8889        html = resp.text90        soup = BeautifulSoup(html, "html.parser")91        text = soup.get_text(" ", strip=True)9293        # encart de disponibilité : widget texte Elementor à visibilité94        # conditionnelle, présent seulement si des unités sont libres95        availability = ""96        for w in soup.select(".elementor-widget-text-editor"):97            t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip()98            if _AVAIL_RE.match(t) and len(t) < 90:99                availability = t100                break101        if not availability or _NO_AVAIL_RE.search(availability):102            return None                 # pas d'unité libre = pas d'annonce103104        h1 = soup.select_one("h1")105        title = re.sub(r"\s+", " ", h1.get_text(" ", strip=True)).strip() \106            if h1 else ext_id107108        # prix : « À partir de | 1000$ / par mois » (widgets bannière)109        price_label = ""110        m_price = re.search(111            r"À partir de\s*(\d[\d\s,]*\$\s*(?:/\s*par mois|/\s*mois)?)", text)112        if m_price:113            price_label = "À partir de " + re.sub(114                r"\s+", " ", m_price.group(1)).strip()115116        # caractéristiques : listes à pictos Elementor (sans les coordonnées117        # affichées dans l'en-tête du site)118        amenities: list[str] = []119        for li in soup.select(".elementor-icon-list-text"):120            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()121            if (t and len(t) < 90 and t not in amenities122                    and not _HEADER_NOISE_RE.search(t)):123                amenities.append(t)124125        # description : contenu de l'article (widget theme-post-content de la126        # section Caractéristiques)127        description = ""128        for w in soup.select(".elementor-widget-theme-post-content, "129                             ".elementor-widget-text-editor"):130            t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip()131            if len(t) > 150 and not _AVAIL_RE.match(t):132                description = t[:1500]133                break134135        # galerie : photos des téléversements (logos/pictos exclus)136        images: list[str] = []137        for u in re.findall(138                r"https://appartoxford\.com/wp-content/uploads/[^\"'()\s]+"139                r"\.(?:jpg|jpeg|webp)", html, re.I):140            u = _SIZE_SUFFIX.sub("", u)141            if u not in images and not re.search(r"logo|icon", u, re.I):142                images.append(u)143144        return Listing(145            source=self.source_id,146            external_id=ext_id,147            url=url,148            title=title,149            address="",150            sector="Lennoxville",       # affiché en en-tête du site151            city="Sherbrooke",152            unit_type=_clean_unit_type(title),153            price_label=price_label,154            availability=availability,155            description=description,156            amenities=amenities[:25],157            images=images[:30],158        )159