# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/dynamic.py : connecteur Gestion immobilière Dynamic # (lesgestionsdynamic.com) — apparts à Sherbrooke, Magog, Granby, East Angus. # WordPress + thème immobilier Houzez, même famille que gimcote.py : archive # /a-louer/ paginée (cartes div.item-listing-wrap avec prix, galerie # data-images, data-listid). Particularité locale : pas d'adresse ni de # statut sur la carte — la disponibilité est un suffixe du titre # (« IMMÉDIATEMENT », « JUILLET », « 1er AOÛT »…) et l'adresse/ville/GPS # viennent de la fiche détail (bloc #property-address-wrap, carte Houzez). # Sert de CLASSE DE BASE à la famille Houzez de l'Estrie : Gestimmo Estrie # (gestimmo_estrie.py) et Agence de location Sherbrooke / groupe Prestiplex # (agence_sherbrooke.py) n'en redéfinissent que les constantes et les # crochets carte (statut/étiquette/adresse). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import json import re from bs4 import BeautifulSoup from ..schema import (Listing, normalize_unit_type, parse_price, strip_accents) from .base import BaseConnector # variantes redimensionnées WordPress (-584x438.jpg) -> pleine taille _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _MAP_LATLNG_RE = re.compile( r'"lat"\s*:\s*"?(-?\d+\.\d+)"?\s*,\s*"lng"\s*:\s*"?(-?\d+\.\d+)"?') # villes réelles desservies par la famille (jamais devinées : on ne retient la # ville que si elle apparaît telle quelle dans l'adresse ou le bloc « Ville ») _KNOWN_CITIES = [ "Sherbrooke", "Magog", "Granby", "East Angus", "Orford", "Waterville", "Windsor", "Coaticook", "Ascot Corner", "Lennoxville", "Richmond", "Cookshire-Eaton", "Bromptonville", "Cowansville", "Bromont", "Valcourt", ] # suffixe de disponibilité dans les titres Dynamic : « IMMÉDIATEMENT », # « JUILLET », « 1er AOÛT », « LOUÉ »… _AVAIL_TITLE_RE = re.compile( r"(imm[eé]diatement|d[èe]s maintenant|lou[ée]|" r"(?:1er\s+|15\s+)?(?:janvier|f[ée]vrier|mars|avril|mai|juin|juillet|" r"ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)(?:\s+20\d\d)?)\s*$", re.I) def _clean_price_label(label: str) -> str: """'1,450$/mois' ou '1 450 $/mois' -> compatible parse_price.""" return re.sub(r"(\d)[,\s](\d{3})", r"\1\2", label) def _city_from_parts(parts: list[str]) -> tuple[str, str]: """(ville, secteur) depuis les segments Nominatim de address.item-address : « 89, Rue des Pins, East Angus, Le Haut-Saint-François, … » — la ville est le segment qui correspond à une ville connue, le secteur le segment qui la précède (micro-quartier) quand il ne fait pas partie de l'adresse civique.""" for i, p in enumerate(parts): for city in _KNOWN_CITIES: if strip_accents(p.strip().lower()) == strip_accents(city.lower()): sector = "" if i >= 3: # [n° civique, rue, quartier, ville, ...] sector = parts[i - 1].strip() return city, sector return "", "" class DynamicConnector(BaseConnector): source_id = "dynamic" request_delay = 0.6 max_pages = 12 # garde-fou de pagination max_details = 80 # garde-fou fiches détail (vraies requêtes) BASE = "https://lesgestionsdynamic.com" LIST_PATH = "/a-louer/" CITY_DEFAULT = "Sherbrooke" # repli si la fiche ne précise pas la ville def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): url = (f"{self.BASE}{self.LIST_PATH}" if page == 1 else f"{self.BASE}{self.LIST_PATH}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) : adresse/ville/GPS, description, # caractéristiques, type d'unité (aperçu Houzez), galerie complète self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" .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()) # -- crochets par site -------------------------------------------------------- def _card_availability(self, card, title: str) -> str: """Dynamic : la disponibilité est un suffixe du titre de l'annonce.""" m = _AVAIL_TITLE_RE.search(title) return m.group(1).strip() if m else "" # -- 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 = re.sub(r"\s+", " ", link.get_text(" ", strip=True)).strip() m = re.search(r"/(?:property|appartement)/([^/]+)/?", 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 str(ext_id) in listings: return # exclusions : logements loués + espaces non résidentiels if re.search(r"\blou[ée]s?\b", title, re.I): return if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t", title, re.I): return availability = self._card_availability(card, title) if re.search(r"lou[ée]", availability, re.I): return price_el = card.select_one("li.item-price") price_label = price_el.get_text(" ", strip=True) if price_el else "" # adresse Nominatim de la carte (présente chez Agence, absente chez # Dynamic/Gestimmo — la fiche détail prendra le relais) address = sector = "" city = self.CITY_DEFAULT addr_el = card.select_one("address.item-address") if addr_el: full = addr_el.get_text(" ", strip=True) parts = [p.strip() for p in full.split(",") if p.strip()] address = ", ".join(parts[:2]) if len(parts) >= 2 else full c, s = _city_from_parts(parts) if c: city, sector = c, s # commodités des cartes Houzez : chambres / salles de bain / pi² amenities: list[str] = [] for li in card.select("ul.item-amenities li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) t = t.replace("Beds:", "Chambres :").replace("Bath:", "Salle(s) de bain :") if t and t not in amenities: amenities.append(t) # galerie : 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: if isinstance(u, dict): # variante Houzez : objets {url: …} u = u.get("url") or u.get("src") or u.get("image") or "" if not isinstance(u, str): continue u = u.replace("\\/", "/").strip() if u.startswith("http"): u = _SIZE_SUFFIX.sub("", u) 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"])] # type d'unité depuis le titre, seulement si le motif est net # (normalize_unit_type retourne le texte brut quand rien ne matche) unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = "" 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: """Adresse structurée (#property-address-wrap), description, caractéristiques, type d'unité (aperçu) et GPS (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()[:1500] out["amenities"] = [a.get_text(" ", strip=True) for a in soup.select("#property-features-wrap li") if a.get_text(strip=True)][:25] # bloc adresse : « Adresse | 375 rue terrill | Ville | # Fleurimont (Sherbrooke) | Code postal | J1E 3S7 » for li in soup.select("#property-address-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 lab.startswith("adresse"): out["address"] = val elif lab.startswith("ville"): out["city_raw"] = val elif "postal" in lab: out["postal"] = val # adresse Nominatim complète (repli ville/secteur) addr_el = soup.select_one("address.item-address") if addr_el: out["item_address"] = addr_el.get_text(" ", strip=True) # type d'unité : aperçu Houzez (« Appartement 4 1/2 ») — exiger un # motif net (« … 4 1/2 », « Studio »…), jamais le mot « chambres » du # compteur de pièces ov = soup.select_one(".property-overview-wrap, #property-overview-wrap") if ov: m = re.search(r"(\d\s*(?:1/2|½)|\bstudio\b|\bloft\b|\bmaison\b)", ov.get_text(" ", strip=True), re.I) if m: out["type_raw"] = m.group(1) 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 ou cache BD) 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"])) # adresse structurée de la fiche ; « Ville » au format # « Fleurimont (Sherbrooke) » (secteur (ville)) ou « Magog » if d.get("address") and not lst.address: lst.address = d["address"] city_raw = (d.get("city_raw") or "").strip() if city_raw: m = re.match(r"^(.*?)\s*\((.+)\)\s*$", city_raw) inner = (m.group(2).strip() if m else city_raw) outer = (m.group(1).strip() if m else "") c, _ = _city_from_parts([inner]) if c: # « Fleurimont (Sherbrooke) » lst.city = c if outer and not lst.sector: lst.sector = outer else: c2, _ = _city_from_parts([city_raw]) if c2: # « Magog » lst.city = c2 elif not lst.sector: # « Nord », « Centre-ville »… lst.sector = city_raw elif d.get("item_address"): parts = [p.strip() for p in d["item_address"].split(",") if p.strip()] c, s = _city_from_parts(parts) if c: lst.city = c if s and not lst.sector: lst.sector = s if not lst.address and d.get("item_address"): parts = [p.strip() for p in d["item_address"].split(",") if p.strip()] if len(parts) >= 2: lst.address = ", ".join(parts[:2]) if not lst.unit_type and d.get("type_raw"): lst.unit_type = normalize_unit_type(d["type_raw"]) if d.get("lat") is not None and d.get("lng") is not None: lst.lat, lst.lng = d["lat"], d["lng"]