SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.0 KB · 162 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/louer_saguenay.py : connecteur Louer Saguenay (louersaguenay.com5#   — centre-ville de Jonquière, ville de Saguenay ; 9128-0768 Québec inc.).6#   Petit parc de lofts et d'appartements au-dessus de locaux commerciaux de la7#   rue Saint-Dominique. WordPress + Elementor rendu serveur :8#     - page /location-loft-appartement-jonquiere/ : boucle Elementor — un9#       item `.e-loop-item.category-location-loft-appartement-jonquiere` par10#       annonce (titre h2 + lien, extrait « DISPONIBLE AU 1-6-2026 », photo) ;11#       la section « NOS ESPACES COMMERCIALES » (articles `ee-post`, catégorie12#       location-espaces-commercials-saguenay) est ignorée ;13#     - fiche : description (« Informations »), « Prix mensuel : » suivi des14#       tarifs « 1395.00$ (Non meublé) / 1495.00$ (Meublé) » -> price = le plus15#       bas, price_label = texte original complet, galerie de photos.16#   Fiches visitées via self.detail (cache BD, re-fetch seulement si l'extrait17#   ou le titre change) — parc ~5 unités résidentielles.18#   external_id stable : slug de la fiche. city=Saguenay, sector=Jonquière19#   (convention des connecteurs SLSJ existants).20#   robots.txt ouvert (Disallow /imunify-bot-check seulement), sitemap Yoast.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import re2526from bs4 import BeautifulSoup2728from ..schema import Listing, normalize_unit_type29from .base import BaseConnector3031BASE = "https://louersaguenay.com"32LIST_URL = f"{BASE}/location-loft-appartement-jonquiere/"3334_TYPE_RE = re.compile(r"([1-6])\s*(?:½|1/2)")35# « … à louer 2345 rue Saint-Dominique Suite 201 » -> tout ce qui suit « à louer »36_ADDR_RE = re.compile(r"à louer\s+(\d.+)$", re.I)37_PRICE_RE = re.compile(r"(\d[\d\s]*(?:[.,]\d{1,2})?)\s*\$")383940class LouerSaguenayConnector(BaseConnector):41    source_id = "louer_saguenay"42    request_delay = 0.643    max_images = 104445    def fetch(self) -> list[Listing]:46        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")4748        listings: dict[str, Listing] = {}49        items = soup.select(50            ".e-loop-item.category-location-loft-appartement-jonquiere")51        for item in items:52            try:53                self._parse_item(item, listings)54            except Exception:55                continue56        return list(listings.values())5758    # -- item de la boucle Elementor ----------------------------------------------59    def _parse_item(self, item, listings: dict[str, Listing]) -> None:60        h2 = item.select_one("h2.elementor-heading-title a[href]")61        if h2 is None:62            return63        url = h2["href"].strip()64        title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))65        m = re.search(r"louersaguenay\.com/([^/?#]+)", url)66        if not m:67            return68        ext_id = m.group(1)69        if ext_id in listings:70            return7172        # extrait = disponibilité (« DISPONIBLE AU 1-6-2026 ») quand publiée73        availability = ""74        excerpt = item.select_one(".elementor-widget-theme-post-excerpt "75                                  ".elementor-widget-container")76        if excerpt is not None:77            t = re.sub(r"\s+", " ", excerpt.get_text(" ", strip=True))78            if t:79                availability = t8081        images: list[str] = []82        thumb = item.select_one("img[src]")83        if thumb and thumb["src"].startswith("http"):84            images.append(thumb["src"])8586        m_type = _TYPE_RE.search(title)87        if m_type:88            unit_type = f"{m_type.group(1)}½"89        elif re.search(r"(?i)\bloft\b", title):90            unit_type = "Loft"91        else:92            unit_type = ""9394        m_addr = _ADDR_RE.search(title)95        address = f"{m_addr.group(1)}, Jonquière" if m_addr else ""9697        # fiche (cache : re-fetch seulement si le titre/extrait change)98        payload = self.detail(ext_id, f"{title}|{availability}",99                              lambda: self._fetch_detail(url))100101        price_label = payload.get("price_label", "")102        prices = [float(p.replace(" ", "").replace(" ", "").replace(",", "."))103                  for p in _PRICE_RE.findall(price_label)]104        prices = [p for p in prices if 100 <= p <= 20000]105106        for u in payload.get("images", []):107            if u not in images and len(images) < self.max_images:108                images.append(u)109110        listings[ext_id] = Listing(111            source=self.source_id,112            external_id=ext_id,113            url=url,114            title=title,115            address=address,116            sector="Jonquière",117            city="Saguenay",118            unit_type=normalize_unit_type(unit_type),119            price=min(prices) if prices else None,   # le plus bas (non meublé)120            price_label=price_label,121            availability=availability or payload.get("availability", ""),122            description=payload.get("description", ""),123            images=images,124        )125126    # -- fiche -------------------------------------------------------------------127    def _fetch_detail(self, url: str) -> dict:128        soup = BeautifulSoup(self.get(url).text, "html.parser")129        out: dict = {"price_label": "", "availability": "",130                     "description": "", "images": []}131132        # « Prix mensuel : » -> le heading suivant porte les tarifs133        headings = soup.select("h1, h2.elementor-heading-title")134        for i, h in enumerate(headings):135            t = re.sub(r"\s+", " ", h.get_text(" ", strip=True))136            if re.match(r"(?i)^prix\s+mensuel", t) and i + 1 < len(headings):137                out["price_label"] = re.sub(138                    r"\s+", " ", headings[i + 1].get_text(" ", strip=True))139            elif re.match(r"(?i)^disponible", t):140                # « Disponible : » suivi d'un heading date (« 1er juillet 2022 »)141                if t.rstrip(":").strip().lower() == "disponible" \142                        and i + 1 < len(headings):143                    nxt = re.sub(r"\s+", " ",144                                 headings[i + 1].get_text(" ", strip=True))145                    t = f"Disponible : {nxt}"146                out["availability"] = t147148        # paragraphes de la section « Informations »149        paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))150                 for p in soup.select(".elementor-widget-text-editor p")]151        out["description"] = "\n".join(t for t in paras if len(t) >= 25)[:1600]152153        for img in soup.select("img[src*='/wp-content/uploads/']"):154            u = img["src"]155            if (u.startswith("http") and "logo" not in u.lower()156                    and not re.search(r"-\d+x\d+\.(?:jpe?g|png|webp)$", u)157                    and u not in out["images"]):158                out["images"].append(u)159                if len(out["images"]) >= self.max_images:160                    break161        return out162