SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.5 KB · 191 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/location_saguenay.py : connecteur Location Saguenay5#   (locationsaguenay.com — Saguenay : Chicoutimi près de l'UQAC/Cégep et6#   Jonquière près du Cégep ; propriétaire-gestionnaire, formule de base ou7#   tout inclus). WordPress classique (thème Davis), tout rendu serveur : le8#   menu « Logements disponibles » liste TOUTES les fiches, groupées par9#   « Appartements N chambres » (les pages villes /appartement-a-louer-10#   chicoutimi|jonquiere/ sont des vitrines SEO sans cartes). Une fiche WP par11#   immeuble/type d'unité (granularité immeuble, précédent immeubles_guillot) :12#   description libre et galerie photoswipe ; prix « sur demande » (aucun prix13#   publié -> price = None) et pas de dates de disponibilité.14#   ⚠️ robots.txt : Crawl-delay: 10 -> request_delay = 10 s et budget de fiches15#   réduit (max_details = 12, complété au fil des syncs par le cache BD).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import BaseConnector2627BASE = "https://locationsaguenay.com"28LIST_URL = f"{BASE}/"2930_GROUP_RE = re.compile(r"Appartements?\s+(\d+)\s+chambres?", re.I)31_ADDR_RE = re.compile(r"^\s*(\d[\w\s'’.,-]*?)\s*(?:à\s+\w[\w-]*)?\s*$")323334class LocationSaguenayConnector(BaseConnector):35    source_id = "location_saguenay"36    request_delay = 10.0   # Crawl-delay: 10 du robots.txt — respecté tel quel37    max_details = 12       # garde-fou fiches détail (vraies requêtes par sync)3839    def fetch(self) -> list[Listing]:40        html = self.get(LIST_URL).text41        soup = BeautifulSoup(html, "html.parser")4243        menu = soup.select_one("ul.blog-menu")44        if menu is None:45            return []4647        # entrée « Logements disponibles » du menu principal48        root = None49        for li in menu.find_all("li", recursive=False):50            a = li.find("a", recursive=False)51            if a and "logements disponibles" in a.get_text(strip=True).lower():52                root = li.find("ul", class_="sub-menu")53                break54        if root is None:55            return []5657        self._fetched = 058        listings: dict[str, Listing] = {}59        # groupes « Appartements N chambres », chacun avec ses fiches60        for group in root.find_all("li", recursive=False):61            head = group.find("a", recursive=False)62            label = head.get_text(strip=True) if head else ""63            m = _GROUP_RE.search(label)64            bedrooms = m.group(1) if m else ""65            sub = group.find("ul", class_="sub-menu")66            if sub is None:67                continue68            for a in sub.select("li > a[href]"):69                try:70                    self._parse_entry(a, bedrooms, listings)71                except Exception:72                    continue73        return list(listings.values())7475    # -- entrée du menu (une fiche WP par immeuble/type) -------------------------------76    def _parse_entry(self, a, bedrooms: str,77                     listings: dict[str, Listing]) -> None:78        url = a["href"]79        if not url.startswith(BASE):80            return81        slug = url.rstrip("/").rsplit("/", 1)[-1]82        if not slug or slug in listings:83            return84        title = re.sub(r"\s+", " ", a.get_text(" ", strip=True))8586        # type : nombre de chambres du groupe du menu (Loft si le titre le dit)87        if re.search(r"\bLofts?\b", title, re.I):88            unit_type = "Loft"89        elif bedrooms:90            unit_type = normalize_unit_type(f"{bedrooms} chambres")91        else:92            unit_type = ""9394        # adresse civique en tête de titre (« 2603, rue St-Hubert à Jonquière : … »)95        address = ""96        head = title.split(":", 1)[0].strip()97        if re.match(r"^\d", head):98            address = re.sub(r"\s+à\s+\w[\w'’-]*$", "", head).strip(" ,")99100        # secteur : mots-clés factuels du titre (UQAC = Chicoutimi)101        key = strip_accents(title.lower())102        sector = ""103        if "jonquiere" in key:104            sector = "Jonquière"105        elif "chicoutimi" in key or "uqac" in key:106            sector = "Chicoutimi"107108        amenities = []109        if bedrooms:110            amenities.append(f"{bedrooms} chambre(s)")111112        lst = Listing(113            source=self.source_id,114            external_id=slug,115            url=url,116            title=title,117            address=address,118            sector=sector,119            city="Saguenay",120            unit_type=unit_type,121            amenities=amenities,122            images=[],123        )124125        key_hash = hashlib.sha1(f"{title}|{bedrooms}".encode("utf-8")).hexdigest()126        try:127            payload = self.detail(slug, key_hash,128                                  lambda u=url: self._fetch_detail(u))129            self._apply_detail(lst, payload)130        except Exception:131            pass132        listings[slug] = lst133134    # -- fiche détail (page WP de l'immeuble) ------------------------------------------135    def _fetch_detail(self, url: str) -> dict:136        """Description libre, mention de prix (« sur demande ») et galerie."""137        if self._fetched >= self.max_details:138            raise RuntimeError("budget de fiches détail atteint")139        self._fetched += 1140        html = self.get(url).text141        soup = BeautifulSoup(html, "html.parser")142        out: dict = {}143144        content = soup.select_one(".entry-content") or soup145        paras: list[str] = []146        for p in content.find_all("p"):147            txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))148            if not txt:149                continue150            # mention de prix du site (« Tarif/Prix sur demande ») : libellé brut151            m = re.search(r"(?:tarif|prix)[^.]*demande[^.]*\.?", txt, re.I)152            if m:153                out.setdefault("price_label", m.group(0).strip())154                continue155            # fin du contenu utile : bloc contact/formulaire156            if re.search(r"contactez|formulaire|courriel|581-|1-888-", txt, re.I):157                break158            paras.append(txt)159        if paras:160            out["description"] = "\n".join(paras)[:1500]161162        # secteur si la description est plus précise que le titre163        key = strip_accents(" ".join(paras).lower())164        if "jonquiere" in key:165            out["sector"] = "Jonquière"166        elif "chicoutimi" in key or "uqac" in key:167            out["sector"] = "Chicoutimi"168169        # galerie photoswipe (photos réelles ; logo du site exclu)170        images: list[str] = []171        for img in content.find_all("img", src=True):172            u = img["src"]173            if (u.startswith("http") and "/wp-content/uploads/" in u174                    and "cropped-" not in u and u not in images):175                images.append(u)176        out["images"] = images[:25]177        return out178179    def _apply_detail(self, lst: Listing, d: dict) -> None:180        """Reporte le payload (frais ou en cache) sur l'annonce."""181        if not d:182            return183        if d.get("description"):184            lst.description = d["description"]185        if d.get("price_label"):186            lst.price_label = d["price_label"]      # « sur demande » : prix None187        if d.get("sector") and not lst.sector:188            lst.sector = d["sector"]189        if d.get("images"):190            lst.images = d["images"]191