spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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