# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/may_bourg.py : connecteur Gestion May Bourg (maybourg.com) # Parc de 162 unités à Bécancour (Centre-du-Québec). WordPress + Elementor + # Dynamic Content for Elementor : la page /repertoire-de-logements/ liste les # modèles disponibles en
avec data-dce-post-id # stable (ID de post WP), titre, projet, adresse, prix (« 1350$ / mois »), # superficie (« 1048 P.C. ») et badge « Unités disponibles ». Les fiches # /logement// (via cache BD) ajoutent la description et la galerie. # robots.txt : /wp-json/ interdit → HTML seulement (respecté). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://maybourg.com" LIST_URL = f"{BASE}/repertoire-de-logements/" _PRICE_RE = re.compile(r"\d[\d\s,.]*\$") _AREA_RE = re.compile(r"(\d[\d\s]*)\s*P\.?\s*C\.?", re.I) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp))", re.I) class MayBourgConnector(BaseConnector): source_id = "may_bourg" request_delay = 0.7 max_details = 20 # garde-fou fiches détail (parc ~7 modèles affichés) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} # la grille DCE est dupliquée (variantes responsive) : dédup par post-id for art in soup.select("article.logement[data-dce-post-id]"): try: self._parse_card(art, listings) except Exception: continue # fiches détail (cache BD) : description + galerie complète 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 if payload.get("description"): lst.description = payload["description"] if payload.get("images"): lst.images = payload["images"] return list(listings.values()) # -- carte de la grille DCE ------------------------------------------------ def _parse_card(self, art, listings: dict[str, Listing]) -> None: ext_id = str(art.get("data-dce-post-id", "")).strip() link = art.select_one('a[href*="/logement/"]') if not ext_id or not link or ext_id in listings: return url = link["href"] # widgets texte de la carte : titre, projet, adresse, prix, superficie, # badge « Unités disponibles » — identifiés par leur contenu (l'ordre # des conteneurs Elementor n'est pas garanti) texts: list[str] = [] for el in art.select(".elementor-widget-text-editor " ".elementor-widget-container"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if t and t not in texts: texts.append(t) title = texts[0] if texts else "" if not title: return # exclusions : commercial / stationnement / rangement if re.search(r"commercial|stationnement|rangement|entrep[oô]t", title, re.I): return price_label = address = availability = area_txt = sector = "" for t in texts[1:]: if _PRICE_RE.search(t) and not price_label: price_label = t elif _AREA_RE.search(t) and not area_txt: area_txt = t elif re.search(r"disponible", t, re.I) and not availability: availability = t elif re.search(r"b[ée]cancour", t, re.I) and not address: address = t elif not sector: sector = t # nom du projet (« Logements Port Royal »…) # type d'unité : titre (« 4 1/2 – 2e étage »), repli sur la classe # taxonomique WP « type-de-logement-4-1-2 » unit_type = normalize_unit_type(title) if not unit_type: for c in art.get("class", []): m = re.match(r"type-de-logement-(\d)-1-2", c) if m: unit_type = normalize_unit_type(f"{m.group(1)} 1/2") break area_sqft = None m = _AREA_RE.search(area_txt) if m: area_sqft = float(m.group(1).replace(" ", "")) images: list[str] = [] img = art.select_one("img[src]") if img: src = _SIZE_SUFFIX.sub("", img["src"].split("?")[0]) if src.startswith("http"): images.append(src) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, # ID du post WordPress (stable) url=url, title=title, address=address, sector=sector, # tout le parc May Bourg (162 unités) est à Bécancour : # projets rue Roy, boul. de Port-Royal et Godefroy (cf. site) city="Bécancour", unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area_sqft, images=images, ) # -- fiche détail /logement// ----------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description (paragraphe principal de la fiche) + galerie complète.""" 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 = {} # description = plus long paragraphe des widgets texte (la fiche n'a # qu'un seul vrai bloc descriptif) paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) for p in soup.select(".elementor-widget-text-editor p")] paras = [p for p in paras if len(p) > 120] if paras: out["description"] = max(paras, key=len)[:1200] images: list[str] = [] for img in soup.select("img[src*='/wp-content/uploads/']"): src = _SIZE_SUFFIX.sub("", (img.get("src") or "").split("?")[0]) if src.startswith("http") and src not in images \ and not re.search(r"logo|icon", src, re.I): images.append(src) if images: out["images"] = images[:30] return out