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/dynamic.py : connecteur Gestion immobilière Dynamic5# (lesgestionsdynamic.com) — apparts à Sherbrooke, Magog, Granby, East Angus.6# WordPress + thème immobilier Houzez, même famille que gimcote.py : archive7# /a-louer/ paginée (cartes div.item-listing-wrap avec prix, galerie8# data-images, data-listid). Particularité locale : pas d'adresse ni de9# statut sur la carte — la disponibilité est un suffixe du titre10# (« IMMÉDIATEMENT », « JUILLET », « 1er AOÛT »…) et l'adresse/ville/GPS11# viennent de la fiche détail (bloc #property-address-wrap, carte Houzez).12# Sert de CLASSE DE BASE à la famille Houzez de l'Estrie : Gestimmo Estrie13# (gestimmo_estrie.py) et Agence de location Sherbrooke / groupe Prestiplex14# (agence_sherbrooke.py) n'en redéfinissent que les constantes et les15# crochets carte (statut/étiquette/adresse).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import html as htmllib21import json22import re2324from bs4 import BeautifulSoup2526from ..schema import (Listing, normalize_unit_type, parse_price,27 strip_accents)28from .base import BaseConnector2930# variantes redimensionnées WordPress (-584x438.jpg) -> pleine taille31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)32_MAP_LATLNG_RE = re.compile(33 r'"lat"\s*:\s*"?(-?\d+\.\d+)"?\s*,\s*"lng"\s*:\s*"?(-?\d+\.\d+)"?')3435# villes réelles desservies par la famille (jamais devinées : on ne retient la36# ville que si elle apparaît telle quelle dans l'adresse ou le bloc « Ville »)37_KNOWN_CITIES = [38 "Sherbrooke", "Magog", "Granby", "East Angus", "Orford", "Waterville",39 "Windsor", "Coaticook", "Ascot Corner", "Lennoxville", "Richmond",40 "Cookshire-Eaton", "Bromptonville", "Cowansville", "Bromont", "Valcourt",41]4243# suffixe de disponibilité dans les titres Dynamic : « IMMÉDIATEMENT »,44# « JUILLET », « 1er AOÛT », « LOUÉ »…45_AVAIL_TITLE_RE = re.compile(46 r"(imm[eé]diatement|d[èe]s maintenant|lou[ée]|"47 r"(?:1er\s+|15\s+)?(?:janvier|f[ée]vrier|mars|avril|mai|juin|juillet|"48 r"ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)(?:\s+20\d\d)?)\s*$",49 re.I)505152def _clean_price_label(label: str) -> str:53 """'1,450$/mois' ou '1 450 $/mois' -> compatible parse_price."""54 return re.sub(r"(\d)[,\s](\d{3})", r"\1\2", label)555657def _city_from_parts(parts: list[str]) -> tuple[str, str]:58 """(ville, secteur) depuis les segments Nominatim de address.item-address :59 « 89, Rue des Pins, East Angus, Le Haut-Saint-François, … » — la ville est60 le segment qui correspond à une ville connue, le secteur le segment qui la61 précède (micro-quartier) quand il ne fait pas partie de l'adresse civique."""62 for i, p in enumerate(parts):63 for city in _KNOWN_CITIES:64 if strip_accents(p.strip().lower()) == strip_accents(city.lower()):65 sector = ""66 if i >= 3: # [n° civique, rue, quartier, ville, ...]67 sector = parts[i - 1].strip()68 return city, sector69 return "", ""707172class DynamicConnector(BaseConnector):73 source_id = "dynamic"74 request_delay = 0.675 max_pages = 12 # garde-fou de pagination76 max_details = 80 # garde-fou fiches détail (vraies requêtes)7778 BASE = "https://lesgestionsdynamic.com"79 LIST_PATH = "/a-louer/"80 CITY_DEFAULT = "Sherbrooke" # repli si la fiche ne précise pas la ville8182 def fetch(self) -> list[Listing]:83 listings: dict[str, Listing] = {}84 for page in range(1, self.max_pages + 1):85 url = (f"{self.BASE}{self.LIST_PATH}" if page == 186 else f"{self.BASE}{self.LIST_PATH}page/{page}/")87 try:88 html = self.get(url).text89 except Exception:90 break91 soup = BeautifulSoup(html, "html.parser")92 cards = soup.select("div.item-listing-wrap")93 if not cards:94 break95 for card in cards:96 try:97 self._parse_card(card, listings)98 except Exception:99 continue100101 # fiches détail (cache BD) : adresse/ville/GPS, description,102 # caractéristiques, type d'unité (aperçu Houzez), galerie complète103 self._fetched = 0104 for lst in listings.values():105 card_key = hashlib.sha1(106 f"{lst.title}|{lst.price_label}|{lst.availability}"107 .encode("utf-8")).hexdigest()108 try:109 payload = self.detail(lst.external_id, card_key,110 lambda u=lst.url: self._fetch_detail(u))111 except Exception:112 continue113 self._apply_detail(lst, payload)114 return list(listings.values())115116 # -- crochets par site --------------------------------------------------------117 def _card_availability(self, card, title: str) -> str:118 """Dynamic : la disponibilité est un suffixe du titre de l'annonce."""119 m = _AVAIL_TITLE_RE.search(title)120 return m.group(1).strip() if m else ""121122 # -- carte Houzez ---------------------------------------------------------------123 def _parse_card(self, card, listings: dict[str, Listing]) -> None:124 link = card.select_one("h2.item-title a[href]")125 if not link:126 return127 url = link["href"]128 title = re.sub(r"\s+", " ", link.get_text(" ", strip=True)).strip()129 m = re.search(r"/(?:property|appartement)/([^/]+)/?", url)130 slug = m.group(1) if m else ""131 listid_el = card.select_one("[data-listid]")132 ext_id = (listid_el.get("data-listid") if listid_el else "") or slug133 if not ext_id or str(ext_id) in listings:134 return135136 # exclusions : logements loués + espaces non résidentiels137 if re.search(r"\blou[ée]s?\b", title, re.I):138 return139 if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t",140 title, re.I):141 return142 availability = self._card_availability(card, title)143 if re.search(r"lou[ée]", availability, re.I):144 return145146 price_el = card.select_one("li.item-price")147 price_label = price_el.get_text(" ", strip=True) if price_el else ""148149 # adresse Nominatim de la carte (présente chez Agence, absente chez150 # Dynamic/Gestimmo — la fiche détail prendra le relais)151 address = sector = ""152 city = self.CITY_DEFAULT153 addr_el = card.select_one("address.item-address")154 if addr_el:155 full = addr_el.get_text(" ", strip=True)156 parts = [p.strip() for p in full.split(",") if p.strip()]157 address = ", ".join(parts[:2]) if len(parts) >= 2 else full158 c, s = _city_from_parts(parts)159 if c:160 city, sector = c, s161162 # commodités des cartes Houzez : chambres / salles de bain / pi²163 amenities: list[str] = []164 for li in card.select("ul.item-amenities li"):165 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))166 t = t.replace("Beds:", "Chambres :").replace("Bath:", "Salle(s) de bain :")167 if t and t not in amenities:168 amenities.append(t)169170 # galerie : attribut data-images (JSON, URLs redimensionnées)171 images: list[str] = []172 raw = card.get("data-images") or ""173 if raw:174 try:175 urls = json.loads(htmllib.unescape(raw))176 except Exception:177 urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw))178 for u in urls:179 if isinstance(u, dict): # variante Houzez : objets {url: …}180 u = u.get("url") or u.get("src") or u.get("image") or ""181 if not isinstance(u, str):182 continue183 u = u.replace("\\/", "/").strip()184 if u.startswith("http"):185 u = _SIZE_SUFFIX.sub("", u)186 if u not in images:187 images.append(u)188 if not images:189 thumb = card.select_one("img.wp-post-image[src]")190 if thumb:191 images = [_SIZE_SUFFIX.sub("", thumb["src"])]192193 # type d'unité depuis le titre, seulement si le motif est net194 # (normalize_unit_type retourne le texte brut quand rien ne matche)195 unit_type = normalize_unit_type(title)196 if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison",197 unit_type or ""):198 unit_type = ""199200 listings[str(ext_id)] = Listing(201 source=self.source_id,202 external_id=str(ext_id),203 url=url,204 title=title,205 address=address,206 sector=sector,207 city=city,208 unit_type=unit_type,209 price=parse_price(_clean_price_label(price_label)),210 price_label=price_label,211 availability=availability,212 amenities=amenities,213 images=images[:30],214 )215216 # -- fiche détail (Houzez) -----------------------------------------------------217 def _fetch_detail(self, url: str) -> dict:218 """Adresse structurée (#property-address-wrap), description,219 caractéristiques, type d'unité (aperçu) et GPS (carte Houzez)."""220 if self._fetched >= self.max_details:221 raise RuntimeError("budget de fiches détail atteint")222 self._fetched += 1223 html = self.get(url).text224 soup = BeautifulSoup(html, "html.parser")225 out: dict = {}226227 desc_el = soup.select_one("#property-description-wrap")228 if desc_el:229 txt = desc_el.get_text("\n", strip=True)230 txt = re.sub(r"^Description\n", "", txt)231 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]232233 out["amenities"] = [a.get_text(" ", strip=True)234 for a in soup.select("#property-features-wrap li")235 if a.get_text(strip=True)][:25]236237 # bloc adresse : « Adresse | 375 rue terrill | Ville |238 # Fleurimont (Sherbrooke) | Code postal | J1E 3S7 »239 for li in soup.select("#property-address-wrap li"):240 st, sp = li.find("strong"), li.find("span")241 if not (st and sp):242 continue243 lab = strip_accents(st.get_text(" ", strip=True).lower())244 val = sp.get_text(" ", strip=True)245 if lab.startswith("adresse"):246 out["address"] = val247 elif lab.startswith("ville"):248 out["city_raw"] = val249 elif "postal" in lab:250 out["postal"] = val251252 # adresse Nominatim complète (repli ville/secteur)253 addr_el = soup.select_one("address.item-address")254 if addr_el:255 out["item_address"] = addr_el.get_text(" ", strip=True)256257 # type d'unité : aperçu Houzez (« Appartement 4 1/2 ») — exiger un258 # motif net (« … 4 1/2 », « Studio »…), jamais le mot « chambres » du259 # compteur de pièces260 ov = soup.select_one(".property-overview-wrap, #property-overview-wrap")261 if ov:262 m = re.search(r"(\d\s*(?:1/2|½)|\bstudio\b|\bloft\b|\bmaison\b)",263 ov.get_text(" ", strip=True), re.I)264 if m:265 out["type_raw"] = m.group(1)266267 m = _MAP_LATLNG_RE.search(html)268 if m:269 out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))270 return out271272 def _apply_detail(self, lst: Listing, d: dict) -> None:273 """Reporte le payload (frais ou cache BD) sur l'annonce."""274 if not d:275 return276 if d.get("description"):277 lst.description = d["description"]278 if d.get("amenities"):279 lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))280281 # adresse structurée de la fiche ; « Ville » au format282 # « Fleurimont (Sherbrooke) » (secteur (ville)) ou « Magog »283 if d.get("address") and not lst.address:284 lst.address = d["address"]285 city_raw = (d.get("city_raw") or "").strip()286 if city_raw:287 m = re.match(r"^(.*?)\s*\((.+)\)\s*$", city_raw)288 inner = (m.group(2).strip() if m else city_raw)289 outer = (m.group(1).strip() if m else "")290 c, _ = _city_from_parts([inner])291 if c: # « Fleurimont (Sherbrooke) »292 lst.city = c293 if outer and not lst.sector:294 lst.sector = outer295 else:296 c2, _ = _city_from_parts([city_raw])297 if c2: # « Magog »298 lst.city = c2299 elif not lst.sector: # « Nord », « Centre-ville »…300 lst.sector = city_raw301 elif d.get("item_address"):302 parts = [p.strip() for p in d["item_address"].split(",") if p.strip()]303 c, s = _city_from_parts(parts)304 if c:305 lst.city = c306 if s and not lst.sector:307 lst.sector = s308 if not lst.address and d.get("item_address"):309 parts = [p.strip() for p in d["item_address"].split(",") if p.strip()]310 if len(parts) >= 2:311 lst.address = ", ".join(parts[:2])312313 if not lst.unit_type and d.get("type_raw"):314 lst.unit_type = normalize_unit_type(d["type_raw"])315 if d.get("lat") is not None and d.get("lng") is not None:316 lst.lat, lst.lng = d["lat"], d["lng"]317