# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mkar.py : connecteur Gestion Immobilière MKAR # (gestionimmobilieremkar.ca — Saguenay : Jonquière/Chicoutimi/La Baie, Alma, # Chambord ; gère aussi pour des tiers). WordPress + thème maison blitzWP # (CPT property), tout rendu serveur. Archive /proprietes-gestion-immobiliere- # mkar/ : cartes `article.archive__item--property` — badge de disponibilité # (« Disponible »/« Complet »), type (« 4½ », « Chambre », « Loft »), # catégorie (Duplex, Colocation…), adresse + municipalité, photos. Fiche # /propriete// (via cache BD) : inclusions (chambres, salles de bain, # chauffage, wifi…), conditions de location (animaux, fumeur) et galerie. # Aucun prix publié nulle part -> price = None (rien d'inventé). # robots.txt Yoast standard (admin seulement), sitemap. # ----------------------------------------------------------------------------- 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://gestionimmobilieremkar.ca" LIST_URL = f"{BASE}/proprietes-gestion-immobiliere-mkar/" # municipalités du filtre du site ; celles de gauche = secteurs de Saguenay _SAGUENAY = {"jonquiere", "chicoutimi", "la baie", "lac kenogami"} _MUNICIPALITIES = _SAGUENAY | {"alma", "chambord"} def _split_locality(raw: str) -> tuple[str, str]: """« 1745 Rue Haziel - 1745 Chicoutimi » -> (adresse, municipalité).""" txt = re.sub(r"\s+", " ", raw).strip() for name in sorted(_MUNICIPALITIES, key=len, reverse=True): m = re.search(rf"\s({name})\s*$", strip_accents(txt.lower())) if m: return txt[: m.start()].strip(" -,"), txt[m.start():].strip() return txt, "" class MkarConnector(BaseConnector): source_id = "mkar" request_delay = 0.6 max_details = 25 # 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("article.archive__item--property"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte (article.archive__item--property) -------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('a.item__link[href*="/propriete/"]') \ or card.select_one('a[href*="/propriete/"]') if not link: return url = link["href"] m = re.search(r"/propriete/([^/?#]+)", url) if not m: return ext_id = m.group(1) if not ext_id or ext_id in listings: return # badge de disponibilité : on ne garde que les unités « Disponible » avail_el = card.select_one(".item__availability") availability = avail_el.get_text(strip=True) if avail_el else "" if availability and not re.search(r"disponible", availability, re.I): return type_el = card.select_one(".item__room") unit_label = type_el.get_text(strip=True) if type_el else "" cat_el = card.select_one(".item__category") category = cat_el.get_text(strip=True) if cat_el else "" # garages et espaces non résidentiels : exclus if re.search(r"garage|entrep[oô]t|commercial|bureau|local", f"{unit_label} {category}", re.I): return addr_el = card.select_one(".item__addresse") address, locality = _split_locality( addr_el.get_text(" ", strip=True) if addr_el else "") loc_key = strip_accents(locality.lower()) if loc_key in _SAGUENAY: city, sector = "Saguenay", locality else: city, sector = locality, "" images: list[str] = [] for img in card.select(".item__image img[src]"): u = img["src"] if u.startswith("http") and u not in images: images.append(u) title = " — ".join(p for p in (category, address) if p) or unit_label lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=address, sector=sector, city=city, unit_type=normalize_unit_type(unit_label), price=None, # aucun prix publié par MKAR price_label="", availability=availability, amenities=[category] if category else [], images=images[:15], ) key = hashlib.sha1( f"{unit_label}|{category}|{address}|{availability}" .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 (/propriete//) --------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Inclusions, conditions de location (animaux) 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 = {} # « Inclusions » : items structurés (1 Chambre, 1 Salle de bain, Wifi…) inclusions = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in soup.select(".property__inclusions .inclusions__item h3")] out["amenities"] = [t for t in inclusions if t][:20] # « Conditions de location » : successifs (bail, animaux, fumeur) conditions = [re.sub(r"\s+", " ", s.get_text(" ", strip=True)) for s in soup.select(".property__conditions strong")] conditions = [t for t in conditions if t] if conditions: out["description"] = ("Conditions de location : " + " ; ".join(conditions))[:1200] joined = " ".join(conditions).lower() if re.search(r"animaux ne sont pas permis", joined): out["pets"] = "non" # GPS : carte du thème (attributs data-lat / data-lng) map_el = soup.select_one(".property__map .map[data-lat][data-lng]") if map_el: try: out["lat"] = float(map_el["data-lat"]) out["lng"] = float(map_el["data-lng"]) except (TypeError, ValueError): pass # galerie (swiper) : photos pleine taille, icônes SVG exclues images: list[str] = [] for img in soup.select(".property img[src*='/wp-content/uploads/']"): u = img["src"] if u.startswith("http") and not u.endswith(".svg") 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("amenities"): lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) if d.get("pets"): lst.pets = d["pets"] 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"]