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/gio.py : connecteur Gestion Immobilière de l'Outaouais (gioinc.ca)5# Gestionnaire du Plateau à Gatineau (boul. du Plateau, av. des Oliviers…).6# WordPress/Elementor : la page /a-louer/ rend côté serveur une boucle7# Elementor (e-loop-item-<postid>, CPT « proprietes ») avec prix, ville,8# adresse, nombre de pièces et lien vers la fiche /proprietes/<slug>/.9# La fiche détail (via cache BD) ajoute : titre, mention « Toutes les10# charges comprises », type (Résidentiel/Commercial — le commercial est11# exclu), salles de bain, description bilingue et le bloc « Aperçu »12# structuré (stationnement, durée du bail, animaux, meublé, électroménagers,13# taille) ainsi que la galerie de photos quand elle existe.14# robots.txt : Crawl-delay 3 (respecté via request_delay) et Disallow /*?15# — le connecteur ne visite que des URLs sans chaîne de requête.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price, strip_accents25from .base import BaseConnector2627BASE = "https://gioinc.ca"28LIST_URL = f"{BASE}/a-louer/"2930_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)31# image générique du site (og:image), jamais une photo du logement32_GENERIC_IMG = re.compile(r"GIO-1200x628|logo", re.I)33_BG_URL_RE = re.compile(r"url\((['\"]?)(https?://[^)'\"]+)\1\)")3435# libellés du bloc « Aperçu » : une valeur qui est elle-même un libellé36# signifie que le champ précédent est vide sur la fiche37_APERCU_LABELS = re.compile(38 r"^(Services inclus|Stationnement inclus|Durée du bail|Date d’emménagement|"39 r"Animaux acceptés|L’unité d’habitation|Taille \(pieds carrés\)|Meublé|"40 r"Électroménagers|Air conditionné|Espace extérieur privé|Fumeurs acceptés|"41 r"Immeuble|Commodités dans l’immeuble)$")424344def _oui_non(raw: str) -> bool | None:45 k = strip_accents((raw or "").strip().lower())46 if k.startswith("oui") or k in ("yes",):47 return True48 if k.startswith("non") or k in ("no",):49 return False50 return None515253class GioConnector(BaseConnector):54 source_id = "gio"55 request_delay = 3.0 # robots.txt : Crawl-delay 356 max_details = 40 # garde-fou fiches détail (vraies requêtes)5758 def fetch(self) -> list[Listing]:59 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")60 listings: dict[str, Listing] = {}61 for card in soup.select('div[data-elementor-type="loop-item"]'):62 try:63 self._parse_card(card, listings)64 except Exception:65 continue6667 # fiches détail (cache BD) : description, aperçu structuré, photos68 self._fetched = 069 for lst in listings.values():70 key = hashlib.sha1(71 f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"72 .encode("utf-8")).hexdigest()73 try:74 payload = self.detail(lst.external_id, key,75 lambda u=lst.url: self._fetch_detail(u))76 except Exception:77 continue78 self._apply_detail(lst, payload)7980 # le commercial n'est identifiable que sur la fiche -> filtre final81 return [l for l in listings.values()82 if l.details.get("property_kind") != "Commercial"]8384 # -- carte de la boucle Elementor ------------------------------------------85 def _parse_card(self, card, listings: dict[str, Listing]) -> None:86 link = card.select_one('a.elementor-button[href*="/proprietes/"]')87 if not link:88 return89 url = link["href"]90 # id de billet WordPress stable : classe « post-2320 »91 ext_id = ""92 for cls in card.get("class", []):93 m = re.match(r"^post-(\d+)$", cls)94 if m:95 ext_id = m.group(1)96 if not ext_id or ext_id in listings:97 return9899 status = link.get_text(strip=True) # « Disponible »100 if re.search(r"lou[ée]", status, re.I):101 return102103 # widgets texte dans l'ordre : prix, ville, adresse, « Pièces: n »104 price_label = city = address = rooms_label = ""105 for w in card.select(".elementor-widget-text-editor .elementor-widget-container"):106 txt = re.sub(r"\s+", " ", w.get_text(" ", strip=True))107 if not txt:108 continue109 if "$" in txt and not price_label:110 price_label = txt111 elif re.search(r"pi[eè]ces?\s*:", txt, re.I):112 rooms_label = txt113 elif not city:114 city = txt115 elif not address:116 address = txt117118 unit_type = ""119 m = re.search(r"pi[eè]ces?\s*:\s*(\d+)", rooms_label, re.I)120 rooms = int(m.group(1)) if m else None121 # « Pièces » du site = nombre de chambres (671-102 « 1 chambre » -> 1)122 if rooms:123 unit_type = normalize_unit_type(f"{rooms} chambres")124125 details: dict = {}126 if rooms:127 details["bedrooms"] = rooms128129 listings[ext_id] = Listing(130 source=self.source_id,131 external_id=ext_id,132 url=url,133 title=address or url.rstrip("/").rsplit("/", 1)[-1],134 address=f"{address}, {city}" if address and city else address,135 city=city or "Gatineau",136 unit_type=unit_type,137 price=parse_price(price_label),138 price_label=price_label,139 availability=status,140 details=details,141 )142143 # -- fiche détail ------------------------------------------------------------144 def _fetch_detail(self, url: str) -> dict:145 if self._fetched >= self.max_details:146 raise RuntimeError("budget de fiches détail atteint")147 self._fetched += 1148 html = self.get(url).text149 soup = BeautifulSoup(html, "html.parser")150 out: dict = {}151152 h1 = soup.find("h1")153 if h1:154 out["title"] = h1.get_text(" ", strip=True)155156 text = soup.get_text("\n", strip=True)157158 # adresse complète avec code postal : « 671 boulevard du Plateau,159 # Gatineau, QC J9J 4K5 (Afficher sur la carte) »160 m = re.search(r"\n([^\n]+,\s*[^\n]+?,\s*QC[^\n(]*)", text)161 if m:162 out["address"] = re.sub(r"\s+", " ", m.group(1)).strip()163164 # « Résidentiel » ou « Commercial » + « Pièces: n | Salles de bain: n »165 m = re.search(r"\b(Résidentiel|Commercial)\b", text)166 if m:167 out["kind"] = m.group(1)168 m = re.search(r"Salles? de bain\s*:?\s*(\d+)", text)169 if m:170 out["bathrooms"] = int(m.group(1))171 if re.search(r"Toutes les charges comprises", text, re.I):172 out["all_inclusive"] = True173174 # description : entre « Description » et « Aperçu »175 m = re.search(r"\nDescription\n(.*?)\nAperçu\n", text, re.S)176 if m:177 desc = re.sub(r"[ \t]+", " ", m.group(1)).strip()178 out["description"] = desc[:2500]179180 # bloc « Aperçu » : paires libellé/valeur rendues en lignes successives181 m = re.search(r"\nAperçu\n(.*?)\n(?:Êtes-vous|Comment pouvons)", text, re.S)182 apercu = m.group(1) if m else ""183 for label, key in [184 (r"Animaux acceptés", "pets_raw"),185 (r"Meublé", "furnished_raw"),186 (r"Fumeurs acceptés", "smoking_raw"),187 (r"Stationnement inclus", "parking_raw"),188 (r"Durée du bail", "lease_raw"),189 (r"Électroménagers", "appliances_raw"),190 (r"Services inclus", "services_raw"),191 (r"Taille \(pieds carrés\)", "area_raw")]:192 mm = re.search(label + r"\n([^\n]+)", apercu)193 if mm and not _APERCU_LABELS.match(mm.group(1).strip()):194 out[key] = mm.group(1).strip()195196 # galerie : diaporama de la fiche (div.slide, background-image) +197 # images du contenu ; jamais l'og:image générique ni les icônes198 images: list[str] = []199200 def _add(u: str) -> None:201 if not u.startswith("http"):202 u = BASE + u203 if _GENERIC_IMG.search(u) or u.lower().endswith(".svg"):204 return205 if "/wp-content/uploads/" not in u:206 return207 u = _SIZE_SUFFIX.sub("", u)208 if u not in images:209 images.append(u)210211 for div in soup.select("div.slide div[style], [style*='background']"):212 m_bg = _BG_URL_RE.search(div.get("style", ""))213 if m_bg:214 _add(m_bg.group(2))215 for im in soup.select('img[src*="/wp-content/uploads/"]'):216 _add(im["src"])217 out["images"] = images[:30]218 return out219220 def _apply_detail(self, lst: Listing, d: dict) -> None:221 if not d:222 return223 if d.get("title"):224 lst.title = d["title"]225 if d.get("address"):226 lst.address = d["address"]227 if d.get("kind"):228 lst.details["property_kind"] = d["kind"]229 if d.get("bathrooms"):230 lst.details["bathrooms"] = d["bathrooms"]231 if d.get("description"):232 lst.description = d["description"]233 # le nombre de chambres écrit par l'agence (titre ou en-tête de la234 # description) prime sur le compteur « Pièces » de la carte, parfois235 # en décalage (ex. fiche 3 chambres avec Pièces: 2)236 head = f"{lst.title} | {(lst.description or '')[:80]}"237 m = re.search(r"(\d+)\s*chambres?\b", head, re.I)238 if m:239 lst.unit_type = normalize_unit_type(f"{m.group(1)} chambres")240 lst.details["bedrooms"] = int(m.group(1))241 if d.get("images"):242 lst.images = d["images"]243 pets = _oui_non(d.get("pets_raw", ""))244 if pets is not None:245 lst.pets = "oui" if pets else "non"246 furn = _oui_non(d.get("furnished_raw", ""))247 if furn is not None:248 lst.furnished = furn249 smoking = _oui_non(d.get("smoking_raw", ""))250 if smoking is not None:251 lst.details["smoking"] = smoking252 if d.get("parking_raw"):253 lst.details["parking"] = {"raw": d["parking_raw"]}254 if d.get("lease_raw"):255 lst.details["lease"] = d["lease_raw"]256 amen: list[str] = []257 if d.get("all_inclusive"):258 amen.append("Toutes les charges comprises")259 if d.get("services_raw"):260 amen.append(f"Services inclus : {d['services_raw']}")261 if _oui_non(d.get("appliances_raw", "")):262 amen.append("Électroménagers inclus")263 if amen:264 lst.amenities = list(dict.fromkeys(lst.amenities + amen))265 m = re.match(r"^([\d\s,.]+)$", d.get("area_raw", "") or "")266 if m:267 try:268 area = float(m.group(1).replace(" ", "").replace(",", "."))269 if 80 <= area <= 20000:270 lst.area_sqft = area271 except ValueError:272 pass273