# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lachance.py : connecteur Les Entreprises Lachance (lachance.qc.ca) # Constructeur-gestionnaire de l'Estrie : Sherbrooke (Fleurimont, Brompton, # Rock-Forest/St-Élie, Université), Magog, Orford, East Angus, Waterville. # Craft CMS (SEOmatic) : découverte par le sitemap sectionnel dédié # `forRentBuildingEntry` (une page par immeuble), chaque page immeuble liste # TOUTES ses unités en cartes `a.card-for-rent` : prix (avec promo barrée), # date de disponibilité (`.available-notice` — « Loué » quand non offert), # adresse, étage, superficie/chambres/sdb (icônes), type (h3.size-title). # Fiches unité visitées via self.detail() (cache BD, plafond max_details) : # description riche + galerie complète (img.img-swiper). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://lachance.qc.ca" SITEMAP_URL = f"{BASE}/sitemaps-1-section-forRentBuildingEntry-1-sitemap.xml" _LOC_RE = re.compile(r"([^<]+)") _AREA_RE = re.compile(r"^([\d\s,]+)\s*pi2?\b", re.I) # « Sherbrooke (Fleurimont) », « East-Angus », « Orford », « Waterville »… _CITY_SECTOR_RE = re.compile(r"^\s*(.+?)\s*(?:\((.+)\))?\s*$") def _split_city_sector(raw: str) -> tuple[str, str]: """« Sherbrooke (Fleurimont) » -> (Sherbrooke, Fleurimont) ; « East-Angus » -> (East Angus, ""). Jamais de ville inventée : on retourne le texte source.""" # « #105 Sherbrooke (Fleurimont) » / « Orford #1300 » : retirer le numéro # d'unité résiduel accolé à la ville raw = re.sub(r"^\s*#?\d+[A-Za-z]?\s+(?=[A-ZÉÈÀ])", "", raw or "") raw = re.sub(r"\s*#\s*\d+[A-Za-z]?\s*$", "", raw) m = _CITY_SECTOR_RE.match(raw) if not m: return "", "" city = m.group(1).replace("East-Angus", "East Angus").strip(" ,") return city, (m.group(2) or "").strip() class _DetailBudget(Exception): """Budget de fiches unité atteint pour cette synchronisation.""" class LachanceConnector(BaseConnector): source_id = "lachance" request_delay = 0.6 max_buildings = 100 # garde-fou : pages immeuble du sitemap max_details = 40 # garde-fou : vraies requêtes de fiches unité def fetch(self) -> list[Listing]: # 1) Découverte : sitemap sectionnel forRentBuildingEntry (une URL par # immeuble ; on ignore les fiches PDF également listées) xml = self.get(SITEMAP_URL).text building_urls = [] for loc in _LOC_RE.findall(xml): if loc.lower().endswith(".pdf") or "/documents/" in loc: continue if loc.startswith(f"{BASE}/logements-a-louer/") and loc not in building_urls: building_urls.append(loc) # 2) Pages immeuble : cartes unité (on saute les logements « Loué ») listings: dict[str, Listing] = {} for burl in building_urls[: self.max_buildings]: try: html = self.get(burl).text except Exception: continue soup = BeautifulSoup(html, "html.parser") for card in soup.select("a.card-for-rent"): try: lst = self._parse_card(card) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # 3) Fiches unité (cache BD) : description riche + galerie complète self._detail_fetches = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.price_label}|{lst.availability}|{lst.unit_type}" .encode("utf-8")).hexdigest() def fetch_fn(u=lst.url): if self._detail_fetches >= self.max_details: raise _DetailBudget() self._detail_fetches += 1 return self._fetch_detail(u) try: payload = self.detail(lst.external_id, key, fetch_fn) except _DetailBudget: continue except Exception: continue if payload.get("description"): lst.description = payload["description"] if payload.get("images"): lst.images = payload["images"] return list(listings.values()) # -- carte unité (page immeuble) ------------------------------------------- def _parse_card(self, card) -> Listing | None: href = (card.get("href") or "").split("?")[0].rstrip("/") m = re.search(r"/logements-a-louer/([\w\-%.]+)$", href) if not m: return None slug = m.group(1) # disponibilité : « Dès maintenant », « 1 septembre 2026 »… ; « Loué » # (ou bandeau absent) = unité non offerte -> on saute notice = card.select_one(".available-notice") availability = notice.get_text(" ", strip=True) if notice else "" if not availability or re.search(r"lou[ée]", availability, re.I): return None # prix : promo = prix courant en rouge, prix régulier barré # (text-decoration-line-through) ; price_label garde le texte affiché price = None price_label = "" h4 = card.select_one(".min-height-price h4") if h4: price_label = " ".join(h4.get_text(" ", strip=True).split()) spans = h4.find_all("span") current = [s for s in spans if "text-decoration-line-through" not in (s.get("class") or [])] effective = (current[-1].get_text(" ", strip=True) if current else price_label) price = parse_price(effective) or parse_price(price_label) # adresse : h3 de la carte, ex. « 1331, rue Quatre-Saisons, # Sherbrooke (Fleurimont) » — dernière portion = ville (secteur) addr_el = card.select_one("h3.font-size-14") address = " ".join(addr_el.get_text(" ", strip=True).split()) if addr_el else "" parts = [p.strip() for p in address.split(",") if p.strip()] city, sector = _split_city_sector(parts[-1]) if parts else ("", "") # étage (libellé structuré de la carte) amenities: list[str] = [] for p in card.select("p.font-size-14"): t = " ".join(p.get_text(" ", strip=True).split()) if t.lower().startswith("étage"): amenities.append(t.replace("Étage: ", "Étage : ")) break # pied de carte : superficie (icône building), chambres (bed), sdb (bath) area = None for rd in card.select(".rent-detail"): svg = rd.select_one("svg") cls = " ".join(svg.get("class") or []) if svg else "" txt = " ".join(rd.get_text(" ", strip=True).split()) if "building" in cls: m2 = _AREA_RE.match(txt) if m2: try: val = float(m2.group(1).replace(" ", "").replace(",", "")) if 80 <= val <= 20000: area = val except ValueError: pass elif "bed" in cls and txt: amenities.append(f"{txt} chambre(s)") elif "bath" in cls and txt: amenities.append(f"{txt} salle(s) de bain") # type d'unité : bandeau h3.size-title de la carte (« 4 ½ », « Studio ») type_el = card.select_one("h3.size-title") unit_type = normalize_unit_type( type_el.get_text(" ", strip=True) if type_el else "") img = card.select_one("img.img-card-for-rent") images = [img["src"]] if img and img.get("src", "").startswith("http") else [] return Listing( source=self.source_id, external_id=slug, url=f"{BASE}/logements-a-louer/{slug}", title=address or slug.replace("-", " "), address=address, sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area, amenities=amenities, images=images, ) # -- fiche unité ------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description riche (bloc .text-editor) + galerie (img.img-swiper).""" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") payload: dict = {} editor = soup.select_one(".col-info-desctiption .text-editor, .text-editor") if editor: txt = editor.get_text("\n", strip=True) payload["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] images: list[str] = [] for img in soup.select("img.img-swiper[src]"): src = img["src"] if src.startswith("http") and "/a-louer/" in src and src not in images: images.append(src) payload["images"] = images[:30] return payload