# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/courtemanche.py : connecteur Gestion Immobilière Courtemanche # (gestioncourtemanche.ca — Saguenay : Chicoutimi/Chicoutimi-Nord, plusieurs # centaines d'unités, leader du Saguenay). WordPress + Beaver Builder + # plugin maison « realestate », tout rendu serveur. Liste /location/tous/ # (taxonomie real-estate-location) : cartes `.real-estate-box` avec type # (« 1 1/2 »), adresse, prix (« 700 $ / mois »), inclusions et date « Libre # le » ; quand aucun logement n'est libre, la page affiche « Aucun logement # disponible… » et le connecteur retourne une liste vide — situation normale # sur ce marché (taux d'inoccupation très bas au Saguenay). Fiche détail # /immobilier// (via cache BD) : description, caractéristiques # (taxonomie real-estate-characteristic), GPS (carte Google du plugin) et # secteur (lien « Retour » vers /location//). # robots.txt Yoast ouvert, sitemaps real-estate-location/-characteristic. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from urllib.parse import unquote from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://gestioncourtemanche.ca" LIST_URL = f"{BASE}/location/tous/" _BG_URL_RE = re.compile(r"background-image\s*:\s*url\(([^)]+)\)") _MAP_LATLNG_RE = re.compile(r"LatLng\((-?\d+\.\d+),\s*(-?\d+\.\d+)\)") # libellés des secteurs (taxonomie real-estate-location du site) _SECTORS = { "chicoutimi-centres-commerciaux": "Chicoutimi — Centres commerciaux", "chicoutimi-uqac-cegep": "Chicoutimi — UQAC/CÉGEP", "chicoutimi-nord": "Chicoutimi-Nord", } class CourtemancheConnector(BaseConnector): source_id = "courtemanche" request_delay = 0.6 max_details = 40 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} # cartes présentes seulement quand des logements sont libres ; sinon la # page affiche « Aucun logement disponible dans ce secteur… » -> [] for card in soup.select(".real-estate-list .real-estate-box"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte (plugin realestate) --------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('a.real-estate-gallery[href*="/immobilier/"]') \ or card.select_one('a[href*="/immobilier/"]') if not link: return url = link["href"] m = re.search(r"/immobilier/([^/?#]+)", url) if not m: return ext_id = unquote(m.group(1)).strip("/") if not ext_id or ext_id in listings: return infos = card.select_one(".real-estate-infos") if not infos: return type_el = infos.select_one(".type") unit_label = type_el.get_text(strip=True) if type_el else "" addr_el = infos.select_one(".address") address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)) if addr_el else "" #
: Prix (« 700 $ / mois »), Inclus, Disponible le (AAAA-MM-JJ) price_el = infos.select_one("dd.prix") price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) if price_el else "" incl_el = infos.select_one("dd.inclus") included = incl_el.get_text(" ", strip=True) if incl_el else "" libre_el = infos.select_one("dd.libre") availability = libre_el.get_text(strip=True) if libre_el else "" # galerie : diaporama en background-image (liste et fiche) images: list[str] = [] for div in card.select(".real-estate-gallery div[style]"): m_bg = _BG_URL_RE.search(div.get("style", "")) if m_bg: u = m_bg.group(1).strip("'\" ") if u.startswith("http") and u not in images: images.append(u) amenities = [f"Inclus : {included}"] if included else [] lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{unit_label} — {address}".strip(" —"), address=address, city="Saguenay", # parc à Chicoutimi (ville de Saguenay) unit_type=normalize_unit_type(unit_label), price=parse_price(price_label), price_label=price_label, availability=availability, amenities=amenities, images=images[:25], ) key = hashlib.sha1( f"{unit_label}|{price_label}|{availability}|{included}" .encode("utf-8")).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche détail (/immobilier//) -------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description, caractéristiques, GPS et secteur (lien « Retour »).""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} desc_el = soup.select_one(".real-estate-infos .description") if desc_el: txt = desc_el.get_text("\n", strip=True) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] out["characteristics"] = [ re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in soup.select("ul.characteristics li") if li.get_text(strip=True)][:20] # secteur : lien « Retour » vers la taxonomie /location// back = soup.select_one('.real-estate-return a[href*="/location/"]') if back: m = re.search(r"/location/([^/?#]+)", back["href"]) if m and m.group(1) != "tous": slug = m.group(1) out["sector"] = _SECTORS.get( slug, slug.replace("-", " ").strip().title()) m = _MAP_LATLNG_RE.search(html) if m: out["lat"], out["lng"] = float(m.group(1)), float(m.group(2)) # galerie pleine taille de la fiche (si plus complète que la liste) images: list[str] = [] for div in soup.select(".real-estate-gallery div[style]"): m_bg = _BG_URL_RE.search(div.get("style", "")) if m_bg: u = m_bg.group(1).strip("'\" ") if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:25] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("description"): lst.description = d["description"] if d.get("characteristics"): lst.amenities = list(dict.fromkeys(lst.amenities + d["characteristics"])) if d.get("sector"): lst.sector = d["sector"] if d.get("lat") is not None and d.get("lng") is not None: lst.lat, lst.lng = d["lat"], d["lng"] if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"]