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/lobato.py : connecteur Groupe Lobato (groupelobato.com —5# promoteur/gestionnaire de la vallée du Richelieu : Beloeil,6# McMasterville, Mont-Saint-Hilaire, Saint-Jean-sur-Richelieu).7# WordPress (thème Vortex Solution) rendu serveur : chaque page8# /project/<slug>/ contient un tableau HTML des unités (Unit | Bed. |9# S.F | Price | Availability | Plan). Seules les lignes « Available »10# sont retenues ; les locaux « Commercial » du Carrefour Laurier sont11# exclus. Granularité = UNITÉ. external_id = <slug-projet>-<slug-unité>12# (slug du lien /unit/ si présent, sinon nom affiché slugifié).13# Colonne Bed. hétérogène : « 1 », « 2 », « 3 », « 1 + den », « 4 1/2 ».14# Photos servies via le proxy PhastPress (phast.php/<base64>.q.jpg) —15# on décode le base64 pour retrouver l'URL wp-content/uploads originale.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import base6420import html as _html21import re22from urllib.parse import unquote2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://groupelobato.com"2829# (slug de page projet, nom du projet, adresse, ville)30PROJECTS: tuple[tuple[str, str, str, str], ...] = (31 ("beloeil-faubourg-du-richelieu", "Le Faubourg du Richelieu",32 "2000, rue Richelieu, Beloeil", "Beloeil"),33 ("carrefour-laurier", "Carrefour Laurier",34 "831, boulevard Laurier, McMasterville", "McMasterville"),35 ("mont-st-hilaire-condos-a-louer-2", "Condos rue de la Sucrerie",36 "rue de la Sucrerie, Mont-Saint-Hilaire", "Mont-Saint-Hilaire"),37 ("mcmasterville-rue-constable", "781-783, rue Constable",38 "781-783, rue Constable, McMasterville", "McMasterville"),39 ("havre-du-richelieu-condos-a-louer", "Havre du Richelieu",40 "81-83, rue Richelieu, Saint-Jean-sur-Richelieu",41 "Saint-Jean-sur-Richelieu"),42)4344ROW_RE = re.compile(r"<tr[^>]*>[\s\S]*?</tr>", re.I)45CELL_RE = re.compile(r"<td[^>]*>([\s\S]*?)</td>", re.I)46UNIT_LINK_RE = re.compile(r'href="https://groupelobato\.com/unit/([^/"]+)/?"')47PDF_RE = re.compile(48 r'href="(https://groupelobato\.com/wp-content/uploads/[^"]+\.pdf)"', re.I)49TAG_RE = re.compile(r"<[^>]+>")50PHAST_RE = re.compile(r"phast\.php/([A-Za-z0-9_=-]+)\.q\.(?:jpe?g|png|webp)",51 re.I)52SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+(\.(?:jpe?g|png|webp))$", re.I)535455def _clean(txt: str) -> str:56 return re.sub(r"\s+", " ", _html.unescape(TAG_RE.sub(" ", txt))).strip()575859def _slug(txt: str) -> str:60 return re.sub(r"[^a-z0-9]+", "-", txt.lower()).strip("-")616263def _images(html: str) -> list[str]:64 """Décode les URLs PhastPress -> originaux wp-content/uploads (photos)."""65 urls: list[str] = []66 for tok in dict.fromkeys(PHAST_RE.findall(html)):67 try:68 raw = base64.urlsafe_b64decode(69 tok + "=" * (-len(tok) % 4)).decode("utf-8", "replace")70 except Exception:71 continue72 m = re.search(r"src=([^&]+)", raw)73 if not m:74 continue75 u = unquote(m.group(1))76 if "/wp-content/uploads/" not in u:77 continue # images de thème/icônes78 if re.search(r"logo|icon|favicon", u, re.I):79 continue80 u = SIZE_SUFFIX_RE.sub(r"\1", u) # variante -WxH -> original81 if u not in urls:82 urls.append(u)83 return urls[:10]848586class LobatoConnector(BaseConnector):87 source_id = "lobato"88 request_delay = 0.88990 def fetch(self) -> list[Listing]:91 listings: list[Listing] = []92 seen: set[str] = set()93 for slug, name, address, city in PROJECTS:94 url = f"{BASE}/project/{slug}/"95 try:96 html = self.get(url).text97 except Exception:98 continue99 images = _images(html)100 for row in ROW_RE.finditer(html):101 chunk = row.group(0)102 cells = CELL_RE.findall(chunk)103 if len(cells) < 5:104 continue105 unit = _clean(cells[0])106 beds_txt = _clean(cells[1])107 sf_txt = _clean(cells[2])108 price_txt = _clean(cells[3])109 avail_txt = _clean(cells[4])110 if not unit or "commercial" in unit.lower():111 continue # local commercial exclu112 if not re.search(r"\bAvailable\b", avail_txt, re.I):113 continue # unité louée (Leased)114115 lm = UNIT_LINK_RE.search(cells[0])116 ext_id = f"{slug}-{lm.group(1) if lm else _slug(unit)}"117 if ext_id in seen:118 continue119 seen.add(ext_id)120121 price = None122 pm = re.search(r"(\d[\d\s ]{2,8})\$", price_txt)123 if pm:124 price = float(re.sub(r"[\s ]", "", pm.group(1)))125 area = None126 am = re.search(r"(\d{3,5})", sf_txt.replace(" ", ""))127 if am:128 area = float(am.group(1))129130 bedrooms = None131 unit_type = ""132 bm = re.match(r"^(\d+)(?:\s*\+\s*den)?$", beds_txt, re.I)133 if bm:134 bedrooms = float(bm.group(1))135 if "den" in beds_txt.lower():136 unit_type = f"{bm.group(1)} ch. + den"137 elif re.match(r"^\d+\s*1/2$", beds_txt):138 unit_type = beds_txt # « 4 1/2 » -> finalize139140 pdf = PDF_RE.search(chunk)141142 listings.append(Listing(143 source=self.source_id,144 external_id=ext_id,145 url=url,146 title=f"{name} — unité {unit}",147 address=address,148 city=city,149 unit_type=unit_type,150 bedrooms=bedrooms,151 price=price,152 price_label=price_txt if price else "",153 availability="Disponible",154 area_sqft=area,155 description=f"Unité {unit} au {name} ({address}) — "156 "immeuble locatif du Groupe Lobato, "157 "promoteur de la vallée du Richelieu.",158 details={"plan_pdf": pdf.group(1)} if pdf else {},159 images=images,160 ))161 return listings162