# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gimcote.py : connecteur GIM Côté inc. (gimcote.com) # WordPress + thème immobilier Houzez. Archive /property-type/appartement # paginée : chaque carte contient prix, adresse, statut, type et la galerie # complète d'images (attribut data-images). Les fiches détail (via cache BD) # ajoutent description, caractéristiques, bloc « Détails » structuré # (animaux, meublé, fumeur, stationnement) et coordonnées GPS. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import json import re from bs4 import BeautifulSoup from ..schema import (Listing, infer_city, normalize_unit_type, parse_price, strip_accents) from .base import BaseConnector BASE = "https://gimcote.com" LIST_URL = f"{BASE}/property-type/appartement/" _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) def _clean_price_label(label: str) -> str: """'1,450$/Par mois' -> '1450$' compatible parse_price (virgule = milliers).""" return re.sub(r"(\d),(\d{3})", r"\1\2", label) _MAP_LATLNG_RE = re.compile(r'"lat"\s*:\s*"(-?\d+\.\d+)"\s*,\s*"lng"\s*:\s*"(-?\d+\.\d+)"') def _oui_non(raw: str) -> bool | None: """'Oui'/'Non' (et variantes) -> bool, sinon None (inconnu).""" k = strip_accents((raw or "").strip().lower()) if k in ("oui", "yes") or k.startswith("oui"): return True if k in ("non", "no") or k.startswith("non"): return False return None def _pets_value(raw: str) -> str | None: """Valeur « Animaux » de la fiche -> oui/non/conditions (jamais deviné).""" k = strip_accents((raw or "").strip().lower()) if not k: return None if k.startswith("non") or "refus" in k or "aucun" in k: return "non" if k.startswith("oui") or "accepte" in k: return "oui" # « Chat opéré », « Chat seulement », « Petit chien »… = sous conditions if re.search(r"chat|chien|opere|seulement|condition|approbation", k): return "conditions" return None class GimCoteConnector(BaseConnector): source_id = "gimcote" request_delay = 0.6 max_pages = 20 # garde-fou de pagination max_details = 150 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/" try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") cards = soup.select("div.item-listing-wrap") if not cards: break for card in cards: try: self._parse_card(card, listings) except Exception: continue # Fiches détail (cache BD) : description, caractéristiques, bloc # « Détails » structuré (animaux/meublé/fumeur/stationnement), GPS self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, card_key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte Houzez ----------------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one("h2.item-title a[href]") if not link: return url = link["href"] title = link.get_text(strip=True) m = re.search(r"/property/([^/]+)/?", url) slug = m.group(1) if m else "" listid_el = card.select_one("[data-listid]") ext_id = (listid_el.get("data-listid") if listid_el else "") or slug if not ext_id or ext_id in listings: return # exclusions : stationnement / commercial / rangement if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t", title, re.I): return # adresse complète (Nominatim) : "3345, Avenue du Colisée, Lairet, # La Cité-Limoilou, Quebec, Urban agglomeration of Québec, ..." addr_el = card.select_one("address.item-address") full_addr = addr_el.get_text(" ", strip=True) if addr_el else "" parts = [p.strip() for p in full_addr.split(",") if p.strip()] address = ", ".join(parts[:2]) if len(parts) >= 2 else full_addr if "lévis" in full_addr.lower() or "levis" in full_addr.lower(): city = "Lévis" elif "québec" in full_addr.lower() or "quebec" in full_addr.lower() or not full_addr: city = "Québec" else: return # hors Québec / Lévis # secteur = micro-quartier + arrondissement (avant les mentions génériques) sector_parts = [p for p in parts[2:] if not re.search(r"^(quebec|québec|urban agglomeration|" r"capitale-nationale|chaudière-appalaches|" r"canada|g\d[a-z]\s?\d[a-z]\d)", p, re.I)] sector = ", ".join(sector_parts[:2]) city = infer_city(sector, default=city) # statut / disponibilité — on saute les logements déjà loués status_el = card.select_one("a[href*='/status/']") availability = status_el.get_text(strip=True) if status_el else "" if re.search(r"lou[ée]", availability, re.I): return # type d'unité : le titre (rédigé par l'agence) prime sur l'étiquette, # parfois erronée ; repli sur l'étiquette /label/ de la carte type_el = card.select_one("a[href*='/label/']") unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = normalize_unit_type(type_el.get_text(strip=True) if type_el else "") price_el = card.select_one("li.item-price") price_label = price_el.get_text(strip=True) if price_el else "" amenities = [] for li in card.select("ul.item-amenities li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) # galerie complète : attribut data-images (JSON, URLs redimensionnées) images: list[str] = [] raw = card.get("data-images") or "" if raw: try: urls = json.loads(htmllib.unescape(raw)) except Exception: urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw)) for u in urls: u = u.replace("\\/", "/").strip() if not u.startswith("http"): continue u = _SIZE_SUFFIX.sub("", u) # version pleine taille (WordPress) if u not in images: images.append(u) if not images: thumb = card.select_one("img.wp-post-image[src]") if thumb: images = [_SIZE_SUFFIX.sub("", thumb["src"])] listings[str(ext_id)] = Listing( source=self.source_id, external_id=str(ext_id), url=url, title=title, address=address, sector=sector, city=city, unit_type=unit_type, price=parse_price(_clean_price_label(price_label)), price_label=price_label, availability=availability, amenities=amenities, images=images[:30], ) # -- fiche détail (Houzez) ---------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description complète, caractéristiques, bloc « Détails » (paires libellé/valeur) et coordonnées GPS (JSON de la carte Houzez).""" 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 = {} desc_el = soup.select_one("#property-description-wrap") if desc_el: txt = desc_el.get_text("\n", strip=True) txt = re.sub(r"^Description\n", "", txt) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1200] out["amenities"] = [a.get_text(" ", strip=True) for a in soup.select("#property-features-wrap li") if a.get_text(strip=True)][:20] # Paires « Stationnement: Non », « Animaux: Chat opéré », etc. for li in soup.select("#property-detail-wrap li"): st, sp = li.find("strong"), li.find("span") if not (st and sp): continue lab = strip_accents(st.get_text(" ", strip=True).lower()) val = sp.get_text(" ", strip=True) if "animaux" in lab: out["pets_raw"] = val elif "meuble" in lab: out["furnished_raw"] = val elif "fumeur" in lab: out["smoking_raw"] = val elif "stationnement" in lab: out["parking_raw"] = val m = _MAP_LATLNG_RE.search(html) if m: out["lat"], out["lng"] = float(m.group(1)), float(m.group(2)) return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais/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"])) pets = _pets_value(d.get("pets_raw", "")) if pets: lst.pets = pets furn = _oui_non(d.get("furnished_raw", "")) if furn is not None: lst.furnished = furn details: dict = {} smoking = _oui_non(d.get("smoking_raw", "")) if smoking is not None: details["smoking"] = smoking parking = _oui_non(d.get("parking_raw", "")) if parking is not None: details["parking"] = {"available": parking} if details: lst.details = details if d.get("lat") is not None and d.get("lng") is not None: lst.lat, lst.lng = d["lat"], d["lng"]