SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.6 KB · 161 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/matinale.py : connecteur Gestion Matinale (gestionmatinale.com)5#   ~44 apparts + maisons à Sherbrooke, Magog et Windsor. WordPress +6#   WooCommerce : chaque logement offert est un « produit » — archives7#   /a-louer/appartements-logements/ (paginée, 16/page) et /a-louer/maison/.8#   Cartes li.product : titre « 1330 KING OUEST, SHERBROOKE, J1J 2B6 (Août) »9#   (adresse, ville, code postal + disponibilité entre parenthèses), prix10#   WooCommerce, type d'unité dans la taxonomie (classe product_cat-5-1-2) et11#   photo. external_id = ID WordPress (classe post-<id>).12#   ATTENTION robots.txt : « Crawl-Delay: 20 » -> request_delay de 20 s et13#   AUCUNE fiche produit visitée (archives seulement, 4-5 requêtes par sync).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, parse_price22from .base import BaseConnector2324BASE = "https://gestionmatinale.com"25ARCHIVES = [26    ("/a-louer/appartements-logements/", ""),27    ("/a-louer/maison/", "Maison"),28]2930_POST_ID_RE = re.compile(r"\bpost-(\d+)\b")31_CAT_TYPE_RE = re.compile(r"\bproduct_cat-(\d)-1-2\b")32_PARENS_RE = re.compile(r"\(([^)]+)\)\s*$")33_POSTAL_RE = re.compile(r"\b[A-Z]\d[A-Z]\s?\d[A-Z]\d\b")34_CITY_RE = re.compile(r"\b(SHERBROOKE|MAGOG|WINDSOR|EAST ANGUS|ROCK FOREST|"35                      r"FLEURIMONT|LENNOXVILLE|ORFORD|STUKELY-SUD|"36                      r"SAINTE-CATHERINE-DE-HATLEY)\b", re.I)373839class MatinaleConnector(BaseConnector):40    source_id = "matinale"41    request_delay = 20.0   # robots.txt : Crawl-Delay: 20 — respecté42    max_pages = 6          # garde-fou par archive (3 pages observées)4344    def fetch(self) -> list[Listing]:45        listings: dict[str, Listing] = {}46        for path, forced_type in ARCHIVES:47            for page in range(1, self.max_pages + 1):48                url = (f"{BASE}{path}" if page == 149                       else f"{BASE}{path}page/{page}/")50                try:51                    html = self.get(url).text52                except Exception:53                    break54                soup = BeautifulSoup(html, "html.parser")55                cards = soup.select("li.product")56                if not cards:57                    break58                for card in cards:59                    try:60                        lst = self._parse_card(card, forced_type)61                    except Exception:62                        continue63                    if lst and lst.external_id not in listings:64                        listings[lst.external_id] = lst65                # dernière page atteinte ?66                nums = [int(a.get_text(strip=True))67                        for a in soup.select("a.page-numbers, span.page-numbers")68                        if a.get_text(strip=True).isdigit()]69                if not nums or page >= max(nums):70                    break71        return list(listings.values())7273    # -- carte produit ----------------------------------------------------------------74    def _parse_card(self, card, forced_type: str) -> Listing | None:75        link = card.select_one("a.woocommerce-loop-product__link[href]")76        title_el = card.select_one("h2.woocommerce-loop-product__title")77        if not (link and title_el):78            return None79        url = link["href"]80        title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)).strip()8182        # exclusions : espaces non résidentiels vendus comme produits83        if re.search(r"stationnement|garage|rangement|entrep[oô]t", title, re.I):84            return None8586        classes = " ".join(card.get("class") or [])87        m = _POST_ID_RE.search(classes)88        ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]8990        # type d'unité : taxonomie WooCommerce (product_cat-5-1-2) ou archive91        unit_type = forced_type92        mt = _CAT_TYPE_RE.search(classes)93        if mt:94            n = int(mt.group(1))95            unit_type = "6½+" if n >= 6 else f"{n}½"96        elif re.search(r"product_cat-mais|product_cat-chalet", classes) \97                or re.search(r"\bmaison\b|\bchalet\b", title, re.I):98            unit_type = "Maison"99100        # disponibilité entre parenthèses du titre : « (Août) », « (Vacant) »,101        # « (Janvier 2027) » — certains titres la placent au milieu102        availability = ""103        mp = re.search(r"\(([^)]{2,25})\)", title)104        if mp:105            availability = mp.group(1).strip()106107        # adresse / ville / code postal depuis le titre108        clean = re.sub(r"\s*\([^)]*\)", "", title).strip(" ,")109        city = ""110        mc = _CITY_RE.search(clean)111        if mc:112            city = mc.group(1).title()113            if city in ("Rock Forest", "Fleurimont", "Lennoxville"):114                city = "Sherbrooke"115        postal = ""116        mpost = _POSTAL_RE.search(clean.upper())117        if mpost:118            postal = mpost.group(0)119        address = clean120121        # prix WooCommerce (promo : <ins> = prix courant)122        price = None123        price_label = ""124        price_el = card.select_one("span.price")125        if price_el:126            price_label = re.sub(r"\s+", " ",127                                 price_el.get_text(" ", strip=True)).strip()128            ins = price_el.select_one("ins .woocommerce-Price-amount")129            amount = ins or price_el.select_one(".woocommerce-Price-amount")130            if amount:131                price = parse_price(re.sub(132                    r"(\d),(\d{3})", r"\1\2",133                    amount.get_text(" ", strip=True)))134135        # photo (chargée en différé : data-src)136        images = []137        img = card.select_one("img")138        if img:139            src = (img.get("data-src") or img.get("src") or "").strip()140            src = re.sub(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", "",141                         src, flags=re.I)142            if src.startswith("http"):143                images.append(src)144145        details = {"postal_code": postal} if postal else {}146        return Listing(147            source=self.source_id,148            external_id=str(ext_id),149            url=url,150            title=title,151            address=address,152            sector="",153            city=city or "Sherbrooke",154            unit_type=unit_type,155            price=price,156            price_label=price_label,157            availability=availability,158            details=details,159            images=images,160        )161