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/alouer_sherbrooke.py : connecteur À Louer Sherbrooke5# (alouersherbrooke.com) — 100+ logements dans 5 immeubles regroupés du6# quartier Université (Sherbrooke) ; clientèle étudiants/professionnels/7# retraités. WordPress (thème idconception) : la liste est servie par8# admin-ajax `pagination-load-posts` (CPT appartements, 8 cartes/page,9# pagination dans la réponse). Cartes : classe `dispo` (À louer, avec date10# « À partir du … ») ou `loue` (Loué — exclues), grandeur (3½…), quartier,11# inclusions et photo. Les fiches (via self.detail, cache BD) ajoutent la12# description complète et la galerie. AUCUN prix ni adresse civique publiés13# par la source (immeubles regroupés, quartier Université) — champs laissés14# vides, rien d'inventé. external_id = slug du billet.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type24from .base import BaseConnector2526BASE = "https://www.alouersherbrooke.com"27AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"2829_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)303132class AlouerSherbrookeConnector(BaseConnector):33 source_id = "alouer_sherbrooke"34 request_delay = 0.635 max_pages = 12 # garde-fou (7 pages observées)36 max_details = 30 # garde-fou fiches détail (vraies requêtes)3738 def _ajax_page(self, page: int) -> BeautifulSoup:39 resp = self.session.post(40 AJAX_URL,41 data={"page": str(page), "action": "pagination-load-posts"},42 timeout=self.timeout)43 resp.raise_for_status()44 return BeautifulSoup(resp.text, "html.parser")4546 def fetch(self) -> list[Listing]:47 listings: dict[str, Listing] = {}48 for page in range(1, self.max_pages + 1):49 try:50 soup = self._ajax_page(page)51 except Exception:52 break53 cards = soup.select(".appartements__bloc")54 if not cards:55 break56 for card in cards:57 try:58 lst = self._parse_card(card)59 except Exception:60 continue61 if lst and lst.external_id not in listings:62 listings[lst.external_id] = lst63 # borne réelle de pagination (liens numériques de la réponse)64 nums = [int(a.get_text(strip=True))65 for a in soup.select(".cvf-universal-pagination li a")66 if a.get_text(strip=True).isdigit()]67 if nums and page >= max(nums):68 break6970 # fiches détail (cache BD) : description + galerie71 self._fetched = 072 for lst in listings.values():73 key = hashlib.sha1(74 f"{lst.title}|{lst.availability}|{lst.unit_type}"75 .encode("utf-8")).hexdigest()76 try:77 payload = self.detail(lst.external_id, key,78 lambda u=lst.url: self._fetch_detail(u))79 except Exception:80 continue81 if payload.get("description"):82 lst.description = payload["description"]83 if payload.get("images"):84 lst.images = list(dict.fromkeys(payload["images"] + lst.images))[:20]85 return list(listings.values())8687 # -- carte AJAX -------------------------------------------------------------88 def _parse_card(self, card) -> Listing | None:89 classes = card.get("class") or []90 if "dispo" not in classes:91 return None # « loue » = logement loué, exclu92 link = card.select_one("a[href*='/appartements/']")93 if not link:94 return None95 url = link["href"].split("?")[0]96 m = re.search(r"/appartements/([^/]+)/?$", url.rstrip("/") + "/")97 m = re.search(r"/appartements/([^/]+)", url)98 if not m:99 return None100 slug = m.group(1).strip("/")101102 title = (link.get("title") or "").strip()103 if not title:104 h2 = card.select_one("h2")105 title = h2.get_text(" ", strip=True) if h2 else slug.replace("-", " ")106107 # « À louer » + « À partir du 1 septembre 2026 »108 tag = card.select_one(".appartements__dispoTag")109 date = card.select_one(".appartements__date")110 availability = " ".join(x for x in (111 tag.get_text(" ", strip=True) if tag else "",112 date.get_text(" ", strip=True) if date else "") if x).strip()113114 gr = card.select_one(".appartement__grandeur")115 unit_type = normalize_unit_type(gr.get_text(strip=True) if gr else "")116 if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison",117 unit_type or ""):118 unit_type = ""119120 qu = card.select_one(".appartement__quartier")121 sector = re.sub(r"\s+", " ",122 qu.get_text(" ", strip=True)).strip() if qu else ""123124 amenities = []125 for li in card.select("li"):126 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))127 if t and t not in amenities:128 amenities.append(t)129130 img = card.select_one("img[src]")131 images = []132 if img and str(img.get("src", "")).startswith("http"):133 images.append(_VARIANT_IMG.sub("", img["src"]))134135 return Listing(136 source=self.source_id,137 external_id=slug,138 url=url,139 title=title,140 address="", # adresse civique non publiée par la source141 sector=sector, # « Université »142 city="Sherbrooke",143 unit_type=unit_type,144 availability=availability,145 amenities=amenities,146 images=images,147 )148149 # -- fiche appartement --------------------------------------------------------150 def _fetch_detail(self, url: str) -> dict:151 if self._fetched >= self.max_details:152 raise RuntimeError("budget de fiches détail atteint")153 self._fetched += 1154 html = self.get(url).text155 soup = BeautifulSoup(html, "html.parser")156 out: dict = {}157158 # contenu du billet : listes descriptives (appartement, immeuble, à159 # proximité) — tout en texte brut, le textmine central s'en charge160 main = soup.select_one("article, .single__content, main") or soup161 parts: list[str] = []162 for el in main.find_all(["p", "li", "h2", "h3"]):163 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()164 if t and t not in parts:165 parts.append(t)166 if sum(len(x) for x in parts) > 1500:167 break168 if parts:169 out["description"] = " | ".join(parts)[:1500]170171 images = []172 for img in soup.select("img[src*='/wp-content/uploads/']"):173 src = _VARIANT_IMG.sub("", str(img.get("src") or ""))174 if src.startswith("http") and src not in images \175 and not re.search(r"logo|icon|favicon", src, re.I):176 images.append(src)177 out["images"] = images[:20]178 return out179