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/jutras.py : connecteur Habitations Jutras (jutras.com)5# Promoteur-gestionnaire au Centre-du-Québec et en Estrie. WordPress + Avada6# (Fusion Builder) : /condos-a-louer/ liste les projets locatifs7# (/condo/<slug>/) ; chaque page projet expose une section « LES UNITÉS »8# (ancre id="prix") avec, par type d'unité (3½/4½/5½) : superficie,9# chambres, stationnements et « À partir de X$/mois ». Une annonce par10# (projet, type). Ville réelle tirée du <title> ou de la phrase « situé11# à/dans … » de la page projet. Le projet Prisme (page spéciale12# /condos-a-louer/drummondville/prisme/) est parsé par regex type+prix.13# Exclu : Huit Cents (immeuble 50 ans et plus). robots.txt permissif.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://jutras.com"25LIST_URL = f"{BASE}/condos-a-louer/"26PRISME_URL = f"{BASE}/condos-a-louer/drummondville/prisme/"2728# villes du parc Jutras (Centre-du-Québec / Estrie) — vocabulaire fermé,29# utilisé pour lire la ville RÉELLE du <title> ou du texte de la page30_CITIES = ["Drummondville", "Sherbrooke", "Nicolet", "Notre-Dame-du-Bon-Conseil",31 "East Angus", "Victoriaville", "Bécancour", "Trois-Rivières"]3233_TYPE_TOKEN = re.compile(r"^(\d)\s*½$")34_AREA_RE = re.compile(r"^(\d{3,4})(?:\s*à\s*(\d{3,4}))?\s*pi\s*2?\s*$", re.I)35_PRICE_RE = re.compile(r"partir de\s*[\d\s]+\$", re.I)36# page Prisme : « 3 ½ À partir de 1050 $/mois »37_PRISME_UNIT_RE = re.compile(r"(\d)\s*½\s*À partir de\s*([\d\s]+\$\s*/\s*mois)")383940class JutrasConnector(BaseConnector):41 source_id = "jutras"42 request_delay = 0.743 max_pages = 20 # garde-fou : nombre max de pages projet visitées4445 def fetch(self) -> list[Listing]:46 listings: list[Listing] = []4748 # 1) répertoire des projets : liens /condo/<slug>/ + nom d'affichage49 html = self.get(LIST_URL).text50 soup = BeautifulSoup(html, "html.parser")51 projects: dict[str, str] = {} # slug -> nom du projet52 for a in soup.select('a[href*="/condo/"]'):53 m = re.search(r"/condo/([^/#?]+)/?", a.get("href", ""))54 name = a.get_text(" ", strip=True)55 if m and name and m.group(1) not in projects:56 projects[m.group(1)] = name5758 # 2) chaque page projet : section « LES UNITÉS » (ancre id="prix")59 for slug, name in list(projects.items())[: self.max_pages]:60 try:61 page = self.get(f"{BASE}/condo/{slug}/").text62 except Exception:63 continue64 listings.extend(self._parse_project(slug, f"{BASE}/condo/{slug}/",65 name, page))6667 # 3) projet Prisme (Drummondville) : page spéciale hors /condo/68 try:69 page = self.get(PRISME_URL).text70 listings.extend(self._parse_prisme(page))71 except Exception:72 pass7374 return listings7576 # -- ville réelle du projet --------------------------------------------------77 @staticmethod78 def _project_city(title: str, body_text: str) -> str:79 for c in _CITIES: # <title> : « … à louer à Sherbrooke »80 if c.lower() in title.lower():81 return c82 # sinon : phrase « situé … » de la page projet (en ignorant les83 # repères « à X minutes de Y ») — « … situé dans le charmant village84 # de Notre-Dame-du-Bon-Conseil »85 for m in re.finditer(r"[Ss]itu[ée][^.]{0,220}", body_text):86 phrase = re.sub(r"\d+\s+minutes?\s+de\s+\S+", "", m.group(0))87 for c in _CITIES: # « village/ville de X » d'abord88 if re.search(r"(?:village|ville|municipalité)\s+de\s+"89 + re.escape(c), phrase, re.I):90 return c91 for c in _CITIES:92 if c.lower() in phrase.lower():93 return c94 return ""9596 # -- page projet standard (/condo/<slug>/) ------------------------------------97 def _parse_project(self, slug: str, url: str, name: str,98 html: str) -> list[Listing]:99 i = html.find('id="prix"')100 if i < 0:101 return [] # pas de section unités → rien102 soup = BeautifulSoup(html, "html.parser")103 title_tag = soup.title.get_text() if soup.title else ""104 if re.search(r"50 ans|a[îi]n[ée]s|retraite", title_tag, re.I):105 return [] # immeuble pour aînés : exclu106 city = self._project_city(title_tag, soup.get_text(" ", strip=True))107108 og = soup.select_one('meta[property="og:image"][content]')109 images = [og["content"]] if og else []110111 # tokens texte de la section unités (jusqu'aux inclusions)112 section = BeautifulSoup(html[i:i + 80000], "html.parser")113 tokens: list[str] = []114 for el in section.find_all(["p", "h2", "h3", "h4"]):115 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))116 if not t:117 continue118 # fin de la section unités : inclusions/galerie/formulaire119 if re.search(r"Inclusions|commodités|Caractéristiques|LOOKBOOK|"120 r"Réservez|Téléchargez|Galerie", t, re.I):121 break122 if el.name != "p" or re.match(r"^LES UNITÉS$|^Découvrez", t):123 continue # titres de section : ignorés124 tokens.append(t)125126 # en-tête de section : dispo projet + mention de prix « à partir de »127 avail_proj, price_proj, intro = "", "", []128 cards: list[dict] = []129 cur: dict | None = None130 for t in tokens:131 m = _TYPE_TOKEN.match(t)132 if m:133 cur = {"type": f"{m.group(1)} ½", "lines": []}134 cards.append(cur)135 continue136 if cur is None:137 intro.append(t)138 if re.search(r"[Oo]ccupation|[Dd]éménagez|[Ee]mménagez", t) \139 and not avail_proj:140 avail_proj = t141 if _PRICE_RE.search(t) and not price_proj:142 price_proj = t143 elif not re.search(r"^Voir (le|la|les)|Prix sujets", t, re.I):144 cur["lines"].append(t)145146 out: list[Listing] = []147 for idx, card in enumerate(cards):148 unit_type = normalize_unit_type(card["type"])149 area_sqft, availability, price_label = None, "", ""150 lines: list[str] = []151 for t in card["lines"]:152 ma = _AREA_RE.match(t)153 if ma and area_sqft is None:154 area_sqft = float(ma.group(1)) # borne basse si « X à Y pi² »155 elif _PRICE_RE.search(t) and not price_label:156 price_label = t157 elif re.search(r"[Oo]ccupation", t) and not availability:158 availability = t159 lines.append(t)160 if not availability:161 availability = avail_proj # dispo affichée au projet162 # mention projet « Location à partir de X $ par mois » : ne163 # s'applique qu'au type le moins cher (1re carte), jamais aux autres164 if not price_label and idx == 0 and price_proj:165 price_label = price_proj166167 out.append(Listing(168 source=self.source_id,169 external_id=f"{slug}-{card['type'].replace(' ½', '-1-2')}",170 url=f"{url}#prix",171 title=f"{name} — {card['type']}",172 sector="",173 city=city,174 unit_type=unit_type,175 price=parse_price(price_label),176 price_label=price_label,177 availability=availability,178 area_sqft=area_sqft,179 description=" · ".join(intro + lines)[:900],180 images=images,181 ))182 return out183184 # -- page spéciale Prisme -------------------------------------------------185 def _parse_prisme(self, html: str) -> list[Listing]:186 soup = BeautifulSoup(html, "html.parser")187 text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))188 og = soup.select_one('meta[property="og:image"][content]')189 images = [og["content"]] if og else []190 m = re.search(r"(?:Emménagez|Déménagez|Occupation)[^.]{0,60}?"191 r"(?:dès|d[eè]s le)\s+[^.]{3,40}?(?=\s+voir|\s+3\s*½|\.)",192 text)193 availability = m.group(0).strip() if m else ""194195 out: list[Listing] = []196 for mt in _PRISME_UNIT_RE.finditer(text):197 n, label = mt.group(1), re.sub(r"\s+", " ", mt.group(2)).strip()198 ext_id = f"prisme-{n}-1-2"199 if any(l.external_id == ext_id for l in out):200 continue201 price_label = f"À partir de {label}"202 out.append(Listing(203 source=self.source_id,204 external_id=ext_id,205 url=PRISME_URL,206 title=f"Prisme — {n} ½",207 sector="",208 city="Drummondville", # « Condos locatifs à Drummondville »209 unit_type=normalize_unit_type(f"{n} ½"),210 price=parse_price(price_label),211 price_label=price_label,212 availability=availability,213 images=images,214 ))215 return out216