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/junic.py : connecteur Junic (junic.ca — promoteur de Gatineau :5# projets Agora Village Urbain, VILL, Central…)6# Seul l'AGORA publie un inventaire exploitable : la page7# residentiel.agora-plateau.com/appartements/ (WordPress/Jupiter) est rendue8# SERVEUR avec une carte par unité disponible (div.unit) regroupée par9# typologie (h2 : Micro, Loft, 1 chambre, 1 chambre + bureau…) :10# numéro « 20 - #320 » (immeuble 20 ou 35, rue de Hambourg, secteur11# Le Plateau à Gatineau), disponibilité (date ou « MAINTENANT DISPONIBLE »),12# type, dimension pi², LOYER de base, forfait tout-inclus obligatoire13# (505 $/mois : meubles en option non — chauffage/clim, internet…),14# mensualité totale, visite virtuelle et plan de l'unité.15# Le prix publié est le LOYER de base ; le forfait et la mensualité totale16# sont reportés en description/détails. VILL (villappartements.com) n'a17# qu'un portail résident Building Stack avec login — aucune annonce18# publique, donc hors connecteur.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import re2324from bs4 import BeautifulSoup2526from ..schema import Listing27from .base import BaseConnector2829BASE = "https://residentiel.agora-plateau.com"30LIST_URL = f"{BASE}/appartements/"3132# immeubles du projet (préfixe des numéros d'unité -> adresse civique)33_ADDRESSES = {34 "20": "20 Rue de Hambourg, Gatineau",35 "35": "35 Rue de Hambourg, Gatineau",36}3738# typologies des entêtes h2 -> (type d'unité Lou-Ka, chambres)39_TYPES = {40 "micro": ("Studio", 0),41 "loft": ("Loft", 0),42 "1 chambre": ("3½", 1),43 "2 chambres": ("4½", 2),44 "3 chambres": ("5½", 3),45}4647_WS_RE = re.compile(r"\s+")48_UNIT_RE = re.compile(r"^(\d+)\s*-\s*#?\s*(\w+)")49_DISPO_RE = re.compile(r"Disponibilité\s*([\d-]+)", re.I)50_DIM_RE = re.compile(r"Dimension\s*([\d\s,.]+)\s*pi", re.I)51_LOYER_RE = re.compile(r"Loyer\s*([\d\s]+)\s*\$", re.I)52_INCLUS_RE = re.compile(r"Tout-inclus\s*([\d\s]+)\s*\$", re.I)53_MENSUEL_RE = re.compile(r"Mensualité\s*([\d\s]+)\s*\$", re.I)54_TYPE_RE = re.compile(r"Type\s+([A-Z0-9+ ]+?)(?:\s+Dimension|\s+Loyer|$)")555657def _money(m: re.Match | None) -> float | None:58 if not m:59 return None60 try:61 v = float(m.group(1).replace(" ", "").replace(" ", ""))62 except ValueError:63 return None64 return v if 100 <= v <= 20000 else None656667def _typologie(header: str) -> tuple[str, int]:68 """Entête de section -> (type d'unité, chambres). Les variantes69 « + bureau » / « + mezzanine » gardent la même typologie de base."""70 key = _WS_RE.sub(" ", header).strip().lower()71 for prefix, val in _TYPES.items():72 if key.startswith(prefix):73 return val74 return ("", 0)757677class JunicConnector(BaseConnector):78 source_id = "junic"79 request_delay = 0.680 max_images = 68182 def fetch(self) -> list[Listing]:83 html = self.get(LIST_URL).text84 soup = BeautifulSoup(html, "html.parser")8586 listings: list[Listing] = []87 header = ""88 # le DOM alterne entêtes de typologie et cartes d'unité, dans l'ordre89 for el in soup.select(".appartements_type h2, div.unit"):90 if el.name == "h2":91 header = el.get_text(" ", strip=True)92 continue93 try:94 lst = self._unit(el, header)95 if lst:96 listings.append(lst)97 except Exception:98 continue # une carte ne bloque pas le reste99100 # dédup par external_id (sécurité)101 uniq: dict[str, Listing] = {}102 for lst in listings:103 uniq.setdefault(lst.external_id, lst)104 return list(uniq.values())105106 # -- une annonce par carte d'unité -------------------------------------------107 def _unit(self, el, header: str) -> Listing | None:108 h3 = el.find("h3")109 if not h3:110 return None111 m = _UNIT_RE.match(_WS_RE.sub(" ", h3.get_text(" ", strip=True)))112 if not m:113 return None114 bldg, num = m.group(1), m.group(2)115 txt = _WS_RE.sub(" ", el.get_text(" ", strip=True))116117 unit_type, beds = _typologie(header)118 loyer = _money(_LOYER_RE.search(txt))119 inclus = _money(_INCLUS_RE.search(txt))120 mensuel = _money(_MENSUEL_RE.search(txt))121122 dispo = _DISPO_RE.search(txt)123 if dispo:124 availability = f"Libre le {dispo.group(1)}"125 elif re.search(r"MAINTENANT DISPONIBLE", txt, re.I):126 availability = "Disponible maintenant"127 else:128 availability = "Disponible"129130 area = None131 dm = _DIM_RE.search(txt)132 if dm:133 try:134 v = float(dm.group(1).replace(" ", "").replace(" ", "")135 .replace(",", "."))136 area = v if 80 <= v <= 20000 else None137 except ValueError:138 pass139140 # visite virtuelle + images (plan de l'unité, aperçu de la visite)141 details: dict = {"building": f"{bldg} Rue de Hambourg",142 "bedrooms": beds}143 images: list[str] = []144 for a in el.find_all("a", href=True):145 href = str(a["href"])146 if "tourbuzz" in href or "matterport" in href:147 details["virtual_tour"] = href148 elif href.lower().endswith(".pdf"):149 details["floor_plan"] = href # plan de l'unité (PDF)150 elif re.search(r"\.(?:jpe?g|png|webp)(?:\?|$)", href, re.I):151 if href not in images:152 images.append(href)153 for img in el.find_all("img", src=True):154 src = str(img["src"])155 if src.startswith("http") and src not in images:156 images.append(src)157 if inclus:158 details["all_inclusive_package"] = inclus159 if mensuel:160 details["total_monthly"] = mensuel161162 tm = _TYPE_RE.search(txt)163 desc_bits = [164 f"Type {tm.group(1).strip()}" if tm else header,165 f"{area:g} pi²" if area else "",166 (f"Loyer de base {loyer:g} $ + forfait tout-inclus "167 f"{inclus:g} $/mois" if loyer and inclus else ""),168 f"mensualité totale {mensuel:g} $" if mensuel else "",169 ]170 desc = " — ".join(x for x in desc_bits if x)171172 return Listing(173 source=self.source_id,174 external_id=f"{bldg}-{num}",175 url=LIST_URL,176 title=f"Agora Village Urbain ({bldg} Hambourg) — Unité {num}",177 address=_ADDRESSES.get(bldg, "Rue de Hambourg, Gatineau"),178 sector="Le Plateau",179 city="Gatineau",180 unit_type=unit_type,181 bedrooms=float(beds),182 price=loyer,183 price_label=f"{loyer:g} $ + {inclus:g} $ tout-inclus"184 if loyer and inclus else "",185 availability=availability,186 area_sqft=area,187 description=desc[:600],188 details=details,189 images=images[: self.max_images],190 )191