Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/groupe_mathieu.py : connecteur Groupe Mathieu (groupemathieu.com)5# Promoteur Rive-Nord : condos locatifs à Blainville (Station 54/56,6# Le Siège Social, Place Notre Dame), Sainte-Thérèse (Ste-Thé, Quartier7# Belmont), Terrebonne (Noüvo District) et Laval. WordPress/Elementor rendu8# serveur : le méga-menu « PROJETS À LOUER » liste les projets par ville ;9# chaque page projet affiche l'adresse, « Occupation … » et des blocs10# typologie (« 3 ½ », description pi²/chambres, « À partir de N$ par mois »).11# Granularité : une annonce par TYPOLOGIE de projet (sinon une par projet12# quand la page ne détaille pas les typologies). Les projets « 100 % vendu/13# loué » sont exclus.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type22from .base import BaseConnector2324BASE = "https://groupemathieu.com"2526UNIT_RE = re.compile(r"^\s*(\d)\s*½\s*$")27FROM_RE = re.compile(r"[ÀA]\s*partir\s*de\s*([\d\s ,]+)\s*\$\s*(?:/|par)?"28 r"\s*mois", re.I)29AREA_RE = re.compile(r"de\s*([\d\s ]+)\s*(?:à\s*[\d\s ]+\s*)?pi", re.I)30BED_RE = re.compile(r"(\d)\s*chambres?\b", re.I)31ADDR_RE = re.compile(32 r"\d{1,5}[^,<>\n]{3,60},?\s*(?:<br\s*/?>)?\s*"33 r"[A-Za-zÀ-ÿ\- ]{3,30},?\s*\(Qu[ée]bec\)\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d")34OCC_RE = re.compile(r"Occupation\s+[^<\n]{2,40}", re.I)35SOLD_RE = re.compile(r"100\s*%\s*(?:vendu|lou[ée])", re.I)36IMG_RE = re.compile(r"https://groupemathieu\.com/wp-content/uploads/"37 r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)383940class GroupeMathieuConnector(BaseConnector):41 source_id = "groupe_mathieu"42 request_delay = 0.74344 def fetch(self) -> list[Listing]:45 listings: list[Listing] = []46 projects: list[tuple[str, str, str]] = [] # (nom, url, ville)4748 # Découverte primaire : la page /condos-locatifs/ (rendue serveur,49 # stable) liste tous les projets locatifs via des cartes50 # « En savoir plus ». Le méga-menu Jet de l'accueil n'est servi que51 # par certaines variantes de cache Elementor → repli seulement.52 try:53 lhtml = self.get(BASE + "/condos-locatifs/").text54 lsoup = BeautifulSoup(lhtml, "html.parser")55 for a in lsoup.find_all("a", href=True):56 if "en savoir plus" not in a.get_text(" ", strip=True).lower():57 continue58 href = a["href"].split("?")[0]59 if href.startswith("/"):60 href = BASE + href61 if "groupemathieu.com" not in href or "promo" in href:62 continue63 projects.append(("", href, ""))64 except Exception:65 pass6667 if not projects:68 projects = self._projects_from_menu()6970 seen: set[str] = set()71 for name, url, ville in projects:72 slug = url.rstrip("/").split("/")[-1]73 if not slug or slug in seen:74 continue75 seen.add(slug)76 try:77 listings.extend(self._fetch_project(name, url, slug, ville))78 except Exception:79 continue80 return listings8182 def _projects_from_menu(self) -> list[tuple[str, str, str]]:83 # méga-menu « PROJETS À LOUER » : colonnes ville (h5) + items projets84 try:85 html = self.get(BASE + "/").text86 except Exception:87 return []88 soup = BeautifulSoup(html, "html.parser")89 menu = None90 for title in soup.select(".jet-menu-title, .jet-menu-item"):91 if "PROJETS À LOUER" in title.get_text(" ", strip=True).upper():92 menu = title if title.name == "li" else \93 title.find_parent("li")94 break95 if menu is None:96 return []97 sub = menu.select_one(".jet-sub-mega-menu")98 if sub is None:99 return []100101 projects: list[tuple[str, str, str]] = []102 city = ""103 for el in sub.select("h5.elementor-heading-title,"104 " li.elementor-icon-list-item a"):105 if el.name == "h5":106 city = el.get_text(" ", strip=True).title()107 continue108 label_el = el.select_one(".elementor-icon-list-text")109 label = label_el.get_text(" ", strip=True) if label_el else ""110 if "locatif" not in label.lower():111 continue112 href = el.get("href", "")113 if href.startswith("/"):114 href = BASE + href115 strong = el.find("strong")116 name = label.replace(strong.get_text(strip=True), "").strip() \117 if strong else label118 if href:119 projects.append((name or href, href.split("?")[0], city))120 return projects121122 seen: set[str] = set()123 for name, url, ville in projects:124 slug = url.rstrip("/").split("/")[-1]125 if not slug or slug in seen:126 continue127 seen.add(slug)128 try:129 listings.extend(self._fetch_project(name, url, slug, ville))130 except Exception:131 continue132 return listings133134 def _fetch_project(self, name: str, url: str, slug: str,135 city: str) -> list[Listing]:136 html = self.get(url).text137 if SOLD_RE.search(html):138 return [] # projet complet (ex. Vëe « 100% vendu »)139 soup = BeautifulSoup(html, "html.parser")140 text = soup.get_text("\n", strip=True)141142 address = ""143 m = ADDR_RE.search(text.replace("\n", ", "))144 if m:145 address = re.sub(r"\s+", " ", m.group(0)).replace(" ,", ",")146 if not city and address: # découverte via /condos-locatifs/ : ville147 m = re.search(r",\s*([A-Za-zÀ-ÿ\- ']{3,30}),?\s*\(Qu", address)148 if m:149 city = m.group(1).strip().title()150 if not name:151 h1 = soup.find("h1")152 t = h1.get_text(" ", strip=True) if h1 else ""153 if not t and soup.title:154 t = soup.title.get_text(strip=True).split("|")[0].strip()155 t = re.sub(r"\s*[-–|]\s*Groupe Mathieu\s*$", "", t, flags=re.I)156 name = re.sub(r"\s+", " ", t) or slug157 availability = ""158 m = OCC_RE.search(text)159 if m:160 availability = re.sub(r"\s+", " ", m.group(0)).strip()161 rented = ""162 m = re.search(r"LOU[ÉE] À\s*\n?\s*(\d{1,3}\s*%)", text, re.I)163 if m:164 rented = m.group(1).replace(" ", "")165166 images = [u for u in dict.fromkeys(IMG_RE.findall(html))167 if not re.search(r"logo|icon|favicon|plan|-\d{2,3}x\d{2,3}\.",168 u, re.I)][:20]169170 # blocs typologie, en ordre du document (dédoublés desktop/mobile)171 units: dict[str, dict] = {}172 current: str | None = None173 for el in soup.select("h2.elementor-heading-title,"174 " .elementor-widget-text-editor p"):175 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))176 if not t:177 continue178 m = UNIT_RE.match(t)179 if m:180 ut = f"{m.group(1)}½"181 if ut in units:182 current = None # doublon (version mobile)183 else:184 units[ut] = {}185 current = ut186 continue187 if current is None:188 continue189 u = units[current]190 m = FROM_RE.search(t)191 if m and "price" not in u:192 try:193 u["price"] = float(m.group(1).replace(" ", "")194 .replace(" ", "").replace(" ", "")195 .replace(",", ""))196 u["price_label"] = re.sub(r"\s+", " ", m.group(0))197 except ValueError:198 pass199 current = None # bloc complet200 continue201 if el.name == "p" and len(t) > 40 and "desc" not in u:202 u["desc"] = t[:400]203 m = AREA_RE.search(t)204 if m:205 try:206 u["area"] = float(m.group(1).replace(" ", "")207 .replace(" ", "").replace(" ", ""))208 except ValueError:209 pass210 m = BED_RE.search(t)211 if m:212 u["beds"] = float(m.group(1))213214 details: dict = {}215 if rented:216 details["taux_loue"] = rented217218 out: list[Listing] = []219 if units:220 for ut, u in units.items():221 out.append(Listing(222 source=self.source_id,223 external_id=f"{slug}-{ut.replace('½', '.5')}",224 url=url,225 title=f"{ut} — {name} ({city})",226 address=address,227 city=city,228 unit_type=normalize_unit_type(ut),229 bedrooms=u.get("beds"),230 price=u.get("price"),231 price_label=u.get("price_label", ""),232 availability=availability,233 area_sqft=u.get("area"),234 description=u.get("desc", ""),235 details=dict(details),236 images=images,237 ))238 elif availability: # page sans blocs typologie (ex. Place Notre Dame)239 desc = " ".join(p.get_text(" ", strip=True)240 for p in soup.find_all("p")241 if len(p.get_text(strip=True)) > 80)[:900]242 out.append(Listing(243 source=self.source_id,244 external_id=slug,245 url=url,246 title=f"{name} — condos locatifs ({city})",247 address=address,248 city=city,249 availability=availability,250 description=re.sub(r"\s+", " ", desc),251 details=dict(details),252 images=images,253 ))254 return out255