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/courtemanche.py : connecteur Gestion Immobilière Courtemanche5# (gestioncourtemanche.ca — Saguenay : Chicoutimi/Chicoutimi-Nord, plusieurs6# centaines d'unités, leader du Saguenay). WordPress + Beaver Builder +7# plugin maison « realestate », tout rendu serveur. Liste /location/tous/8# (taxonomie real-estate-location) : cartes `.real-estate-box` avec type9# (« 1 1/2 »), adresse, prix (« 700 $ / mois »), inclusions et date « Libre10# le » ; quand aucun logement n'est libre, la page affiche « Aucun logement11# disponible… » et le connecteur retourne une liste vide — situation normale12# sur ce marché (taux d'inoccupation très bas au Saguenay). Fiche détail13# /immobilier/<slug>/ (via cache BD) : description, caractéristiques14# (taxonomie real-estate-characteristic), GPS (carte Google du plugin) et15# secteur (lien « Retour » vers /location/<secteur>/).16# robots.txt Yoast ouvert, sitemaps real-estate-location/-characteristic.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import re22from urllib.parse import unquote2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price, strip_accents27from .base import BaseConnector2829BASE = "https://gestioncourtemanche.ca"30LIST_URL = f"{BASE}/location/tous/"3132_BG_URL_RE = re.compile(r"background-image\s*:\s*url\(([^)]+)\)")33_MAP_LATLNG_RE = re.compile(r"LatLng\((-?\d+\.\d+),\s*(-?\d+\.\d+)\)")3435# libellés des secteurs (taxonomie real-estate-location du site)36_SECTORS = {37 "chicoutimi-centres-commerciaux": "Chicoutimi — Centres commerciaux",38 "chicoutimi-uqac-cegep": "Chicoutimi — UQAC/CÉGEP",39 "chicoutimi-nord": "Chicoutimi-Nord",40}414243class CourtemancheConnector(BaseConnector):44 source_id = "courtemanche"45 request_delay = 0.646 max_details = 40 # garde-fou fiches détail (vraies requêtes par sync)4748 def fetch(self) -> list[Listing]:49 html = self.get(LIST_URL).text50 soup = BeautifulSoup(html, "html.parser")5152 self._fetched = 053 listings: dict[str, Listing] = {}54 # cartes présentes seulement quand des logements sont libres ; sinon la55 # page affiche « Aucun logement disponible dans ce secteur… » -> []56 for card in soup.select(".real-estate-list .real-estate-box"):57 try:58 self._parse_card(card, listings)59 except Exception:60 continue61 return list(listings.values())6263 # -- carte (plugin realestate) ---------------------------------------------------64 def _parse_card(self, card, listings: dict[str, Listing]) -> None:65 link = card.select_one('a.real-estate-gallery[href*="/immobilier/"]') \66 or card.select_one('a[href*="/immobilier/"]')67 if not link:68 return69 url = link["href"]70 m = re.search(r"/immobilier/([^/?#]+)", url)71 if not m:72 return73 ext_id = unquote(m.group(1)).strip("/")74 if not ext_id or ext_id in listings:75 return7677 infos = card.select_one(".real-estate-infos")78 if not infos:79 return80 type_el = infos.select_one(".type")81 unit_label = type_el.get_text(strip=True) if type_el else ""82 addr_el = infos.select_one(".address")83 address = re.sub(r"\s+", " ",84 addr_el.get_text(" ", strip=True)) if addr_el else ""8586 # <dl> : Prix (« 700 $ / mois »), Inclus, Disponible le (AAAA-MM-JJ)87 price_el = infos.select_one("dd.prix")88 price_label = re.sub(r"\s+", " ",89 price_el.get_text(" ", strip=True)) if price_el else ""90 incl_el = infos.select_one("dd.inclus")91 included = incl_el.get_text(" ", strip=True) if incl_el else ""92 libre_el = infos.select_one("dd.libre")93 availability = libre_el.get_text(strip=True) if libre_el else ""9495 # galerie : diaporama en background-image (liste et fiche)96 images: list[str] = []97 for div in card.select(".real-estate-gallery div[style]"):98 m_bg = _BG_URL_RE.search(div.get("style", ""))99 if m_bg:100 u = m_bg.group(1).strip("'\" ")101 if u.startswith("http") and u not in images:102 images.append(u)103104 amenities = [f"Inclus : {included}"] if included else []105106 lst = Listing(107 source=self.source_id,108 external_id=ext_id,109 url=url,110 title=f"{unit_label} — {address}".strip(" —"),111 address=address,112 city="Saguenay", # parc à Chicoutimi (ville de Saguenay)113 unit_type=normalize_unit_type(unit_label),114 price=parse_price(price_label),115 price_label=price_label,116 availability=availability,117 amenities=amenities,118 images=images[:25],119 )120121 key = hashlib.sha1(122 f"{unit_label}|{price_label}|{availability}|{included}"123 .encode("utf-8")).hexdigest()124 try:125 payload = self.detail(ext_id, key,126 lambda u=url: self._fetch_detail(u))127 self._apply_detail(lst, payload)128 except Exception:129 pass130 listings[ext_id] = lst131132 # -- fiche détail (/immobilier/<slug>/) --------------------------------------------133 def _fetch_detail(self, url: str) -> dict:134 """Description, caractéristiques, GPS et secteur (lien « Retour »)."""135 if self._fetched >= self.max_details:136 raise RuntimeError("budget de fiches détail atteint")137 self._fetched += 1138 html = self.get(url).text139 soup = BeautifulSoup(html, "html.parser")140 out: dict = {}141142 desc_el = soup.select_one(".real-estate-infos .description")143 if desc_el:144 txt = desc_el.get_text("\n", strip=True)145 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]146147 out["characteristics"] = [148 re.sub(r"\s+", " ", li.get_text(" ", strip=True))149 for li in soup.select("ul.characteristics li")150 if li.get_text(strip=True)][:20]151152 # secteur : lien « Retour » vers la taxonomie /location/<slug>/153 back = soup.select_one('.real-estate-return a[href*="/location/"]')154 if back:155 m = re.search(r"/location/([^/?#]+)", back["href"])156 if m and m.group(1) != "tous":157 slug = m.group(1)158 out["sector"] = _SECTORS.get(159 slug, slug.replace("-", " ").strip().title())160161 m = _MAP_LATLNG_RE.search(html)162 if m:163 out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))164165 # galerie pleine taille de la fiche (si plus complète que la liste)166 images: list[str] = []167 for div in soup.select(".real-estate-gallery div[style]"):168 m_bg = _BG_URL_RE.search(div.get("style", ""))169 if m_bg:170 u = m_bg.group(1).strip("'\" ")171 if u.startswith("http") and u not in images:172 images.append(u)173 out["images"] = images[:25]174 return out175176 def _apply_detail(self, lst: Listing, d: dict) -> None:177 """Reporte le payload (frais ou en cache) sur l'annonce."""178 if not d:179 return180 if d.get("description"):181 lst.description = d["description"]182 if d.get("characteristics"):183 lst.amenities = list(dict.fromkeys(lst.amenities + d["characteristics"]))184 if d.get("sector"):185 lst.sector = d["sector"]186 if d.get("lat") is not None and d.get("lng") is not None:187 lst.lat, lst.lng = d["lat"], d["lng"]188 if d.get("images") and len(d["images"]) > len(lst.images):189 lst.images = d["images"]190