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/sgiq.py : connecteur SGIQ — Société de Gestion Immobilière du5# Québec (gestionimmobilierequebec.com). Liste paginée /immeubles?page=N6# (rendu serveur) + fiches /fiche/<id> pour images, description, commodités7# (avec cache BD self.detail) + endpoint JSON getMarkers pour lat/lng.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import hashlib12import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents17from .base import BaseConnector1819BASE = "https://gestionimmobilierequebec.com"20LIST_URL = f"{BASE}/immeubles"21# Endpoint AJAX du site (recherche/carte) : POST a=getMarkers&form=dispo=22# retourne [titre, "lat, ", "lng", popup, popup, "3-1-2", …] pour CHAQUE fiche.23MARKERS_URL = f"{BASE}/mod/act_p/ImmeubleAct.php"2425# Secteurs connus de l'agglomération de Québec (localité affichée après la26# virgule dans le titre) — tout le reste (hors Québec/Lévis) est exclu.27_QC_SECTORS = {28 "quebec", "ville de quebec", "sainte-foy", "ste-foy", "sillery", "limoilou",29 "beauport", "charlesbourg", "vanier", "loretteville", "val-belair",30 "l'ancienne-lorette", "ancienne-lorette", "saint-augustin",31 "saint-augustin-de-desmaures", "cap-rouge", "saint-roch", "st-roch",32 "saint-sauveur", "st-sauveur", "montcalm", "duberger", "les saules",33 "neufchatel", "lebourgneuf", "lac-saint-charles", "saint-emile", "st-emile",34 "wendake", "cite-limoilou", "la cite-limoilou",35}3637_IMG_RE = re.compile(38 r"//gestionimmobilierequebec\.com/mod/file/ImmeubleSliderFile/"39 r"[0-9a-f]+\.(?:jpg|jpeg|png|webp)", re.I)404142class _FetchBudget(Exception):43 """Plafond de requêtes fiche atteint pour cette synchronisation."""444546class SGIQConnector(BaseConnector):47 source_id = "sgiq"48 request_delay = 0.549 max_pages = 15 # garde-fou de pagination50 max_details = 250 # garde-fou de fetch des fiches51 max_fetch_per_sync = 160 # vraies requêtes fiche par sync (cache exclu)5253 # -- helpers ---------------------------------------------------------------54 @staticmethod55 def _city_from_locality(locality: str) -> str | None:56 """Ville normalisée, ou None si hors agglomération Québec/Lévis."""57 key = strip_accents(locality.strip().lower())58 if not key:59 return "Québec"60 if infer_city(locality, default="") == "Lévis":61 return "Lévis"62 if key in _QC_SECTORS:63 return "Québec"64 return None6566 def _fetch_markers(self) -> dict[str, tuple[float, float, str]]:67 """Coordonnées + type par fiche via l'endpoint carte (1 seul POST).6869 `form=dispo=` désactive le filtre de disponibilité : les 147 fiches70 sont retournées. Format : [html, "lat, ", "lng", popup, popup, type].71 """72 markers: dict[str, tuple[float, float, str]] = {}73 try:74 resp = self.session.post(75 MARKERS_URL, data={"a": "getMarkers", "form": "dispo="},76 timeout=self.timeout)77 resp.raise_for_status()78 for row in resp.json():79 if not isinstance(row, list) or len(row) < 6:80 continue81 m = re.search(r"/fiche/(\d+)", str(row[3]))82 if not m:83 continue84 try:85 lat = float(str(row[1]).strip(" ,"))86 lng = float(str(row[2]).strip(" ,"))87 except (TypeError, ValueError):88 continue89 typ = str(row[5] or "").replace("-1-2", " 1/2") # "3-1-2" -> "3 1/2"90 markers[m.group(1)] = (lat, lng, typ)91 except Exception:92 pass93 return markers9495 # -- fetch -----------------------------------------------------------------96 def fetch(self) -> list[Listing]:97 listings: dict[str, Listing] = {}9899 # 1) Pagination de la liste (?page=N ; total dans input#total_page)100 total_pages = 1101 page = 1102 while page <= total_pages and page <= self.max_pages:103 try:104 html = self.get(LIST_URL if page == 1 else f"{LIST_URL}?page={page}").text105 except Exception:106 break107 soup = BeautifulSoup(html, "html.parser")108 tp = soup.select_one("input#total_page")109 if tp and (tp.get("value") or "").isdigit():110 total_pages = int(tp["value"])111112 for card in soup.select("div.preview-immeuble a[href*='/fiche/']"):113 m = re.search(r"/fiche/(\d+)", card.get("href", ""))114 if not m:115 continue116 ext_id = m.group(1)117 if ext_id in listings:118 continue119 title = (card.get("title") or "").strip()120 if not title:121 img = card.select_one("img[alt]")122 title = (img.get("alt") or "").strip() if img else ""123 # Formats observés : "925-1A rue Liénard, Québec",124 # "920-1F Av. Myrand, Québec, QC G1V 2V9", "830 avenue Turnbull"125 tokens = [t.strip() for t in title.split(",") if t.strip()]126 locality = ""127 for tok in tokens[1:]:128 tok_clean = re.sub(r"\bQC\b|\bG\d[A-Z]\s?\d[A-Z]\d\b", "", tok).strip()129 if tok_clean:130 locality = tok_clean131 break132 address = tokens[0] if tokens else title133 city = self._city_from_locality(locality)134 if city is None: # hors Québec / Lévis135 continue136 cat_el = card.select_one("div.text p")137 category = cat_el.get_text(strip=True) if cat_el else ""138 if re.search(r"stationnement|commercial|rangement|garage",139 category, re.I):140 continue141 price_el = card.select_one(".background-price strong")142 price_label = f"{price_el.get_text(strip=True)} $ /Mois" if price_el else ""143 sector = locality if strip_accents(locality.lower()) not in ("quebec",) else ""144 listings[ext_id] = Listing(145 source=self.source_id,146 external_id=ext_id,147 url=f"{BASE}/fiche/{ext_id}",148 title=title or f"Logement {ext_id}",149 address=address,150 sector=sector,151 city=city,152 price=parse_price(price_label),153 price_label=price_label,154 description=category,155 )156 page += 1157158 # 2) lat/lng + type structurés via l'endpoint carte (1 seul POST)159 markers = self._fetch_markers()160 for ext_id, (lat, lng, typ) in markers.items():161 lst = listings.get(ext_id)162 if lst:163 lst.lat, lst.lng = lat, lng164 if typ:165 lst.unit_type = normalize_unit_type(typ)166167 # 3) Fiches détaillées : images, description, commodités — via le168 # cache BD self.detail() : la fiche n'est re-téléchargée que si la169 # carte liste (titre/prix/catégorie) a changé.170 fetched = 0171 for i, lst in enumerate(listings.values()):172 if i >= self.max_details:173 break174 key = hashlib.sha1(175 f"{lst.title}|{lst.price_label}|{lst.description}"176 .encode("utf-8")).hexdigest()177178 def _fetch(url=lst.url):179 nonlocal fetched180 if fetched >= self.max_fetch_per_sync:181 raise _FetchBudget(url) # ni requête, ni mise en cache vide182 fetched += 1183 return self._fetch_detail(url)184185 try:186 payload = self.detail(lst.external_id, key, _fetch)187 except Exception:188 payload = {}189 if payload:190 self._apply_detail(lst, payload)191192 return list(listings.values())193194 # -- fiche détail ----------------------------------------------------------195 def _fetch_detail(self, url: str) -> dict:196 """Télécharge une fiche et en extrait le payload brut (cacheable)."""197 detail = self.get(url).text198 dsoup = BeautifulSoup(detail, "html.parser")199200 images = ["https:" + u for u in dict.fromkeys(_IMG_RE.findall(detail))][:30]201202 # description : bloc gauche de « Description et remarques » (sans les203 # items « Chauffé : Oui » qui vont dans les commodités)204 desc_el = dsoup.select_one(205 "div.description div.block-left > div.text:not(.description-item)") \206 or dsoup.select_one("div.text.description-content")207 desc = ""208 if desc_el:209 desc = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True))210 if not desc: # repli : tout le corps de la fiche211 body = dsoup.get_text(" ", strip=True)212 m = re.search(r"Description et remarques\s*(.+?)(?:Vous pourriez aussi aimer|Siège social)",213 body)214 desc = re.sub(r"\s+", " ", m.group(1)) if m else ""215216 # commodités : "Chauffé : Non", "1 chambre", "Chiens permis", ...217 amenities: list[str] = []218 for el in dsoup.select("div.description-item div, div.block-right div.icon div"):219 t = el.get_text(" ", strip=True)220 if t and len(t) < 60 and t not in amenities:221 amenities.append(t)222223 # icône « animal » structurée (filtre Chiens acceptés du site)224 pets_icon = ""225 icon = dsoup.select_one("div.block-right div.icon img[src*='icon_animal']")226 if icon:227 div = icon.find_next_sibling("div")228 pets_icon = div.get_text(" ", strip=True) if div else ""229230 return {"images": images, "description": desc[:800],231 "amenities": amenities, "pets_icon": pets_icon}232233 def _apply_detail(self, lst: Listing, payload: dict) -> None:234 """Applique le payload d'une fiche (frais ou depuis le cache BD)."""235 desc = payload.get("description") or ""236 if payload.get("images"):237 lst.images = payload["images"]238 if desc:239 lst.description = desc240 if payload.get("amenities"):241 lst.amenities = payload["amenities"]242243 # animaux : icône dédiée du site ("Chiens permis")244 pi = strip_accents((payload.get("pets_icon") or "").lower())245 if pi:246 lst.pets = "non" if re.search(r"\bnon\b|refus|interdit", pi) else "oui"247248 # type d'unité depuis la description ("3 1/2 LUMINEUX ...") si les249 # marqueurs ne l'ont pas fourni250 if not lst.unit_type:251 unit = normalize_unit_type(desc)252 if unit and unit != desc.strip():253 lst.unit_type = unit254255 # secteur depuis la description ("QUARTIER SAINTE-FOY", "SECTEUR LIMOILOU")256 if not lst.sector:257 m = re.search(r"(?:QUARTIER|SECTEUR)\s+(?:DE\s+|DU\s+)?"258 r"([A-ZÀ-Ü][A-ZÀ-Üa-zà-ü']+(?:-[A-ZÀ-Üa-zà-ü']+)*)",259 desc)260 if m:261 lst.sector = m.group(1).strip(" -").title()262 lst.city = infer_city(lst.sector, default=lst.city)263264 # disponibilité si mentionnée ("DISPONIBLE PRÉSENTEMENT", "PRÉSENTEMENT265 # DISPONIBLE", "DISPONIBLE EN JUILLET", "LIBRE 1ER JUILLET"...)266 mois = (r"JAN[A-ZÀ-Ü]*|F[ÉE]V[A-ZÀ-Ü]*|MARS|AVR[A-ZÀ-Ü]*|MAI|JUIN|"267 r"JUIL[A-ZÀ-Ü]*|AO[ÛU]T|SEPT[A-ZÀ-Ü]*|OCT[A-ZÀ-Ü]*|"268 r"NOV[A-ZÀ-Ü]*|D[ÉE]C[A-ZÀ-Ü]*")269 m = re.search(rf"(?:LIBRE|DISPONIBLE|DISPONIBILIT[ÉE])\s*:?\s*"270 rf"(?:D[ÈE]S\s+|LE\s+|EN\s+)?"271 rf"(MAINTENANT|PR[ÉE]SENTEMENT|IMM[ÉE]DIATEMENT|"272 rf"\d+\s*(?:ER|E)?\s*[A-ZÀ-Ü]{{3,10}}(?:\s+20\d\d)?|"273 rf"(?:{mois})(?:\s+20\d\d)?)", desc, re.I)274 if not m:275 m = re.search(r"(MAINTENANT|PR[ÉE]SENTEMENT|IMM[ÉE]DIATEMENT)\s+"276 r"DISPONIBLE", desc, re.I)277 if not m:278 m = re.search(r"PRISE DE POSSESSION\s*:?\s*(FLEXIBLE\s*)?"279 r"(\([^)]{0,50}\)|[A-ZÀ-Ü0-9][^.<–—-]{0,40})?", desc, re.I)280 if m:281 lst.availability = re.sub(r"\s+", " ", m.group(0)).strip().capitalize()282