# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_habitation.py : connecteur Gestion Habitation # (gestionhabitation.ca — Abitibi-Témiscamingue : Val-d'Or, Amos, Malartic ; # seul gestionnaire structuré couvrant Amos). WordPress + Oxygen Builder + # WP Grid Builder, tout rendu serveur. Liste /a-louer/ : cartes # `.location-card` (adresse, n° de porte, ville, secteur, disponibilité # « DISPONIBLE » / « juin 2026 » / « NON DISPONIBLE », prix « 900$/mois ») — # les cartes « NON DISPONIBLE » (logements loués) sont exclues. Fiche détail # /logement// (via cache BD) : bloc structuré `.location-data__item` # (pièces, chambres, salles de bain, chauffage/électricité/internet, # animaux, fumeur, stationnement, disponibilité), description # (`.location-more-infos`) et galerie (carrousel Oxygen, liens pleine taille). # robots.txt WP standard (Disallow /wp-admin/), sitemap XML. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://gestionhabitation.ca" LIST_URL = f"{BASE}/a-louer/" # suffixe de redimensionnement WordPress (« -224x300.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) # « 3 1/2 », « 3½ », « 4 et demi » dans le titre de la carte _TYPE_RE = re.compile(r"\b(\d)\s*(?:1/2|½|et\s+demi)", re.I) def _clean_price_label(label: str) -> str: """'1,250$/mois' -> '1250$/mois' compatible parse_price (virgule = milliers).""" return re.sub(r"(\d),(\d{3})", r"\1\2", label) def _oui_non(raw: str) -> str | None: """'Oui'/'Non' du bloc structuré -> 'oui'/'non', sinon None (inconnu).""" k = strip_accents((raw or "").strip().lower()) if k.startswith("oui"): return "oui" if k.startswith("non"): return "non" return None class GestionHabitationConnector(BaseConnector): source_id = "gestion_habitation" request_delay = 0.6 max_details = 30 # 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] = {} for card in soup.select(".location-card"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte (.location-card, grille WP Grid Builder rendue serveur) ----------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one("a.location-card__permalink[href]") if not link: return url = link["href"] m = re.search(r"/logement/([^/?#]+)", url) if not m: return ext_id = m.group(1).strip("/") if not ext_id or ext_id in listings: return # disponibilité : « DISPONIBLE », « juin 2026 »… — on saute les loués avail_el = card.select_one(".location-card__availability") availability = avail_el.get_text(" ", strip=True) if avail_el else "" if re.search(r"non\s+disponible", availability, re.I): return # titre = adresse (ou libellé libre) + n° de porte éventuel title_el = link.select_one(".location-card__address .ct-span") title = title_el.get_text(strip=True) if title_el else "" door_el = link.select_one(".location-card__door .ct-span") door = door_el.get_text(strip=True) if door_el else "" # 2e bloc : ville (Val-d'Or, Amos, Malartic) et secteur (Centre-ville…) city = sector = "" meta_blocks = card.select(".location-card__data-title") if len(meta_blocks) > 1: spans = meta_blocks[1].select(".ct-span") if spans: city = spans[0].get_text(strip=True) if len(spans) > 1: sector = spans[1].get_text(strip=True) # adresse civique seulement si le libellé en est une (commence par un n°) address = "" if re.match(r"^\d+[\s,]", title): address = title + (f", app. {door}" if door else "") if city: address += f", {city}" price_el = card.select_one(".location-card__price") period_el = card.select_one(".location-card__period") price_label = "" if price_el and price_el.get_text(strip=True): price_label = price_el.get_text(strip=True) + \ (period_el.get_text(strip=True) if period_el else "") img_el = card.select_one("img.location-card__image[src]") images = [_SIZE_SUFFIX.sub("", img_el["src"])] if img_el else [] # type d'unité : « 3 1/2 » du libellé de la carte (sinon fiche détail) unit_type = "" m_type = _TYPE_RE.search(title) if m_type: unit_type = normalize_unit_type(f"{m_type.group(1)} 1/2") lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=title + (f" #{door}" if door else ""), address=address, sector=sector, city=city, unit_type=unit_type, price_label=_clean_price_label(price_label), availability=availability, images=images, ) key = hashlib.sha1( f"{title}|{price_label}|{availability}|{city}" .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 (/logement//) ---------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Bloc structuré (pièces, animaux…), description et galerie complète.""" 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 = {} # paires titre/valeur du bloc .location-data (rendu serveur) fields: dict[str, str] = {} for it in soup.select(".location-data__item"): t_el = it.select_one(".location-data__item-title") v_el = it.select_one(".location-data__item-value") if not t_el: continue key = strip_accents(t_el.get_text(strip=True).rstrip(" :").lower()) val = v_el.get_text(" ", strip=True) if v_el else "" if val: fields[key] = val out["fields"] = fields desc_el = soup.select_one(".location-more-infos") if desc_el: txt = desc_el.get_text("\n", strip=True) txt = re.sub(r"^Sp[ée]cifications\n", "", txt) txt = re.sub(r"^Inclusions\n", "", txt) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] images: list[str] = [] for a in soup.select("a.oxy-carousel-builder_gallery-image[href]"): u = a["href"].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("images") and len(d["images"]) > len(lst.images): lst.images = d["images"] fields = d.get("fields") or {} amenities: list[str] = [] pieces = fields.get("nombre de pieces", "") if pieces.isdigit(): amenities.append(f"{pieces} pièce(s)") # le site affiche « n½ » pour n pièces : dérivation fidèle if not lst.unit_type: lst.unit_type = normalize_unit_type(f"{pieces} 1/2") if fields.get("chambre(s)"): amenities.append(f"{fields['chambre(s)']} chambre(s)") if fields.get("salle de bain(s)"): amenities.append(f"{fields['salle de bain(s)']} salle(s) de bain") for label, key in (("Chauffage", "chauffage"), ("Électricité", "electricite"), ("Internet", "internet"), ("Stationnement", "stationnement")): if fields.get(key): amenities.append(f"{label} : {fields[key]}") if fields.get("fumeur"): amenities.append(f"Fumeur : {fields['fumeur']}") lst.amenities = list(dict.fromkeys(lst.amenities + amenities)) lst.pets = _oui_non(fields.get("animaux", "")) if not lst.availability and fields.get("disponibilite"): lst.availability = fields["disponibilite"] if not lst.price_label and fields.get("prix"): lst.price_label = _clean_price_label(fields["prix"]) if not lst.city and fields.get("ville"): lst.city = fields["ville"] if not lst.sector and fields.get("secteur"): lst.sector = fields["secteur"]