# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/logispro_mauricie.py : connecteur LogisPro Mauricie (logispro.ca) # Trois-Rivières, Cap-de-la-Madeleine, Shawinigan, Bécancour, Nicolet. # ⚠️ Compagnie DISTINCTE de LogisPro Rimouski (logisprorimouski.com, # connecteur `logispro`). WordPress thème maison rendu serveur : # /appartements-a-louer/ = cartes (prix, adresse, typologie, lien # /apartments//) ; page détail = liste « Secteur / Prix / Grandeur / # Étage / … », description riche et galerie fancybox. # Granularité : une annonce par logement disponible. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://logispro.ca" LIST_URL = f"{BASE}/appartements-a-louer/" PRICE_RE = re.compile(r"([\d\s ,]+)\s*\$") # villes desservies, pour extraire la ville depuis l'adresse de la carte CITY_RE = re.compile( r"(Trois-Rivi[èe]res|Shawinigan|B[ée]cancour|Nicolet|" r"Cap-de-la-Madeleine|Gentilly|Saint-Maurice)", re.I) class LogisProMauricieConnector(BaseConnector): source_id = "logispro_mauricie" request_delay = 0.7 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") seen: set[str] = set() for card in soup.select("div.apartments-item"): try: a = card.select_one('a[href*="/apartments/"]') if a is None: continue url = a.get("href", "").split("?")[0] slug = url.rstrip("/").split("/")[-1] if not slug or slug in seen: continue seen.add(slug) price = None price_label = "" price_el = card.select_one("p.price") if price_el is not None: price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) m = PRICE_RE.search(price_label) if m: try: price = float(m.group(1).replace(" ", "") .replace(" ", "").replace(",", "")) except ValueError: pass addr_el = card.select_one("p.adress, p.address") address = (re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)) if addr_el else "") room_el = card.select_one("p.room") unit_type = normalize_unit_type( room_el.get_text(" ", strip=True) if room_el else "") city = "" m = CITY_RE.search(address) if m: city = m.group(1) if city.lower() in ("cap-de-la-madeleine", "gentilly"): city = ("Trois-Rivières" if "cap" in m.group(1).lower() else "Bécancour") card_key = hashlib.sha1( f"{price_label}|{address}|{unit_type}" .encode("utf-8")).hexdigest() d = self.detail(slug, card_key, lambda url=url: self._fetch_detail(url)) listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=address or slug, address=d.get("address") or address, sector=d.get("sector", ""), city=city or d.get("sector", ""), unit_type=unit_type or d.get("unit_type", ""), price=price, price_label=price_label, availability=d.get("availability", ""), description=d.get("description", ""), amenities=d.get("amenities") or [], images=d.get("images") or [], )) except Exception: continue return listings def _fetch_detail(self, url: str) -> dict: """Page détail : liste top_descript (Secteur/Prix/Grandeur/Étage/…), description riche (dispo « dès maintenant » incluse) et galerie.""" out: dict = {} try: html = self.get(url).text except Exception: return out soup = BeautifulSoup(html, "html.parser") amenities: list[str] = [] for li in soup.select("ul.top_descript li"): txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if not txt or ":" not in txt: continue label, _, value = txt.partition(":") label, value = label.strip(), value.strip() if not value: continue low = label.lower() if low == "secteur": out["sector"] = value elif low == "prix": pass # déjà sur la carte elif low == "grandeur": out["unit_type"] = normalize_unit_type(value) else: amenities.append(f"{label} : {value}") out["amenities"] = amenities[:20] desc_el = soup.select_one("div.description") if desc_el is not None: desc = desc_el.get_text("\n", strip=True) out["description"] = re.sub(r"\n{2,}", "\n", desc).strip()[:1200] m = re.search(r"Disponible[^\n•]*", desc, re.I) if m: out["availability"] = m.group(0).strip() # adresse complète avec code postal (dernier paragraphe) m = re.search(r"\d[\w#\-]*[^\n]*,\s*(?:QC|Qu[ée]bec)[^\n]*", desc) if m: out["address"] = m.group(0).replace(", Canada", "").strip() out["images"] = [ a.get("href", "").strip() for a in soup.select(".apartment__gallery a.fancybox[href]") if a.get("href", "").strip()][:25] return out