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/gestion_habitation.py : connecteur Gestion Habitation5# (gestionhabitation.ca — Abitibi-Témiscamingue : Val-d'Or, Amos, Malartic ;6# seul gestionnaire structuré couvrant Amos). WordPress + Oxygen Builder +7# WP Grid Builder, tout rendu serveur. Liste /a-louer/ : cartes8# `.location-card` (adresse, n° de porte, ville, secteur, disponibilité9# « DISPONIBLE » / « juin 2026 » / « NON DISPONIBLE », prix « 900$/mois ») —10# les cartes « NON DISPONIBLE » (logements loués) sont exclues. Fiche détail11# /logement/<slug>/ (via cache BD) : bloc structuré `.location-data__item`12# (pièces, chambres, salles de bain, chauffage/électricité/internet,13# animaux, fumeur, stationnement, disponibilité), description14# (`.location-more-infos`) et galerie (carrousel Oxygen, liens pleine taille).15# robots.txt WP standard (Disallow /wp-admin/), sitemap XML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import BaseConnector2627BASE = "https://gestionhabitation.ca"28LIST_URL = f"{BASE}/a-louer/"2930# suffixe de redimensionnement WordPress (« -224x300.jpg » -> pleine taille)31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)32# « 3 1/2 », « 3½ », « 4 et demi » dans le titre de la carte33_TYPE_RE = re.compile(r"\b(\d)\s*(?:1/2|½|et\s+demi)", re.I)343536def _clean_price_label(label: str) -> str:37 """'1,250$/mois' -> '1250$/mois' compatible parse_price (virgule = milliers)."""38 return re.sub(r"(\d),(\d{3})", r"\1\2", label)394041def _oui_non(raw: str) -> str | None:42 """'Oui'/'Non' du bloc structuré -> 'oui'/'non', sinon None (inconnu)."""43 k = strip_accents((raw or "").strip().lower())44 if k.startswith("oui"):45 return "oui"46 if k.startswith("non"):47 return "non"48 return None495051class GestionHabitationConnector(BaseConnector):52 source_id = "gestion_habitation"53 request_delay = 0.654 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync)5556 def fetch(self) -> list[Listing]:57 html = self.get(LIST_URL).text58 soup = BeautifulSoup(html, "html.parser")5960 self._fetched = 061 listings: dict[str, Listing] = {}62 for card in soup.select(".location-card"):63 try:64 self._parse_card(card, listings)65 except Exception:66 continue67 return list(listings.values())6869 # -- carte (.location-card, grille WP Grid Builder rendue serveur) -----------------70 def _parse_card(self, card, listings: dict[str, Listing]) -> None:71 link = card.select_one("a.location-card__permalink[href]")72 if not link:73 return74 url = link["href"]75 m = re.search(r"/logement/([^/?#]+)", url)76 if not m:77 return78 ext_id = m.group(1).strip("/")79 if not ext_id or ext_id in listings:80 return8182 # disponibilité : « DISPONIBLE », « juin 2026 »… — on saute les loués83 avail_el = card.select_one(".location-card__availability")84 availability = avail_el.get_text(" ", strip=True) if avail_el else ""85 if re.search(r"non\s+disponible", availability, re.I):86 return8788 # titre = adresse (ou libellé libre) + n° de porte éventuel89 title_el = link.select_one(".location-card__address .ct-span")90 title = title_el.get_text(strip=True) if title_el else ""91 door_el = link.select_one(".location-card__door .ct-span")92 door = door_el.get_text(strip=True) if door_el else ""9394 # 2e bloc : ville (Val-d'Or, Amos, Malartic) et secteur (Centre-ville…)95 city = sector = ""96 meta_blocks = card.select(".location-card__data-title")97 if len(meta_blocks) > 1:98 spans = meta_blocks[1].select(".ct-span")99 if spans:100 city = spans[0].get_text(strip=True)101 if len(spans) > 1:102 sector = spans[1].get_text(strip=True)103104 # adresse civique seulement si le libellé en est une (commence par un n°)105 address = ""106 if re.match(r"^\d+[\s,]", title):107 address = title + (f", app. {door}" if door else "")108 if city:109 address += f", {city}"110111 price_el = card.select_one(".location-card__price")112 period_el = card.select_one(".location-card__period")113 price_label = ""114 if price_el and price_el.get_text(strip=True):115 price_label = price_el.get_text(strip=True) + \116 (period_el.get_text(strip=True) if period_el else "")117118 img_el = card.select_one("img.location-card__image[src]")119 images = [_SIZE_SUFFIX.sub("", img_el["src"])] if img_el else []120121 # type d'unité : « 3 1/2 » du libellé de la carte (sinon fiche détail)122 unit_type = ""123 m_type = _TYPE_RE.search(title)124 if m_type:125 unit_type = normalize_unit_type(f"{m_type.group(1)} 1/2")126127 lst = Listing(128 source=self.source_id,129 external_id=ext_id,130 url=url,131 title=title + (f" #{door}" if door else ""),132 address=address,133 sector=sector,134 city=city,135 unit_type=unit_type,136 price_label=_clean_price_label(price_label),137 availability=availability,138 images=images,139 )140141 key = hashlib.sha1(142 f"{title}|{price_label}|{availability}|{city}"143 .encode("utf-8")).hexdigest()144 try:145 payload = self.detail(ext_id, key,146 lambda u=url: self._fetch_detail(u))147 self._apply_detail(lst, payload)148 except Exception:149 pass150 listings[ext_id] = lst151152 # -- fiche détail (/logement/<slug>/) ----------------------------------------------153 def _fetch_detail(self, url: str) -> dict:154 """Bloc structuré (pièces, animaux…), description et galerie complète."""155 if self._fetched >= self.max_details:156 raise RuntimeError("budget de fiches détail atteint")157 self._fetched += 1158 html = self.get(url).text159 soup = BeautifulSoup(html, "html.parser")160 out: dict = {}161162 # paires titre/valeur du bloc .location-data (rendu serveur)163 fields: dict[str, str] = {}164 for it in soup.select(".location-data__item"):165 t_el = it.select_one(".location-data__item-title")166 v_el = it.select_one(".location-data__item-value")167 if not t_el:168 continue169 key = strip_accents(t_el.get_text(strip=True).rstrip(" :").lower())170 val = v_el.get_text(" ", strip=True) if v_el else ""171 if val:172 fields[key] = val173 out["fields"] = fields174175 desc_el = soup.select_one(".location-more-infos")176 if desc_el:177 txt = desc_el.get_text("\n", strip=True)178 txt = re.sub(r"^Sp[ée]cifications\n", "", txt)179 txt = re.sub(r"^Inclusions\n", "", txt)180 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]181182 images: list[str] = []183 for a in soup.select("a.oxy-carousel-builder_gallery-image[href]"):184 u = a["href"].strip()185 if u.startswith("http") and u not in images:186 images.append(u)187 out["images"] = images[:25]188 return out189190 def _apply_detail(self, lst: Listing, d: dict) -> None:191 """Reporte le payload (frais ou en cache) sur l'annonce."""192 if not d:193 return194 if d.get("description"):195 lst.description = d["description"]196 if d.get("images") and len(d["images"]) > len(lst.images):197 lst.images = d["images"]198199 fields = d.get("fields") or {}200 amenities: list[str] = []201 pieces = fields.get("nombre de pieces", "")202 if pieces.isdigit():203 amenities.append(f"{pieces} pièce(s)")204 # le site affiche « n½ » pour n pièces : dérivation fidèle205 if not lst.unit_type:206 lst.unit_type = normalize_unit_type(f"{pieces} 1/2")207 if fields.get("chambre(s)"):208 amenities.append(f"{fields['chambre(s)']} chambre(s)")209 if fields.get("salle de bain(s)"):210 amenities.append(f"{fields['salle de bain(s)']} salle(s) de bain")211 for label, key in (("Chauffage", "chauffage"),212 ("Électricité", "electricite"),213 ("Internet", "internet"),214 ("Stationnement", "stationnement")):215 if fields.get(key):216 amenities.append(f"{label} : {fields[key]}")217 if fields.get("fumeur"):218 amenities.append(f"Fumeur : {fields['fumeur']}")219 lst.amenities = list(dict.fromkeys(lst.amenities + amenities))220221 lst.pets = _oui_non(fields.get("animaux", ""))222 if not lst.availability and fields.get("disponibilite"):223 lst.availability = fields["disponibilite"]224 if not lst.price_label and fields.get("prix"):225 lst.price_label = _clean_price_label(fields["prix"])226 if not lst.city and fields.get("ville"):227 lst.city = fields["ville"]228 if not lst.sector and fields.get("secteur"):229 lst.sector = fields["secteur"]230