# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ascensio.py : connecteur Groupe Ascensio (groupeascensio.com) # Société immobilière — logements neufs à Sherbrooke (Les Nations : # arrondissements Jacques-Cartier et Mont-Bellevue) + Pont-Rouge (région de # Québec). WordPress : la page /logements-a-louer/ est rendue serveur — # grille `.grid-logements .grid-item` (une carte par logement disponible : # type + numéro, date de disponibilité, secteur, immeuble, photo, lien # /location//). Les fiches (via self.detail, cache BD) ajoutent le # numéro de référence stable (external_id, ex. BRY-2030134), la mensualité, # la description, les pièces et dimensions et la galerie. Seuls les # logements affichés (tous « Disponible ») deviennent des annonces. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://groupeascensio.com" LIST_URL = f"{BASE}/logements-a-louer/" _VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) class AscensioConnector(BaseConnector): source_id = "ascensio" request_delay = 0.6 max_details = 40 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for card in soup.select(".grid-logements .grid-item"): try: lst = self._parse_card(card) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # fiches détail (cache BD) : référence, mensualité, description, # pièces/dimensions, galerie self._fetched = 0 out: dict[str, Listing] = {} for slug, lst in listings.items(): key = hashlib.sha1( f"{lst.title}|{lst.availability}|{lst.sector}" .encode("utf-8")).hexdigest() try: payload = self.detail(slug, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: payload = {} self._apply_detail(lst, payload) # référence de gestion stable (BRY-2030134) quand publiée ref = (payload.get("reference") or "").strip() lst.external_id = ref or slug if lst.external_id not in out: out[lst.external_id] = lst return list(out.values()) # -- carte de la grille ----------------------------------------------------------- def _parse_card(self, card) -> Listing | None: link = card.select_one("a[href*='/location/']") if not link: return None url = link["href"] m = re.search(r"/location/([^/]+)/?", url) if not m: return None slug = m.group(1) title_el = card.select_one(".logement-title") title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)).strip() if title_el else slug # « Disponibilité : 01/08/2026 » (bandeau survol) availability = "" extra = card.select_one(".extra-infos") if extra: m2 = re.search(r"Disponibilit[eé]\s*:?\s*([\d/]+)", extra.get_text(" ", strip=True)) if m2: availability = f"Disponible le {m2.group(1)}" sector_el = card.select_one(".secteur-title") sector = "" if sector_el: sector = re.sub(r"^\s*Secteur\s*:?\s*", "", sector_el.get_text(" ", strip=True)).strip(" .") imm_el = card.select_one(".immeuble-value") immeuble = re.sub(r"\s+", " ", imm_el.get_text(" ", strip=True)).strip() if imm_el else "" # ville réelle : Sherbrooke, sauf mention explicite de Pont-Rouge city = "Pont-Rouge" if re.search(r"pont-rouge", sector, re.I) else "Sherbrooke" unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = "" img = card.select_one("img[src]") images = [] if img and str(img.get("src", "")).startswith("http"): images.append(_VARIANT_IMG.sub("", img["src"])) amenities = [f"Immeuble : {immeuble}"] if immeuble else [] return Listing( source=self.source_id, external_id=slug, # remplacé par la référence en aval url=url, title=f"{title} — {immeuble}" if immeuble else title, address=immeuble if re.match(r"^\d", immeuble) else "", sector=sector, city=city, unit_type=unit_type, availability=availability, amenities=amenities, images=images, ) # -- fiche logement --------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # paires h5 -> valeur : Référence, Mensualité, Disponibilités for h in soup.select("h5"): lab = h.get_text(" ", strip=True).lower() sib = h.find_next_sibling() if sib is None: continue val = re.sub(r"\s+", " ", sib.get_text(" ", strip=True)).strip() if "référence" in lab or "reference" in lab: out["reference"] = val elif "mensualité" in lab or "mensualite" in lab: out["price_label"] = val elif "disponibilités" in lab or "disponibilites" in lab: out["availability"] = val elif "pièces et dimensions" in lab and sib.name == "ul": out["rooms"] = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in sib.select("li")][:15] # description : paragraphes longs du corps de la fiche paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) for p in soup.select("p")] longs = [p for p in paras if len(p) > 120] if longs: out["description"] = " ".join(longs)[:1500] images: list[str] = [] for img in soup.select("img[src*='/wp-content/uploads/']"): src = _VARIANT_IMG.sub("", str(img.get("src") or "")) if src.startswith("http") and src not in images \ and not re.search(r"logo|icon|favicon", src, re.I): images.append(src) out["images"] = images[:20] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("price_label"): lst.price_label = d["price_label"] # « 1425 $ / mois » lst.price = parse_price(re.sub(r"(\d)\s(\d{3})", r"\1\2", d["price_label"])) if d.get("availability"): lst.availability = d["availability"] if d.get("description"): lst.description = d["description"] if d.get("rooms"): lst.amenities = list(dict.fromkeys(lst.amenities + d["rooms"])) if d.get("images"): lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]