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/laureat_richard.py : connecteur Logements Lauréat Richard5# (laureatrichard.com — Sherbrooke, quartier Nord, 700+ appartements gérés).6# WordPress + thème Avada (fusion) + plugin WP-Property : page7# /nos-immeubles-a-logements/ -> 8 pages immeuble (Le Plateau Richard,8# Complexe Lunik, Carré McGregor…), chacune listant ses modèles de logements9# offerts (`.property_div` : titre + lien + plan). Fiches /proprietes/<slug>10# via self.detail() (cache BD) : bloc structuré `#wpp_property_stats`11# (Grandeur, Inclus dans le logement, Adresse), description et plans.12# LE SITE NE PUBLIE NI PRIX NI DATE DE DISPONIBILITÉ : ces champs restent13# vides (jamais de valeur inventée) — contact par téléphone/visite.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, strip_accents23from .base import BaseConnector2425BASE = "https://laureatrichard.com"26LIST_URL = f"{BASE}/nos-immeubles-a-logements/"2728_PROP_RE = re.compile(r"/proprietes/([\w\-%]+)/?$")293031class LaureatRichardConnector(BaseConnector):32 source_id = "laureat_richard"33 request_delay = 0.634 max_details = 40 # garde-fou : vraies requêtes de fiches propriété3536 def fetch(self) -> list[Listing]:37 # 1) Page index -> pages immeuble (URLs normalisées, dédupliquées)38 html = self.get(LIST_URL).text39 soup = BeautifulSoup(html, "html.parser")40 building_urls: list[str] = []41 for a in soup.select('a[href*="/nos-immeubles-a-logements/"]'):42 url = (a.get("href") or "").split("?")[0].rstrip("/") + "/"43 if url != LIST_URL and url.startswith(LIST_URL) and url not in building_urls:44 building_urls.append(url)4546 # 2) Pages immeuble : nom, description, particularités + modèles offerts47 listings: dict[str, Listing] = {}48 for burl in building_urls:49 try:50 bhtml = self.get(burl).text51 except Exception:52 continue53 bsoup = BeautifulSoup(bhtml, "html.parser")54 h1 = bsoup.select_one("h1.entry-title, h1")55 building = h1.get_text(" ", strip=True) if h1 else ""56 b_desc = self._text_after_heading(bsoup, r"Description des immeubles")57 b_amen = self._particularites(bsoup)5859 for item in bsoup.select("div.property_div"):60 link = item.select_one("li.property_title a[href], a[href]")61 if not link:62 continue63 url = link["href"]64 m = _PROP_RE.search(url)65 if not m:66 continue67 slug = m.group(1)68 if slug in listings:69 continue70 title = link.get_text(" ", strip=True)71 thumb = item.select_one("img[src]")72 images = ([thumb["src"]] if thumb73 and thumb.get("src", "").startswith("http") else [])74 listings[slug] = Listing(75 source=self.source_id,76 external_id=slug,77 url=url,78 title=title or slug.replace("-", " "),79 city="Sherbrooke",80 unit_type=normalize_unit_type(title),81 description=b_desc[:600],82 amenities=list(b_amen),83 details={"building": building} if building else {},84 images=images,85 )8687 # 3) Fiches propriété (cache BD) : Grandeur / Inclus / Adresse,88 # description du logement, plans89 self._fetched = 090 for lst in listings.values():91 key = hashlib.sha1(f"{lst.title}|{lst.url}".encode("utf-8")).hexdigest()9293 def fetch_fn(u=lst.url):94 if self._fetched >= self.max_details:95 raise RuntimeError("budget de fiches détail atteint")96 self._fetched += 197 return self._fetch_detail(u)9899 try:100 payload = self.detail(lst.external_id, key, fetch_fn)101 except Exception:102 continue103 self._apply_detail(lst, payload)104105 return list(listings.values())106107 # -- blocs Avada (fusion) -----------------------------------------------------108 @staticmethod109 def _text_after_heading(soup, pattern: str) -> str:110 """Texte du bloc fusion-text qui suit un titre donné (page immeuble)."""111 h = soup.find(["h2", "h3"], string=re.compile(pattern))112 if not h:113 return ""114 cont = h.find_parent(class_=re.compile("fusion-title")) or h115 node = cont116 for _ in range(4):117 node = node.find_next_sibling()118 if node is None:119 break120 if "fusion-text" in (node.get("class") or []):121 return " ".join(node.get_text(" ", strip=True).split())122 return ""123124 @staticmethod125 def _particularites(soup) -> list[str]:126 """Items de la liste « Particularités » de l'immeuble."""127 h = soup.find("h3", string=re.compile(r"^\s*Particularités\s*$"))128 if not h:129 return []130 cont = h.find_parent(class_=re.compile("fusion-title")) or h131 node = cont132 for _ in range(4):133 node = node.find_next_sibling()134 if node is None:135 break136 if "fusion-text" in (node.get("class") or []):137 items = [li.get_text(" ", strip=True) for li in node.select("li")]138 items = [i for i in items if i]139 if items:140 return items[:15]141 txt = " ".join(node.get_text(" ", strip=True).split())142 return [txt] if txt else []143 return []144145 # -- fiche propriété (WP-Property) ---------------------------------------------146 def _fetch_detail(self, url: str) -> dict:147 html = self.get(url).text148 soup = BeautifulSoup(html, "html.parser")149 out: dict = {}150151 # stats structurées : Grandeur / Inclus dans le logement / Adresse152 for li in soup.select("#wpp_property_stats li"):153 lab_el = li.select_one("span.attribute")154 val_el = li.select_one("span.value")155 if not (lab_el and val_el):156 continue157 lab = strip_accents(lab_el.get_text(" ", strip=True).lower())158 val = " ".join(val_el.get_text(" ", strip=True).split()).rstrip(",")159 if lab.startswith("grandeur"):160 out["grandeur"] = val161 elif lab.startswith("inclus"):162 out["inclus"] = [x.strip() for x in val.split(",") if x.strip()]163 elif lab.startswith("adresse"):164 out["address"] = val165166 # description du logement (paragraphes après le titre wpp_title)167 title_el = soup.find("div", class_="wpp_title",168 string=re.compile("Description du logement"))169 if title_el:170 paras = []171 node = title_el172 for _ in range(8):173 node = node.find_next_sibling()174 if node is None or node.name not in ("p",):175 break176 t = " ".join(node.get_text(" ", strip=True).split())177 if t:178 paras.append(t)179 if paras:180 out["description"] = " ".join(paras)[:1200]181182 # plans des modèles (grande taille via les liens de la galerie)183 images: list[str] = []184 for a in soup.select('a[href*="/wp-content/uploads/"]'):185 href = a["href"]186 if re.search(r"\.(jpe?g|png|webp)$", href, re.I) and href not in images:187 images.append(href)188 for img in soup.select('img[src*="/wp-content/uploads/"]'):189 src = img["src"]190 if (re.search(r"\.(jpe?g|png|webp)$", src, re.I)191 and not re.search(r"logo", src, re.I) and src not in images):192 images.append(src)193 out["images"] = images[:20]194 return out195196 def _apply_detail(self, lst: Listing, d: dict) -> None:197 if not d:198 return199 if d.get("address"):200 # « 2975, rue Richard, Sherbrooke, QC J1L 2X5, Canada »201 lst.address = re.sub(r",?\s*Canada$", "", d["address"])202 if d.get("grandeur"):203 lst.unit_type = normalize_unit_type(d["grandeur"]) or lst.unit_type204 if d.get("inclus"):205 lst.amenities = list(dict.fromkeys(d["inclus"] + lst.amenities))206 if d.get("description"):207 # description propre au logement d'abord, contexte immeuble ensuite208 building_desc = lst.description209 lst.description = (d["description"] +210 (f" | {building_desc}" if building_desc else ""))[:1200]211 if d.get("images"):212 lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]213