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/lachance.py : connecteur Les Entreprises Lachance (lachance.qc.ca)5# Constructeur-gestionnaire de l'Estrie : Sherbrooke (Fleurimont, Brompton,6# Rock-Forest/St-Élie, Université), Magog, Orford, East Angus, Waterville.7# Craft CMS (SEOmatic) : découverte par le sitemap sectionnel dédié8# `forRentBuildingEntry` (une page par immeuble), chaque page immeuble liste9# TOUTES ses unités en cartes `a.card-for-rent` : prix (avec promo barrée),10# date de disponibilité (`.available-notice` — « Loué » quand non offert),11# adresse, étage, superficie/chambres/sdb (icônes), type (h3.size-title).12# Fiches unité visitées via self.detail() (cache BD, plafond max_details) :13# description riche + galerie complète (img.img-swiper).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://lachance.qc.ca"26SITEMAP_URL = f"{BASE}/sitemaps-1-section-forRentBuildingEntry-1-sitemap.xml"2728_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")29_AREA_RE = re.compile(r"^([\d\s,]+)\s*pi2?\b", re.I)3031# « Sherbrooke (Fleurimont) », « East-Angus », « Orford », « Waterville »…32_CITY_SECTOR_RE = re.compile(r"^\s*(.+?)\s*(?:\((.+)\))?\s*$")333435def _split_city_sector(raw: str) -> tuple[str, str]:36 """« Sherbrooke (Fleurimont) » -> (Sherbrooke, Fleurimont) ; « East-Angus »37 -> (East Angus, ""). Jamais de ville inventée : on retourne le texte source."""38 # « #105 Sherbrooke (Fleurimont) » / « Orford #1300 » : retirer le numéro39 # d'unité résiduel accolé à la ville40 raw = re.sub(r"^\s*#?\d+[A-Za-z]?\s+(?=[A-ZÉÈÀ])", "", raw or "")41 raw = re.sub(r"\s*#\s*\d+[A-Za-z]?\s*$", "", raw)42 m = _CITY_SECTOR_RE.match(raw)43 if not m:44 return "", ""45 city = m.group(1).replace("East-Angus", "East Angus").strip(" ,")46 return city, (m.group(2) or "").strip()474849class _DetailBudget(Exception):50 """Budget de fiches unité atteint pour cette synchronisation."""515253class LachanceConnector(BaseConnector):54 source_id = "lachance"55 request_delay = 0.656 max_buildings = 100 # garde-fou : pages immeuble du sitemap57 max_details = 40 # garde-fou : vraies requêtes de fiches unité5859 def fetch(self) -> list[Listing]:60 # 1) Découverte : sitemap sectionnel forRentBuildingEntry (une URL par61 # immeuble ; on ignore les fiches PDF également listées)62 xml = self.get(SITEMAP_URL).text63 building_urls = []64 for loc in _LOC_RE.findall(xml):65 if loc.lower().endswith(".pdf") or "/documents/" in loc:66 continue67 if loc.startswith(f"{BASE}/logements-a-louer/") and loc not in building_urls:68 building_urls.append(loc)6970 # 2) Pages immeuble : cartes unité (on saute les logements « Loué »)71 listings: dict[str, Listing] = {}72 for burl in building_urls[: self.max_buildings]:73 try:74 html = self.get(burl).text75 except Exception:76 continue77 soup = BeautifulSoup(html, "html.parser")78 for card in soup.select("a.card-for-rent"):79 try:80 lst = self._parse_card(card)81 except Exception:82 continue83 if lst and lst.external_id not in listings:84 listings[lst.external_id] = lst8586 # 3) Fiches unité (cache BD) : description riche + galerie complète87 self._detail_fetches = 088 for lst in listings.values():89 key = hashlib.sha1(90 f"{lst.price_label}|{lst.availability}|{lst.unit_type}"91 .encode("utf-8")).hexdigest()9293 def fetch_fn(u=lst.url):94 if self._detail_fetches >= self.max_details:95 raise _DetailBudget()96 self._detail_fetches += 197 return self._fetch_detail(u)9899 try:100 payload = self.detail(lst.external_id, key, fetch_fn)101 except _DetailBudget:102 continue103 except Exception:104 continue105 if payload.get("description"):106 lst.description = payload["description"]107 if payload.get("images"):108 lst.images = payload["images"]109110 return list(listings.values())111112 # -- carte unité (page immeuble) -------------------------------------------113 def _parse_card(self, card) -> Listing | None:114 href = (card.get("href") or "").split("?")[0].rstrip("/")115 m = re.search(r"/logements-a-louer/([\w\-%.]+)$", href)116 if not m:117 return None118 slug = m.group(1)119120 # disponibilité : « Dès maintenant », « 1 septembre 2026 »… ; « Loué »121 # (ou bandeau absent) = unité non offerte -> on saute122 notice = card.select_one(".available-notice")123 availability = notice.get_text(" ", strip=True) if notice else ""124 if not availability or re.search(r"lou[ée]", availability, re.I):125 return None126127 # prix : promo = prix courant en rouge, prix régulier barré128 # (text-decoration-line-through) ; price_label garde le texte affiché129 price = None130 price_label = ""131 h4 = card.select_one(".min-height-price h4")132 if h4:133 price_label = " ".join(h4.get_text(" ", strip=True).split())134 spans = h4.find_all("span")135 current = [s for s in spans136 if "text-decoration-line-through" not in (s.get("class") or [])]137 effective = (current[-1].get_text(" ", strip=True)138 if current else price_label)139 price = parse_price(effective) or parse_price(price_label)140141 # adresse : h3 de la carte, ex. « 1331, rue Quatre-Saisons,142 # Sherbrooke (Fleurimont) » — dernière portion = ville (secteur)143 addr_el = card.select_one("h3.font-size-14")144 address = " ".join(addr_el.get_text(" ", strip=True).split()) if addr_el else ""145 parts = [p.strip() for p in address.split(",") if p.strip()]146 city, sector = _split_city_sector(parts[-1]) if parts else ("", "")147148 # étage (libellé structuré de la carte)149 amenities: list[str] = []150 for p in card.select("p.font-size-14"):151 t = " ".join(p.get_text(" ", strip=True).split())152 if t.lower().startswith("étage"):153 amenities.append(t.replace("Étage: ", "Étage : "))154 break155156 # pied de carte : superficie (icône building), chambres (bed), sdb (bath)157 area = None158 for rd in card.select(".rent-detail"):159 svg = rd.select_one("svg")160 cls = " ".join(svg.get("class") or []) if svg else ""161 txt = " ".join(rd.get_text(" ", strip=True).split())162 if "building" in cls:163 m2 = _AREA_RE.match(txt)164 if m2:165 try:166 val = float(m2.group(1).replace(" ", "").replace(",", ""))167 if 80 <= val <= 20000:168 area = val169 except ValueError:170 pass171 elif "bed" in cls and txt:172 amenities.append(f"{txt} chambre(s)")173 elif "bath" in cls and txt:174 amenities.append(f"{txt} salle(s) de bain")175176 # type d'unité : bandeau h3.size-title de la carte (« 4 ½ », « Studio »)177 type_el = card.select_one("h3.size-title")178 unit_type = normalize_unit_type(179 type_el.get_text(" ", strip=True) if type_el else "")180181 img = card.select_one("img.img-card-for-rent")182 images = [img["src"]] if img and img.get("src", "").startswith("http") else []183184 return Listing(185 source=self.source_id,186 external_id=slug,187 url=f"{BASE}/logements-a-louer/{slug}",188 title=address or slug.replace("-", " "),189 address=address,190 sector=sector,191 city=city,192 unit_type=unit_type,193 price=price,194 price_label=price_label,195 availability=availability,196 area_sqft=area,197 amenities=amenities,198 images=images,199 )200201 # -- fiche unité -------------------------------------------------------------202 def _fetch_detail(self, url: str) -> dict:203 """Description riche (bloc .text-editor) + galerie (img.img-swiper)."""204 html = self.get(url).text205 soup = BeautifulSoup(html, "html.parser")206 payload: dict = {}207208 editor = soup.select_one(".col-info-desctiption .text-editor, .text-editor")209 if editor:210 txt = editor.get_text("\n", strip=True)211 payload["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]212213 images: list[str] = []214 for img in soup.select("img.img-swiper[src]"):215 src = img["src"]216 if src.startswith("http") and "/a-louer/" in src and src not in images:217 images.append(src)218 payload["images"] = images[:30]219 return payload220