# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gio.py : connecteur Gestion Immobilière de l'Outaouais (gioinc.ca) # Gestionnaire du Plateau à Gatineau (boul. du Plateau, av. des Oliviers…). # WordPress/Elementor : la page /a-louer/ rend côté serveur une boucle # Elementor (e-loop-item-, CPT « proprietes ») avec prix, ville, # adresse, nombre de pièces et lien vers la fiche /proprietes//. # La fiche détail (via cache BD) ajoute : titre, mention « Toutes les # charges comprises », type (Résidentiel/Commercial — le commercial est # exclu), salles de bain, description bilingue et le bloc « Aperçu » # structuré (stationnement, durée du bail, animaux, meublé, électroménagers, # taille) ainsi que la galerie de photos quand elle existe. # robots.txt : Crawl-delay 3 (respecté via request_delay) et Disallow /*? # — le connecteur ne visite que des URLs sans chaîne de requête. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://gioinc.ca" LIST_URL = f"{BASE}/a-louer/" _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) # image générique du site (og:image), jamais une photo du logement _GENERIC_IMG = re.compile(r"GIO-1200x628|logo", re.I) _BG_URL_RE = re.compile(r"url\((['\"]?)(https?://[^)'\"]+)\1\)") # libellés du bloc « Aperçu » : une valeur qui est elle-même un libellé # signifie que le champ précédent est vide sur la fiche _APERCU_LABELS = re.compile( r"^(Services inclus|Stationnement inclus|Durée du bail|Date d’emménagement|" r"Animaux acceptés|L’unité d’habitation|Taille \(pieds carrés\)|Meublé|" r"Électroménagers|Air conditionné|Espace extérieur privé|Fumeurs acceptés|" r"Immeuble|Commodités dans l’immeuble)$") def _oui_non(raw: str) -> bool | None: k = strip_accents((raw or "").strip().lower()) if k.startswith("oui") or k in ("yes",): return True if k.startswith("non") or k in ("no",): return False return None class GioConnector(BaseConnector): source_id = "gio" request_delay = 3.0 # robots.txt : Crawl-delay 3 max_details = 40 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") listings: dict[str, Listing] = {} for card in soup.select('div[data-elementor-type="loop-item"]'): try: self._parse_card(card, listings) except Exception: continue # fiches détail (cache BD) : description, aperçu structuré, photos self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) # le commercial n'est identifiable que sur la fiche -> filtre final return [l for l in listings.values() if l.details.get("property_kind") != "Commercial"] # -- carte de la boucle Elementor ------------------------------------------ def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('a.elementor-button[href*="/proprietes/"]') if not link: return url = link["href"] # id de billet WordPress stable : classe « post-2320 » ext_id = "" for cls in card.get("class", []): m = re.match(r"^post-(\d+)$", cls) if m: ext_id = m.group(1) if not ext_id or ext_id in listings: return status = link.get_text(strip=True) # « Disponible » if re.search(r"lou[ée]", status, re.I): return # widgets texte dans l'ordre : prix, ville, adresse, « Pièces: n » price_label = city = address = rooms_label = "" for w in card.select(".elementor-widget-text-editor .elementor-widget-container"): txt = re.sub(r"\s+", " ", w.get_text(" ", strip=True)) if not txt: continue if "$" in txt and not price_label: price_label = txt elif re.search(r"pi[eè]ces?\s*:", txt, re.I): rooms_label = txt elif not city: city = txt elif not address: address = txt unit_type = "" m = re.search(r"pi[eè]ces?\s*:\s*(\d+)", rooms_label, re.I) rooms = int(m.group(1)) if m else None # « Pièces » du site = nombre de chambres (671-102 « 1 chambre » -> 1) if rooms: unit_type = normalize_unit_type(f"{rooms} chambres") details: dict = {} if rooms: details["bedrooms"] = rooms listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=address or url.rstrip("/").rsplit("/", 1)[-1], address=f"{address}, {city}" if address and city else address, city=city or "Gatineau", unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=status, details=details, ) # -- fiche détail ------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: 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 = {} h1 = soup.find("h1") if h1: out["title"] = h1.get_text(" ", strip=True) text = soup.get_text("\n", strip=True) # adresse complète avec code postal : « 671 boulevard du Plateau, # Gatineau, QC J9J 4K5 (Afficher sur la carte) » m = re.search(r"\n([^\n]+,\s*[^\n]+?,\s*QC[^\n(]*)", text) if m: out["address"] = re.sub(r"\s+", " ", m.group(1)).strip() # « Résidentiel » ou « Commercial » + « Pièces: n | Salles de bain: n » m = re.search(r"\b(Résidentiel|Commercial)\b", text) if m: out["kind"] = m.group(1) m = re.search(r"Salles? de bain\s*:?\s*(\d+)", text) if m: out["bathrooms"] = int(m.group(1)) if re.search(r"Toutes les charges comprises", text, re.I): out["all_inclusive"] = True # description : entre « Description » et « Aperçu » m = re.search(r"\nDescription\n(.*?)\nAperçu\n", text, re.S) if m: desc = re.sub(r"[ \t]+", " ", m.group(1)).strip() out["description"] = desc[:2500] # bloc « Aperçu » : paires libellé/valeur rendues en lignes successives m = re.search(r"\nAperçu\n(.*?)\n(?:Êtes-vous|Comment pouvons)", text, re.S) apercu = m.group(1) if m else "" for label, key in [ (r"Animaux acceptés", "pets_raw"), (r"Meublé", "furnished_raw"), (r"Fumeurs acceptés", "smoking_raw"), (r"Stationnement inclus", "parking_raw"), (r"Durée du bail", "lease_raw"), (r"Électroménagers", "appliances_raw"), (r"Services inclus", "services_raw"), (r"Taille \(pieds carrés\)", "area_raw")]: mm = re.search(label + r"\n([^\n]+)", apercu) if mm and not _APERCU_LABELS.match(mm.group(1).strip()): out[key] = mm.group(1).strip() # galerie : diaporama de la fiche (div.slide, background-image) + # images du contenu ; jamais l'og:image générique ni les icônes images: list[str] = [] def _add(u: str) -> None: if not u.startswith("http"): u = BASE + u if _GENERIC_IMG.search(u) or u.lower().endswith(".svg"): return if "/wp-content/uploads/" not in u: return u = _SIZE_SUFFIX.sub("", u) if u not in images: images.append(u) for div in soup.select("div.slide div[style], [style*='background']"): m_bg = _BG_URL_RE.search(div.get("style", "")) if m_bg: _add(m_bg.group(2)) for im in soup.select('img[src*="/wp-content/uploads/"]'): _add(im["src"]) out["images"] = images[:30] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("title"): lst.title = d["title"] if d.get("address"): lst.address = d["address"] if d.get("kind"): lst.details["property_kind"] = d["kind"] if d.get("bathrooms"): lst.details["bathrooms"] = d["bathrooms"] if d.get("description"): lst.description = d["description"] # le nombre de chambres écrit par l'agence (titre ou en-tête de la # description) prime sur le compteur « Pièces » de la carte, parfois # en décalage (ex. fiche 3 chambres avec Pièces: 2) head = f"{lst.title} | {(lst.description or '')[:80]}" m = re.search(r"(\d+)\s*chambres?\b", head, re.I) if m: lst.unit_type = normalize_unit_type(f"{m.group(1)} chambres") lst.details["bedrooms"] = int(m.group(1)) if d.get("images"): lst.images = d["images"] pets = _oui_non(d.get("pets_raw", "")) if pets is not None: lst.pets = "oui" if pets else "non" furn = _oui_non(d.get("furnished_raw", "")) if furn is not None: lst.furnished = furn smoking = _oui_non(d.get("smoking_raw", "")) if smoking is not None: lst.details["smoking"] = smoking if d.get("parking_raw"): lst.details["parking"] = {"raw": d["parking_raw"]} if d.get("lease_raw"): lst.details["lease"] = d["lease_raw"] amen: list[str] = [] if d.get("all_inclusive"): amen.append("Toutes les charges comprises") if d.get("services_raw"): amen.append(f"Services inclus : {d['services_raw']}") if _oui_non(d.get("appliances_raw", "")): amen.append("Électroménagers inclus") if amen: lst.amenities = list(dict.fromkeys(lst.amenities + amen)) m = re.match(r"^([\d\s,.]+)$", d.get("area_raw", "") or "") if m: try: area = float(m.group(1).replace(" ", "").replace(",", ".")) if 80 <= area <= 20000: lst.area_sqft = area except ValueError: pass