# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/alouer_sherbrooke.py : connecteur À Louer Sherbrooke # (alouersherbrooke.com) — 100+ logements dans 5 immeubles regroupés du # quartier Université (Sherbrooke) ; clientèle étudiants/professionnels/ # retraités. WordPress (thème idconception) : la liste est servie par # admin-ajax `pagination-load-posts` (CPT appartements, 8 cartes/page, # pagination dans la réponse). Cartes : classe `dispo` (À louer, avec date # « À partir du … ») ou `loue` (Loué — exclues), grandeur (3½…), quartier, # inclusions et photo. Les fiches (via self.detail, cache BD) ajoutent la # description complète et la galerie. AUCUN prix ni adresse civique publiés # par la source (immeubles regroupés, quartier Université) — champs laissés # vides, rien d'inventé. external_id = slug du billet. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.alouersherbrooke.com" AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" _VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) class AlouerSherbrookeConnector(BaseConnector): source_id = "alouer_sherbrooke" request_delay = 0.6 max_pages = 12 # garde-fou (7 pages observées) max_details = 30 # garde-fou fiches détail (vraies requêtes) def _ajax_page(self, page: int) -> BeautifulSoup: resp = self.session.post( AJAX_URL, data={"page": str(page), "action": "pagination-load-posts"}, timeout=self.timeout) resp.raise_for_status() return BeautifulSoup(resp.text, "html.parser") def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): try: soup = self._ajax_page(page) except Exception: break cards = soup.select(".appartements__bloc") if not cards: break for card in cards: try: lst = self._parse_card(card) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # borne réelle de pagination (liens numériques de la réponse) nums = [int(a.get_text(strip=True)) for a in soup.select(".cvf-universal-pagination li a") if a.get_text(strip=True).isdigit()] if nums and page >= max(nums): break # fiches détail (cache BD) : description + galerie self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.availability}|{lst.unit_type}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue if payload.get("description"): lst.description = payload["description"] if payload.get("images"): lst.images = list(dict.fromkeys(payload["images"] + lst.images))[:20] return list(listings.values()) # -- carte AJAX ------------------------------------------------------------- def _parse_card(self, card) -> Listing | None: classes = card.get("class") or [] if "dispo" not in classes: return None # « loue » = logement loué, exclu link = card.select_one("a[href*='/appartements/']") if not link: return None url = link["href"].split("?")[0] m = re.search(r"/appartements/([^/]+)/?$", url.rstrip("/") + "/") m = re.search(r"/appartements/([^/]+)", url) if not m: return None slug = m.group(1).strip("/") title = (link.get("title") or "").strip() if not title: h2 = card.select_one("h2") title = h2.get_text(" ", strip=True) if h2 else slug.replace("-", " ") # « À louer » + « À partir du 1 septembre 2026 » tag = card.select_one(".appartements__dispoTag") date = card.select_one(".appartements__date") availability = " ".join(x for x in ( tag.get_text(" ", strip=True) if tag else "", date.get_text(" ", strip=True) if date else "") if x).strip() gr = card.select_one(".appartement__grandeur") unit_type = normalize_unit_type(gr.get_text(strip=True) if gr else "") if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = "" qu = card.select_one(".appartement__quartier") sector = re.sub(r"\s+", " ", qu.get_text(" ", strip=True)).strip() if qu else "" amenities = [] for li in card.select("li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) img = card.select_one("img[src]") images = [] if img and str(img.get("src", "")).startswith("http"): images.append(_VARIANT_IMG.sub("", img["src"])) return Listing( source=self.source_id, external_id=slug, url=url, title=title, address="", # adresse civique non publiée par la source sector=sector, # « Université » city="Sherbrooke", unit_type=unit_type, availability=availability, amenities=amenities, images=images, ) # -- fiche appartement -------------------------------------------------------- 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 = {} # contenu du billet : listes descriptives (appartement, immeuble, à # proximité) — tout en texte brut, le textmine central s'en charge main = soup.select_one("article, .single__content, main") or soup parts: list[str] = [] for el in main.find_all(["p", "li", "h2", "h3"]): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if t and t not in parts: parts.append(t) if sum(len(x) for x in parts) > 1500: break if parts: out["description"] = " | ".join(parts)[:1500] images = [] 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