# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/junic.py : connecteur Junic (junic.ca — promoteur de Gatineau : # projets Agora Village Urbain, VILL, Central…) # Seul l'AGORA publie un inventaire exploitable : la page # residentiel.agora-plateau.com/appartements/ (WordPress/Jupiter) est rendue # SERVEUR avec une carte par unité disponible (div.unit) regroupée par # typologie (h2 : Micro, Loft, 1 chambre, 1 chambre + bureau…) : # numéro « 20 - #320 » (immeuble 20 ou 35, rue de Hambourg, secteur # Le Plateau à Gatineau), disponibilité (date ou « MAINTENANT DISPONIBLE »), # type, dimension pi², LOYER de base, forfait tout-inclus obligatoire # (505 $/mois : meubles en option non — chauffage/clim, internet…), # mensualité totale, visite virtuelle et plan de l'unité. # Le prix publié est le LOYER de base ; le forfait et la mensualité totale # sont reportés en description/détails. VILL (villappartements.com) n'a # qu'un portail résident Building Stack avec login — aucune annonce # publique, donc hors connecteur. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://residentiel.agora-plateau.com" LIST_URL = f"{BASE}/appartements/" # immeubles du projet (préfixe des numéros d'unité -> adresse civique) _ADDRESSES = { "20": "20 Rue de Hambourg, Gatineau", "35": "35 Rue de Hambourg, Gatineau", } # typologies des entêtes h2 -> (type d'unité Lou-Ka, chambres) _TYPES = { "micro": ("Studio", 0), "loft": ("Loft", 0), "1 chambre": ("3½", 1), "2 chambres": ("4½", 2), "3 chambres": ("5½", 3), } _WS_RE = re.compile(r"\s+") _UNIT_RE = re.compile(r"^(\d+)\s*-\s*#?\s*(\w+)") _DISPO_RE = re.compile(r"Disponibilité\s*([\d-]+)", re.I) _DIM_RE = re.compile(r"Dimension\s*([\d\s,.]+)\s*pi", re.I) _LOYER_RE = re.compile(r"Loyer\s*([\d\s]+)\s*\$", re.I) _INCLUS_RE = re.compile(r"Tout-inclus\s*([\d\s]+)\s*\$", re.I) _MENSUEL_RE = re.compile(r"Mensualité\s*([\d\s]+)\s*\$", re.I) _TYPE_RE = re.compile(r"Type\s+([A-Z0-9+ ]+?)(?:\s+Dimension|\s+Loyer|$)") def _money(m: re.Match | None) -> float | None: if not m: return None try: v = float(m.group(1).replace(" ", "").replace(" ", "")) except ValueError: return None return v if 100 <= v <= 20000 else None def _typologie(header: str) -> tuple[str, int]: """Entête de section -> (type d'unité, chambres). Les variantes « + bureau » / « + mezzanine » gardent la même typologie de base.""" key = _WS_RE.sub(" ", header).strip().lower() for prefix, val in _TYPES.items(): if key.startswith(prefix): return val return ("", 0) class JunicConnector(BaseConnector): source_id = "junic" request_delay = 0.6 max_images = 6 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: list[Listing] = [] header = "" # le DOM alterne entêtes de typologie et cartes d'unité, dans l'ordre for el in soup.select(".appartements_type h2, div.unit"): if el.name == "h2": header = el.get_text(" ", strip=True) continue try: lst = self._unit(el, header) if lst: listings.append(lst) except Exception: continue # une carte ne bloque pas le reste # dédup par external_id (sécurité) uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values()) # -- une annonce par carte d'unité ------------------------------------------- def _unit(self, el, header: str) -> Listing | None: h3 = el.find("h3") if not h3: return None m = _UNIT_RE.match(_WS_RE.sub(" ", h3.get_text(" ", strip=True))) if not m: return None bldg, num = m.group(1), m.group(2) txt = _WS_RE.sub(" ", el.get_text(" ", strip=True)) unit_type, beds = _typologie(header) loyer = _money(_LOYER_RE.search(txt)) inclus = _money(_INCLUS_RE.search(txt)) mensuel = _money(_MENSUEL_RE.search(txt)) dispo = _DISPO_RE.search(txt) if dispo: availability = f"Libre le {dispo.group(1)}" elif re.search(r"MAINTENANT DISPONIBLE", txt, re.I): availability = "Disponible maintenant" else: availability = "Disponible" area = None dm = _DIM_RE.search(txt) if dm: try: v = float(dm.group(1).replace(" ", "").replace(" ", "") .replace(",", ".")) area = v if 80 <= v <= 20000 else None except ValueError: pass # visite virtuelle + images (plan de l'unité, aperçu de la visite) details: dict = {"building": f"{bldg} Rue de Hambourg", "bedrooms": beds} images: list[str] = [] for a in el.find_all("a", href=True): href = str(a["href"]) if "tourbuzz" in href or "matterport" in href: details["virtual_tour"] = href elif href.lower().endswith(".pdf"): details["floor_plan"] = href # plan de l'unité (PDF) elif re.search(r"\.(?:jpe?g|png|webp)(?:\?|$)", href, re.I): if href not in images: images.append(href) for img in el.find_all("img", src=True): src = str(img["src"]) if src.startswith("http") and src not in images: images.append(src) if inclus: details["all_inclusive_package"] = inclus if mensuel: details["total_monthly"] = mensuel tm = _TYPE_RE.search(txt) desc_bits = [ f"Type {tm.group(1).strip()}" if tm else header, f"{area:g} pi²" if area else "", (f"Loyer de base {loyer:g} $ + forfait tout-inclus " f"{inclus:g} $/mois" if loyer and inclus else ""), f"mensualité totale {mensuel:g} $" if mensuel else "", ] desc = " — ".join(x for x in desc_bits if x) return Listing( source=self.source_id, external_id=f"{bldg}-{num}", url=LIST_URL, title=f"Agora Village Urbain ({bldg} Hambourg) — Unité {num}", address=_ADDRESSES.get(bldg, "Rue de Hambourg, Gatineau"), sector="Le Plateau", city="Gatineau", unit_type=unit_type, bedrooms=float(beds), price=loyer, price_label=f"{loyer:g} $ + {inclus:g} $ tout-inclus" if loyer and inclus else "", availability=availability, area_sqft=area, description=desc[:600], details=details, images=images[: self.max_images], )