SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
9.1 KB · 213 lines python
Raw Blame History
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