# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/matinale.py : connecteur Gestion Matinale (gestionmatinale.com) # ~44 apparts + maisons à Sherbrooke, Magog et Windsor. WordPress + # WooCommerce : chaque logement offert est un « produit » — archives # /a-louer/appartements-logements/ (paginée, 16/page) et /a-louer/maison/. # Cartes li.product : titre « 1330 KING OUEST, SHERBROOKE, J1J 2B6 (Août) » # (adresse, ville, code postal + disponibilité entre parenthèses), prix # WooCommerce, type d'unité dans la taxonomie (classe product_cat-5-1-2) et # photo. external_id = ID WordPress (classe post-). # ATTENTION robots.txt : « Crawl-Delay: 20 » -> request_delay de 20 s et # AUCUNE fiche produit visitée (archives seulement, 4-5 requêtes par sync). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://gestionmatinale.com" ARCHIVES = [ ("/a-louer/appartements-logements/", ""), ("/a-louer/maison/", "Maison"), ] _POST_ID_RE = re.compile(r"\bpost-(\d+)\b") _CAT_TYPE_RE = re.compile(r"\bproduct_cat-(\d)-1-2\b") _PARENS_RE = re.compile(r"\(([^)]+)\)\s*$") _POSTAL_RE = re.compile(r"\b[A-Z]\d[A-Z]\s?\d[A-Z]\d\b") _CITY_RE = re.compile(r"\b(SHERBROOKE|MAGOG|WINDSOR|EAST ANGUS|ROCK FOREST|" r"FLEURIMONT|LENNOXVILLE|ORFORD|STUKELY-SUD|" r"SAINTE-CATHERINE-DE-HATLEY)\b", re.I) class MatinaleConnector(BaseConnector): source_id = "matinale" request_delay = 20.0 # robots.txt : Crawl-Delay: 20 — respecté max_pages = 6 # garde-fou par archive (3 pages observées) def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for path, forced_type in ARCHIVES: for page in range(1, self.max_pages + 1): url = (f"{BASE}{path}" if page == 1 else f"{BASE}{path}page/{page}/") try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") cards = soup.select("li.product") if not cards: break for card in cards: try: lst = self._parse_card(card, forced_type) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # dernière page atteinte ? nums = [int(a.get_text(strip=True)) for a in soup.select("a.page-numbers, span.page-numbers") if a.get_text(strip=True).isdigit()] if not nums or page >= max(nums): break return list(listings.values()) # -- carte produit ---------------------------------------------------------------- def _parse_card(self, card, forced_type: str) -> Listing | None: link = card.select_one("a.woocommerce-loop-product__link[href]") title_el = card.select_one("h2.woocommerce-loop-product__title") if not (link and title_el): return None url = link["href"] title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)).strip() # exclusions : espaces non résidentiels vendus comme produits if re.search(r"stationnement|garage|rangement|entrep[oô]t", title, re.I): return None classes = " ".join(card.get("class") or []) m = _POST_ID_RE.search(classes) ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1] # type d'unité : taxonomie WooCommerce (product_cat-5-1-2) ou archive unit_type = forced_type mt = _CAT_TYPE_RE.search(classes) if mt: n = int(mt.group(1)) unit_type = "6½+" if n >= 6 else f"{n}½" elif re.search(r"product_cat-mais|product_cat-chalet", classes) \ or re.search(r"\bmaison\b|\bchalet\b", title, re.I): unit_type = "Maison" # disponibilité entre parenthèses du titre : « (Août) », « (Vacant) », # « (Janvier 2027) » — certains titres la placent au milieu availability = "" mp = re.search(r"\(([^)]{2,25})\)", title) if mp: availability = mp.group(1).strip() # adresse / ville / code postal depuis le titre clean = re.sub(r"\s*\([^)]*\)", "", title).strip(" ,") city = "" mc = _CITY_RE.search(clean) if mc: city = mc.group(1).title() if city in ("Rock Forest", "Fleurimont", "Lennoxville"): city = "Sherbrooke" postal = "" mpost = _POSTAL_RE.search(clean.upper()) if mpost: postal = mpost.group(0) address = clean # prix WooCommerce (promo : = prix courant) price = None price_label = "" price_el = card.select_one("span.price") if price_el: price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)).strip() ins = price_el.select_one("ins .woocommerce-Price-amount") amount = ins or price_el.select_one(".woocommerce-Price-amount") if amount: price = parse_price(re.sub( r"(\d),(\d{3})", r"\1\2", amount.get_text(" ", strip=True))) # photo (chargée en différé : data-src) images = [] img = card.select_one("img") if img: src = (img.get("data-src") or img.get("src") or "").strip() src = re.sub(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", "", src, flags=re.I) if src.startswith("http"): images.append(src) details = {"postal_code": postal} if postal else {} return Listing( source=self.source_id, external_id=str(ext_id), url=url, title=title, address=address, sector="", city=city or "Sherbrooke", unit_type=unit_type, price=price, price_label=price_label, availability=availability, details=details, images=images, )