# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sgiq.py : connecteur SGIQ — Société de Gestion Immobilière du # Québec (gestionimmobilierequebec.com). Liste paginée /immeubles?page=N # (rendu serveur) + fiches /fiche/ pour images, description, commodités # (avec cache BD self.detail) + endpoint JSON getMarkers pour lat/lng. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib 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://gestionimmobilierequebec.com" LIST_URL = f"{BASE}/immeubles" # Endpoint AJAX du site (recherche/carte) : POST a=getMarkers&form=dispo= # retourne [titre, "lat, ", "lng", popup, popup, "3-1-2", …] pour CHAQUE fiche. MARKERS_URL = f"{BASE}/mod/act_p/ImmeubleAct.php" # Secteurs connus de l'agglomération de Québec (localité affichée après la # virgule dans le titre) — tout le reste (hors Québec/Lévis) est exclu. _QC_SECTORS = { "quebec", "ville de quebec", "sainte-foy", "ste-foy", "sillery", "limoilou", "beauport", "charlesbourg", "vanier", "loretteville", "val-belair", "l'ancienne-lorette", "ancienne-lorette", "saint-augustin", "saint-augustin-de-desmaures", "cap-rouge", "saint-roch", "st-roch", "saint-sauveur", "st-sauveur", "montcalm", "duberger", "les saules", "neufchatel", "lebourgneuf", "lac-saint-charles", "saint-emile", "st-emile", "wendake", "cite-limoilou", "la cite-limoilou", } _IMG_RE = re.compile( r"//gestionimmobilierequebec\.com/mod/file/ImmeubleSliderFile/" r"[0-9a-f]+\.(?:jpg|jpeg|png|webp)", re.I) class _FetchBudget(Exception): """Plafond de requêtes fiche atteint pour cette synchronisation.""" class SGIQConnector(BaseConnector): source_id = "sgiq" request_delay = 0.5 max_pages = 15 # garde-fou de pagination max_details = 250 # garde-fou de fetch des fiches max_fetch_per_sync = 160 # vraies requêtes fiche par sync (cache exclu) # -- helpers --------------------------------------------------------------- @staticmethod def _city_from_locality(locality: str) -> str | None: """Ville normalisée, ou None si hors agglomération Québec/Lévis.""" key = strip_accents(locality.strip().lower()) if not key: return "Québec" if infer_city(locality, default="") == "Lévis": return "Lévis" if key in _QC_SECTORS: return "Québec" return None def _fetch_markers(self) -> dict[str, tuple[float, float, str]]: """Coordonnées + type par fiche via l'endpoint carte (1 seul POST). `form=dispo=` désactive le filtre de disponibilité : les 147 fiches sont retournées. Format : [html, "lat, ", "lng", popup, popup, type]. """ markers: dict[str, tuple[float, float, str]] = {} try: resp = self.session.post( MARKERS_URL, data={"a": "getMarkers", "form": "dispo="}, timeout=self.timeout) resp.raise_for_status() for row in resp.json(): if not isinstance(row, list) or len(row) < 6: continue m = re.search(r"/fiche/(\d+)", str(row[3])) if not m: continue try: lat = float(str(row[1]).strip(" ,")) lng = float(str(row[2]).strip(" ,")) except (TypeError, ValueError): continue typ = str(row[5] or "").replace("-1-2", " 1/2") # "3-1-2" -> "3 1/2" markers[m.group(1)] = (lat, lng, typ) except Exception: pass return markers # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} # 1) Pagination de la liste (?page=N ; total dans input#total_page) total_pages = 1 page = 1 while page <= total_pages and page <= self.max_pages: try: html = self.get(LIST_URL if page == 1 else f"{LIST_URL}?page={page}").text except Exception: break soup = BeautifulSoup(html, "html.parser") tp = soup.select_one("input#total_page") if tp and (tp.get("value") or "").isdigit(): total_pages = int(tp["value"]) for card in soup.select("div.preview-immeuble a[href*='/fiche/']"): m = re.search(r"/fiche/(\d+)", card.get("href", "")) if not m: continue ext_id = m.group(1) if ext_id in listings: continue title = (card.get("title") or "").strip() if not title: img = card.select_one("img[alt]") title = (img.get("alt") or "").strip() if img else "" # Formats observés : "925-1A rue Liénard, Québec", # "920-1F Av. Myrand, Québec, QC G1V 2V9", "830 avenue Turnbull" tokens = [t.strip() for t in title.split(",") if t.strip()] locality = "" for tok in tokens[1:]: tok_clean = re.sub(r"\bQC\b|\bG\d[A-Z]\s?\d[A-Z]\d\b", "", tok).strip() if tok_clean: locality = tok_clean break address = tokens[0] if tokens else title city = self._city_from_locality(locality) if city is None: # hors Québec / Lévis continue cat_el = card.select_one("div.text p") category = cat_el.get_text(strip=True) if cat_el else "" if re.search(r"stationnement|commercial|rangement|garage", category, re.I): continue price_el = card.select_one(".background-price strong") price_label = f"{price_el.get_text(strip=True)} $ /Mois" if price_el else "" sector = locality if strip_accents(locality.lower()) not in ("quebec",) else "" listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/fiche/{ext_id}", title=title or f"Logement {ext_id}", address=address, sector=sector, city=city, price=parse_price(price_label), price_label=price_label, description=category, ) page += 1 # 2) lat/lng + type structurés via l'endpoint carte (1 seul POST) markers = self._fetch_markers() for ext_id, (lat, lng, typ) in markers.items(): lst = listings.get(ext_id) if lst: lst.lat, lst.lng = lat, lng if typ: lst.unit_type = normalize_unit_type(typ) # 3) Fiches détaillées : images, description, commodités — via le # cache BD self.detail() : la fiche n'est re-téléchargée que si la # carte liste (titre/prix/catégorie) a changé. fetched = 0 for i, lst in enumerate(listings.values()): if i >= self.max_details: break key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.description}" .encode("utf-8")).hexdigest() def _fetch(url=lst.url): nonlocal fetched if fetched >= self.max_fetch_per_sync: raise _FetchBudget(url) # ni requête, ni mise en cache vide fetched += 1 return self._fetch_detail(url) try: payload = self.detail(lst.external_id, key, _fetch) except Exception: payload = {} if payload: self._apply_detail(lst, payload) return list(listings.values()) # -- fiche détail ---------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Télécharge une fiche et en extrait le payload brut (cacheable).""" detail = self.get(url).text dsoup = BeautifulSoup(detail, "html.parser") images = ["https:" + u for u in dict.fromkeys(_IMG_RE.findall(detail))][:30] # description : bloc gauche de « Description et remarques » (sans les # items « Chauffé : Oui » qui vont dans les commodités) desc_el = dsoup.select_one( "div.description div.block-left > div.text:not(.description-item)") \ or dsoup.select_one("div.text.description-content") desc = "" if desc_el: desc = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True)) if not desc: # repli : tout le corps de la fiche body = dsoup.get_text(" ", strip=True) m = re.search(r"Description et remarques\s*(.+?)(?:Vous pourriez aussi aimer|Siège social)", body) desc = re.sub(r"\s+", " ", m.group(1)) if m else "" # commodités : "Chauffé : Non", "1 chambre", "Chiens permis", ... amenities: list[str] = [] for el in dsoup.select("div.description-item div, div.block-right div.icon div"): t = el.get_text(" ", strip=True) if t and len(t) < 60 and t not in amenities: amenities.append(t) # icône « animal » structurée (filtre Chiens acceptés du site) pets_icon = "" icon = dsoup.select_one("div.block-right div.icon img[src*='icon_animal']") if icon: div = icon.find_next_sibling("div") pets_icon = div.get_text(" ", strip=True) if div else "" return {"images": images, "description": desc[:800], "amenities": amenities, "pets_icon": pets_icon} def _apply_detail(self, lst: Listing, payload: dict) -> None: """Applique le payload d'une fiche (frais ou depuis le cache BD).""" desc = payload.get("description") or "" if payload.get("images"): lst.images = payload["images"] if desc: lst.description = desc if payload.get("amenities"): lst.amenities = payload["amenities"] # animaux : icône dédiée du site ("Chiens permis") pi = strip_accents((payload.get("pets_icon") or "").lower()) if pi: lst.pets = "non" if re.search(r"\bnon\b|refus|interdit", pi) else "oui" # type d'unité depuis la description ("3 1/2 LUMINEUX ...") si les # marqueurs ne l'ont pas fourni if not lst.unit_type: unit = normalize_unit_type(desc) if unit and unit != desc.strip(): lst.unit_type = unit # secteur depuis la description ("QUARTIER SAINTE-FOY", "SECTEUR LIMOILOU") if not lst.sector: m = re.search(r"(?:QUARTIER|SECTEUR)\s+(?:DE\s+|DU\s+)?" r"([A-ZÀ-Ü][A-ZÀ-Üa-zà-ü']+(?:-[A-ZÀ-Üa-zà-ü']+)*)", desc) if m: lst.sector = m.group(1).strip(" -").title() lst.city = infer_city(lst.sector, default=lst.city) # disponibilité si mentionnée ("DISPONIBLE PRÉSENTEMENT", "PRÉSENTEMENT # DISPONIBLE", "DISPONIBLE EN JUILLET", "LIBRE 1ER JUILLET"...) mois = (r"JAN[A-ZÀ-Ü]*|F[ÉE]V[A-ZÀ-Ü]*|MARS|AVR[A-ZÀ-Ü]*|MAI|JUIN|" r"JUIL[A-ZÀ-Ü]*|AO[ÛU]T|SEPT[A-ZÀ-Ü]*|OCT[A-ZÀ-Ü]*|" r"NOV[A-ZÀ-Ü]*|D[ÉE]C[A-ZÀ-Ü]*") m = re.search(rf"(?:LIBRE|DISPONIBLE|DISPONIBILIT[ÉE])\s*:?\s*" rf"(?:D[ÈE]S\s+|LE\s+|EN\s+)?" rf"(MAINTENANT|PR[ÉE]SENTEMENT|IMM[ÉE]DIATEMENT|" rf"\d+\s*(?:ER|E)?\s*[A-ZÀ-Ü]{{3,10}}(?:\s+20\d\d)?|" rf"(?:{mois})(?:\s+20\d\d)?)", desc, re.I) if not m: m = re.search(r"(MAINTENANT|PR[ÉE]SENTEMENT|IMM[ÉE]DIATEMENT)\s+" r"DISPONIBLE", desc, re.I) if not m: m = re.search(r"PRISE DE POSSESSION\s*:?\s*(FLEXIBLE\s*)?" r"(\([^)]{0,50}\)|[A-ZÀ-Ü0-9][^.<–—-]{0,40})?", desc, re.I) if m: lst.availability = re.sub(r"\s+", " ", m.group(0)).strip().capitalize()