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/may_bourg.py : connecteur Gestion May Bourg (maybourg.com)5# Parc de 162 unités à Bécancour (Centre-du-Québec). WordPress + Elementor +6# Dynamic Content for Elementor : la page /repertoire-de-logements/ liste les7# modèles disponibles en <article class="logement"> avec data-dce-post-id8# stable (ID de post WP), titre, projet, adresse, prix (« 1350$ / mois »),9# superficie (« 1048 P.C. ») et badge « Unités disponibles ». Les fiches10# /logement/<slug>/ (via cache BD) ajoutent la description et la galerie.11# robots.txt : /wp-json/ interdit → HTML seulement (respecté).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price21from .base import BaseConnector2223BASE = "https://maybourg.com"24LIST_URL = f"{BASE}/repertoire-de-logements/"2526_PRICE_RE = re.compile(r"\d[\d\s,.]*\$")27_AREA_RE = re.compile(r"(\d[\d\s]*)\s*P\.?\s*C\.?", re.I)28_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp))", re.I)293031class MayBourgConnector(BaseConnector):32 source_id = "may_bourg"33 request_delay = 0.734 max_details = 20 # garde-fou fiches détail (parc ~7 modèles affichés)3536 def fetch(self) -> list[Listing]:37 html = self.get(LIST_URL).text38 soup = BeautifulSoup(html, "html.parser")3940 listings: dict[str, Listing] = {}41 # la grille DCE est dupliquée (variantes responsive) : dédup par post-id42 for art in soup.select("article.logement[data-dce-post-id]"):43 try:44 self._parse_card(art, listings)45 except Exception:46 continue4748 # fiches détail (cache BD) : description + galerie complète49 self._fetched = 050 for lst in listings.values():51 card_key = hashlib.sha1(52 f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"53 .encode("utf-8")).hexdigest()54 try:55 payload = self.detail(lst.external_id, card_key,56 lambda u=lst.url: self._fetch_detail(u))57 except Exception:58 continue59 if payload.get("description"):60 lst.description = payload["description"]61 if payload.get("images"):62 lst.images = payload["images"]6364 return list(listings.values())6566 # -- carte de la grille DCE ------------------------------------------------67 def _parse_card(self, art, listings: dict[str, Listing]) -> None:68 ext_id = str(art.get("data-dce-post-id", "")).strip()69 link = art.select_one('a[href*="/logement/"]')70 if not ext_id or not link or ext_id in listings:71 return72 url = link["href"]7374 # widgets texte de la carte : titre, projet, adresse, prix, superficie,75 # badge « Unités disponibles » — identifiés par leur contenu (l'ordre76 # des conteneurs Elementor n'est pas garanti)77 texts: list[str] = []78 for el in art.select(".elementor-widget-text-editor "79 ".elementor-widget-container"):80 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))81 if t and t not in texts:82 texts.append(t)8384 title = texts[0] if texts else ""85 if not title:86 return87 # exclusions : commercial / stationnement / rangement88 if re.search(r"commercial|stationnement|rangement|entrep[oô]t",89 title, re.I):90 return9192 price_label = address = availability = area_txt = sector = ""93 for t in texts[1:]:94 if _PRICE_RE.search(t) and not price_label:95 price_label = t96 elif _AREA_RE.search(t) and not area_txt:97 area_txt = t98 elif re.search(r"disponible", t, re.I) and not availability:99 availability = t100 elif re.search(r"b[ée]cancour", t, re.I) and not address:101 address = t102 elif not sector:103 sector = t # nom du projet (« Logements Port Royal »…)104105 # type d'unité : titre (« 4 1/2 – 2e étage »), repli sur la classe106 # taxonomique WP « type-de-logement-4-1-2 »107 unit_type = normalize_unit_type(title)108 if not unit_type:109 for c in art.get("class", []):110 m = re.match(r"type-de-logement-(\d)-1-2", c)111 if m:112 unit_type = normalize_unit_type(f"{m.group(1)} 1/2")113 break114115 area_sqft = None116 m = _AREA_RE.search(area_txt)117 if m:118 area_sqft = float(m.group(1).replace(" ", ""))119120 images: list[str] = []121 img = art.select_one("img[src]")122 if img:123 src = _SIZE_SUFFIX.sub("", img["src"].split("?")[0])124 if src.startswith("http"):125 images.append(src)126127 listings[ext_id] = Listing(128 source=self.source_id,129 external_id=ext_id, # ID du post WordPress (stable)130 url=url,131 title=title,132 address=address,133 sector=sector,134 # tout le parc May Bourg (162 unités) est à Bécancour :135 # projets rue Roy, boul. de Port-Royal et Godefroy (cf. site)136 city="Bécancour",137 unit_type=unit_type,138 price=parse_price(price_label),139 price_label=price_label,140 availability=availability,141 area_sqft=area_sqft,142 images=images,143 )144145 # -- fiche détail /logement/<slug>/ -----------------------------------------146 def _fetch_detail(self, url: str) -> dict:147 """Description (paragraphe principal de la fiche) + galerie complète."""148 if self._fetched >= self.max_details:149 raise RuntimeError("budget de fiches détail atteint")150 self._fetched += 1151 html = self.get(url).text152 soup = BeautifulSoup(html, "html.parser")153 out: dict = {}154155 # description = plus long paragraphe des widgets texte (la fiche n'a156 # qu'un seul vrai bloc descriptif)157 paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))158 for p in soup.select(".elementor-widget-text-editor p")]159 paras = [p for p in paras if len(p) > 120]160 if paras:161 out["description"] = max(paras, key=len)[:1200]162163 images: list[str] = []164 for img in soup.select("img[src*='/wp-content/uploads/']"):165 src = _SIZE_SUFFIX.sub("", (img.get("src") or "").split("?")[0])166 if src.startswith("http") and src not in images \167 and not re.search(r"logo|icon", src, re.I):168 images.append(src)169 if images:170 out["images"] = images[:30]171 return out172