# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/jutras.py : connecteur Habitations Jutras (jutras.com) # Promoteur-gestionnaire au Centre-du-Québec et en Estrie. WordPress + Avada # (Fusion Builder) : /condos-a-louer/ liste les projets locatifs # (/condo//) ; chaque page projet expose une section « LES UNITÉS » # (ancre id="prix") avec, par type d'unité (3½/4½/5½) : superficie, # chambres, stationnements et « À partir de X$/mois ». Une annonce par # (projet, type). Ville réelle tirée du ou de la phrase « situé # à/dans … » de la page projet. Le projet Prisme (page spéciale # /condos-a-louer/drummondville/prisme/) est parsé par regex type+prix. # Exclu : Huit Cents (immeuble 50 ans et plus). robots.txt permissif. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://jutras.com" LIST_URL = f"{BASE}/condos-a-louer/" PRISME_URL = f"{BASE}/condos-a-louer/drummondville/prisme/" # villes du parc Jutras (Centre-du-Québec / Estrie) — vocabulaire fermé, # utilisé pour lire la ville RÉELLE du <title> ou du texte de la page _CITIES = ["Drummondville", "Sherbrooke", "Nicolet", "Notre-Dame-du-Bon-Conseil", "East Angus", "Victoriaville", "Bécancour", "Trois-Rivières"] _TYPE_TOKEN = re.compile(r"^(\d)\s*½$") _AREA_RE = re.compile(r"^(\d{3,4})(?:\s*à\s*(\d{3,4}))?\s*pi\s*2?\s*$", re.I) _PRICE_RE = re.compile(r"partir de\s*[\d\s]+\$", re.I) # page Prisme : « 3 ½ À partir de 1050 $/mois » _PRISME_UNIT_RE = re.compile(r"(\d)\s*½\s*À partir de\s*([\d\s]+\$\s*/\s*mois)") class JutrasConnector(BaseConnector): source_id = "jutras" request_delay = 0.7 max_pages = 20 # garde-fou : nombre max de pages projet visitées def fetch(self) -> list[Listing]: listings: list[Listing] = [] # 1) répertoire des projets : liens /condo/<slug>/ + nom d'affichage html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") projects: dict[str, str] = {} # slug -> nom du projet for a in soup.select('a[href*="/condo/"]'): m = re.search(r"/condo/([^/#?]+)/?", a.get("href", "")) name = a.get_text(" ", strip=True) if m and name and m.group(1) not in projects: projects[m.group(1)] = name # 2) chaque page projet : section « LES UNITÉS » (ancre id="prix") for slug, name in list(projects.items())[: self.max_pages]: try: page = self.get(f"{BASE}/condo/{slug}/").text except Exception: continue listings.extend(self._parse_project(slug, f"{BASE}/condo/{slug}/", name, page)) # 3) projet Prisme (Drummondville) : page spéciale hors /condo/ try: page = self.get(PRISME_URL).text listings.extend(self._parse_prisme(page)) except Exception: pass return listings # -- ville réelle du projet -------------------------------------------------- @staticmethod def _project_city(title: str, body_text: str) -> str: for c in _CITIES: # <title> : « … à louer à Sherbrooke » if c.lower() in title.lower(): return c # sinon : phrase « situé … » de la page projet (en ignorant les # repères « à X minutes de Y ») — « … situé dans le charmant village # de Notre-Dame-du-Bon-Conseil » for m in re.finditer(r"[Ss]itu[ée][^.]{0,220}", body_text): phrase = re.sub(r"\d+\s+minutes?\s+de\s+\S+", "", m.group(0)) for c in _CITIES: # « village/ville de X » d'abord if re.search(r"(?:village|ville|municipalité)\s+de\s+" + re.escape(c), phrase, re.I): return c for c in _CITIES: if c.lower() in phrase.lower(): return c return "" # -- page projet standard (/condo/<slug>/) ------------------------------------ def _parse_project(self, slug: str, url: str, name: str, html: str) -> list[Listing]: i = html.find('id="prix"') if i < 0: return [] # pas de section unités → rien soup = BeautifulSoup(html, "html.parser") title_tag = soup.title.get_text() if soup.title else "" if re.search(r"50 ans|a[îi]n[ée]s|retraite", title_tag, re.I): return [] # immeuble pour aînés : exclu city = self._project_city(title_tag, soup.get_text(" ", strip=True)) og = soup.select_one('meta[property="og:image"][content]') images = [og["content"]] if og else [] # tokens texte de la section unités (jusqu'aux inclusions) section = BeautifulSoup(html[i:i + 80000], "html.parser") tokens: list[str] = [] for el in section.find_all(["p", "h2", "h3", "h4"]): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if not t: continue # fin de la section unités : inclusions/galerie/formulaire if re.search(r"Inclusions|commodités|Caractéristiques|LOOKBOOK|" r"Réservez|Téléchargez|Galerie", t, re.I): break if el.name != "p" or re.match(r"^LES UNITÉS$|^Découvrez", t): continue # titres de section : ignorés tokens.append(t) # en-tête de section : dispo projet + mention de prix « à partir de » avail_proj, price_proj, intro = "", "", [] cards: list[dict] = [] cur: dict | None = None for t in tokens: m = _TYPE_TOKEN.match(t) if m: cur = {"type": f"{m.group(1)} ½", "lines": []} cards.append(cur) continue if cur is None: intro.append(t) if re.search(r"[Oo]ccupation|[Dd]éménagez|[Ee]mménagez", t) \ and not avail_proj: avail_proj = t if _PRICE_RE.search(t) and not price_proj: price_proj = t elif not re.search(r"^Voir (le|la|les)|Prix sujets", t, re.I): cur["lines"].append(t) out: list[Listing] = [] for idx, card in enumerate(cards): unit_type = normalize_unit_type(card["type"]) area_sqft, availability, price_label = None, "", "" lines: list[str] = [] for t in card["lines"]: ma = _AREA_RE.match(t) if ma and area_sqft is None: area_sqft = float(ma.group(1)) # borne basse si « X à Y pi² » elif _PRICE_RE.search(t) and not price_label: price_label = t elif re.search(r"[Oo]ccupation", t) and not availability: availability = t lines.append(t) if not availability: availability = avail_proj # dispo affichée au projet # mention projet « Location à partir de X $ par mois » : ne # s'applique qu'au type le moins cher (1re carte), jamais aux autres if not price_label and idx == 0 and price_proj: price_label = price_proj out.append(Listing( source=self.source_id, external_id=f"{slug}-{card['type'].replace(' ½', '-1-2')}", url=f"{url}#prix", title=f"{name} — {card['type']}", sector="", city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area_sqft, description=" · ".join(intro + lines)[:900], images=images, )) return out # -- page spéciale Prisme ------------------------------------------------- def _parse_prisme(self, html: str) -> list[Listing]: soup = BeautifulSoup(html, "html.parser") text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) og = soup.select_one('meta[property="og:image"][content]') images = [og["content"]] if og else [] m = re.search(r"(?:Emménagez|Déménagez|Occupation)[^.]{0,60}?" r"(?:dès|d[eè]s le)\s+[^.]{3,40}?(?=\s+voir|\s+3\s*½|\.)", text) availability = m.group(0).strip() if m else "" out: list[Listing] = [] for mt in _PRISME_UNIT_RE.finditer(text): n, label = mt.group(1), re.sub(r"\s+", " ", mt.group(2)).strip() ext_id = f"prisme-{n}-1-2" if any(l.external_id == ext_id for l in out): continue price_label = f"À partir de {label}" out.append(Listing( source=self.source_id, external_id=ext_id, url=PRISME_URL, title=f"Prisme — {n} ½", sector="", city="Drummondville", # « Condos locatifs à Drummondville » unit_type=normalize_unit_type(f"{n} ½"), price=parse_price(price_label), price_label=price_label, availability=availability, images=images, )) return out