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/ascensio.py : connecteur Groupe Ascensio (groupeascensio.com)5# Société immobilière — logements neufs à Sherbrooke (Les Nations :6# arrondissements Jacques-Cartier et Mont-Bellevue) + Pont-Rouge (région de7# Québec). WordPress : la page /logements-a-louer/ est rendue serveur —8# grille `.grid-logements .grid-item` (une carte par logement disponible :9# type + numéro, date de disponibilité, secteur, immeuble, photo, lien10# /location/<slug>/). Les fiches (via self.detail, cache BD) ajoutent le11# numéro de référence stable (external_id, ex. BRY-2030134), la mensualité,12# la description, les pièces et dimensions et la galerie. Seuls les13# logements affichés (tous « Disponible ») deviennent des annonces.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://groupeascensio.com"26LIST_URL = f"{BASE}/logements-a-louer/"2728_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)293031class AscensioConnector(BaseConnector):32 source_id = "ascensio"33 request_delay = 0.634 max_details = 40 # garde-fou fiches détail (vraies requêtes)3536 def fetch(self) -> list[Listing]:37 html = self.get(LIST_URL).text38 soup = BeautifulSoup(html, "html.parser")3940 listings: dict[str, Listing] = {}41 for card in soup.select(".grid-logements .grid-item"):42 try:43 lst = self._parse_card(card)44 except Exception:45 continue46 if lst and lst.external_id not in listings:47 listings[lst.external_id] = lst4849 # fiches détail (cache BD) : référence, mensualité, description,50 # pièces/dimensions, galerie51 self._fetched = 052 out: dict[str, Listing] = {}53 for slug, lst in listings.items():54 key = hashlib.sha1(55 f"{lst.title}|{lst.availability}|{lst.sector}"56 .encode("utf-8")).hexdigest()57 try:58 payload = self.detail(slug, key,59 lambda u=lst.url: self._fetch_detail(u))60 except Exception:61 payload = {}62 self._apply_detail(lst, payload)63 # référence de gestion stable (BRY-2030134) quand publiée64 ref = (payload.get("reference") or "").strip()65 lst.external_id = ref or slug66 if lst.external_id not in out:67 out[lst.external_id] = lst68 return list(out.values())6970 # -- carte de la grille -----------------------------------------------------------71 def _parse_card(self, card) -> Listing | None:72 link = card.select_one("a[href*='/location/']")73 if not link:74 return None75 url = link["href"]76 m = re.search(r"/location/([^/]+)/?", url)77 if not m:78 return None79 slug = m.group(1)8081 title_el = card.select_one(".logement-title")82 title = re.sub(r"\s+", " ",83 title_el.get_text(" ", strip=True)).strip() if title_el else slug8485 # « Disponibilité : 01/08/2026 » (bandeau survol)86 availability = ""87 extra = card.select_one(".extra-infos")88 if extra:89 m2 = re.search(r"Disponibilit[eé]\s*:?\s*([\d/]+)",90 extra.get_text(" ", strip=True))91 if m2:92 availability = f"Disponible le {m2.group(1)}"9394 sector_el = card.select_one(".secteur-title")95 sector = ""96 if sector_el:97 sector = re.sub(r"^\s*Secteur\s*:?\s*", "",98 sector_el.get_text(" ", strip=True)).strip(" .")99100 imm_el = card.select_one(".immeuble-value")101 immeuble = re.sub(r"\s+", " ",102 imm_el.get_text(" ", strip=True)).strip() if imm_el else ""103104 # ville réelle : Sherbrooke, sauf mention explicite de Pont-Rouge105 city = "Pont-Rouge" if re.search(r"pont-rouge", sector, re.I) else "Sherbrooke"106107 unit_type = normalize_unit_type(title)108 if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison",109 unit_type or ""):110 unit_type = ""111112 img = card.select_one("img[src]")113 images = []114 if img and str(img.get("src", "")).startswith("http"):115 images.append(_VARIANT_IMG.sub("", img["src"]))116117 amenities = [f"Immeuble : {immeuble}"] if immeuble else []118 return Listing(119 source=self.source_id,120 external_id=slug, # remplacé par la référence en aval121 url=url,122 title=f"{title} — {immeuble}" if immeuble else title,123 address=immeuble if re.match(r"^\d", immeuble) else "",124 sector=sector,125 city=city,126 unit_type=unit_type,127 availability=availability,128 amenities=amenities,129 images=images,130 )131132 # -- fiche logement ---------------------------------------------------------------133 def _fetch_detail(self, url: str) -> dict:134 if self._fetched >= self.max_details:135 raise RuntimeError("budget de fiches détail atteint")136 self._fetched += 1137 html = self.get(url).text138 soup = BeautifulSoup(html, "html.parser")139 out: dict = {}140141 # paires h5 -> valeur : Référence, Mensualité, Disponibilités142 for h in soup.select("h5"):143 lab = h.get_text(" ", strip=True).lower()144 sib = h.find_next_sibling()145 if sib is None:146 continue147 val = re.sub(r"\s+", " ", sib.get_text(" ", strip=True)).strip()148 if "référence" in lab or "reference" in lab:149 out["reference"] = val150 elif "mensualité" in lab or "mensualite" in lab:151 out["price_label"] = val152 elif "disponibilités" in lab or "disponibilites" in lab:153 out["availability"] = val154 elif "pièces et dimensions" in lab and sib.name == "ul":155 out["rooms"] = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))156 for li in sib.select("li")][:15]157158 # description : paragraphes longs du corps de la fiche159 paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))160 for p in soup.select("p")]161 longs = [p for p in paras if len(p) > 120]162 if longs:163 out["description"] = " ".join(longs)[:1500]164165 images: list[str] = []166 for img in soup.select("img[src*='/wp-content/uploads/']"):167 src = _VARIANT_IMG.sub("", str(img.get("src") or ""))168 if src.startswith("http") and src not in images \169 and not re.search(r"logo|icon|favicon", src, re.I):170 images.append(src)171 out["images"] = images[:20]172 return out173174 def _apply_detail(self, lst: Listing, d: dict) -> None:175 if not d:176 return177 if d.get("price_label"):178 lst.price_label = d["price_label"] # « 1425 $ / mois »179 lst.price = parse_price(re.sub(r"(\d)\s(\d{3})", r"\1\2",180 d["price_label"]))181 if d.get("availability"):182 lst.availability = d["availability"]183 if d.get("description"):184 lst.description = d["description"]185 if d.get("rooms"):186 lst.amenities = list(dict.fromkeys(lst.amenities + d["rooms"]))187 if d.get("images"):188 lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]189