SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.9 KB · 157 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/gestion_jdm.py : connecteur JDM Gestion immobilière (gestionjdm.ca5#   — Rivière-du-Loup, Témiscouata-sur-le-Lac, Cacouna ; Bas-Saint-Laurent).6#   WordPress + Elementor (widget UAEL Posts) rendu serveur :7#     - page /logement/ : cartes `.uael-post-wrapper.logement` — badge8#       d'étiquette (« Disponible »), adresse complète en extrait9#       (« 36 rue plourde, Rivière-du-Loup (QC) G5R 1A1 »), photo, lien fiche ;10#     - fiche (/logement-1/…) : sections h4 « Description » (text-editor),11#       « Caractéristiques » et « À proximité » (icon-list), galerie.12#   Granularité IMMEUBLE (« 24 unités de logements sur 3 étages ») — précédent13#   girs/immeubles_guillot : aucun prix ni liste d'unités publiés -> price=None,14#   rien d'inventé. Les cartes étiquetées « loué / indisponible » (si elles15#   apparaissent un jour) sont écartées ; le badge brut alimente availability.16#   Le type n'est retenu que si UN seul est cité (« 3-1/2, 4-1/2 et 5-1/2 » ->17#   vide, la description fait foi). Les pages /chambre/ (maisons de chambres)18#   et /commercial/ ne sont pas visitées.19#   external_id stable : slug de la fiche (logement-1).20#   robots.txt ouvert (Disallow vide), sitemap Yoast public.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import re2526from bs4 import BeautifulSoup2728from ..schema import Listing29from .base import BaseConnector3031BASE = "https://gestionjdm.ca"32LIST_URL = f"{BASE}/logement/"3334_TYPE_RE = re.compile(r"([1-9])\s*[-\s]?\s*(?:½|1/2)")35_CITY_RE = re.compile(r",\s*([^,()]+?)\s*\(QC\)", re.I)36_SKIP_STATUS = re.compile(r"(?i)lou[ée]|indisponible|non\s+disponible")373839class GestionJdmConnector(BaseConnector):40    source_id = "gestion_jdm"41    request_delay = 0.642    max_images = 104344    def fetch(self) -> list[Listing]:45        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")46        listings: dict[str, Listing] = {}47        for card in soup.select("div.uael-post-wrapper"):48            try:49                self._parse_card(card, listings)50            except Exception:51                continue52        return list(listings.values())5354    # -- carte UAEL Posts ----------------------------------------------------------55    def _parse_card(self, card, listings: dict[str, Listing]) -> None:56        link = card.select_one("a[href]")57        if link is None:58            return59        url = link["href"].strip()60        m = re.search(r"gestionjdm\.ca/([^/?#]+)", url)61        if not m:62            return63        ext_id = m.group(1)64        if ext_id in listings:65            return6667        # adresse complète en extrait (« 52 rue de l'Anse, Témiscouata… (QC) G0L 1E0 »)68        excerpt = card.select_one(".uael-post__excerpt")69        address = re.sub(r"\s+", " ",70                         excerpt.get_text(" ", strip=True)) if excerpt else ""71        m_city = _CITY_RE.search(address)72        city = m_city.group(1).strip() if m_city else ""7374        # badge d'étiquette (« Disponible ») — brut ; cartes louées écartées75        badge = card.select_one(".uael-post__terms")76        availability = re.sub(r"\s+", " ",77                              badge.get_text(" ", strip=True)) if badge else ""78        if _SKIP_STATUS.search(availability):79            return8081        images: list[str] = []82        thumb = card.select_one(".uael-post__thumbnail img[src]")83        if thumb and thumb["src"].startswith("http"):84            images.append(thumb["src"])8586        # fiche (cache : re-fetch seulement si adresse/badge changent)87        payload = self.detail(ext_id, f"{address}|{availability}",88                              lambda: self._fetch_detail(url))89        description = payload.get("description", "")90        amenities = payload.get("amenities", [])91        for u in payload.get("images", []):92            if u not in images and len(images) < self.max_images:93                images.append(u)9495        # type d'unités : seulement si UN seul type est cité en prose96        types = sorted(set(_TYPE_RE.findall(f"{description} "97                                            f"{' '.join(amenities)}")))98        unit_type = f"{types[0]}½" if len(types) == 1 else ""99100        listings[ext_id] = Listing(101            source=self.source_id,102            external_id=ext_id,103            url=url,104            title=address or ext_id,105            address=address,106            city=city,107            unit_type=unit_type,108            price=None,                    # aucun loyer publié sur le site109            price_label="",110            availability=availability,111            description=description,112            amenities=amenities,113            images=images,114        )115116    # -- fiche (/logement-<n>/) ------------------------------------------------------117    def _fetch_detail(self, url: str) -> dict:118        soup = BeautifulSoup(self.get(url).text, "html.parser")119        out: dict = {"description": "", "amenities": [], "images": []}120121        paras: list[str] = []122        nearby: list[str] = []123        for h4 in soup.select("h4.elementor-heading-title"):124            section = h4.get_text(" ", strip=True).lower()125            widget = h4.find_parent("div", class_="elementor-widget-heading")126            nxt = widget.find_next_sibling("div") if widget else None127            if nxt is None:128                continue129            if section.startswith("description"):130                paras += [re.sub(r"\s+", " ", p.get_text(" ", strip=True))131                          for p in nxt.select("p")]132                if not nxt.select("p"):      # texte sans <p>133                    paras.append(re.sub(r"\s+", " ",134                                        nxt.get_text(" ", strip=True)))135            elif section.startswith("caractéristiques"):136                out["amenities"] = [137                    re.sub(r"\s+", " ", li.get_text(" ", strip=True))138                    for li in nxt.select("li") if li.get_text(strip=True)]139            elif section.startswith("à proximité"):140                nearby = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))141                          for li in nxt.select("li") if li.get_text(strip=True)]142143        description = "\n".join(t for t in paras if t)[:1400]144        if nearby:145            description = (description + "\nÀ proximité : "146                           + ", ".join(nearby)).strip()147        out["description"] = description148149        for img in soup.select("img[src*='/wp-content/uploads/']"):150            u = img["src"]151            if (u.startswith("http") and "logo" not in u.lower()152                    and u not in out["images"]):153                out["images"].append(u)154                if len(out["images"]) >= self.max_images:155                    break156        return out157