SPB Git

spb/lou-ka Public

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

HTML 99.7%
13.7 KB · 317 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/dynamic.py : connecteur Gestion immobilière Dynamic5#   (lesgestionsdynamic.com) — apparts à Sherbrooke, Magog, Granby, East Angus.6#   WordPress + thème immobilier Houzez, même famille que gimcote.py : archive7#   /a-louer/ paginée (cartes div.item-listing-wrap avec prix, galerie8#   data-images, data-listid). Particularité locale : pas d'adresse ni de9#   statut sur la carte — la disponibilité est un suffixe du titre10#   (« IMMÉDIATEMENT », « JUILLET », « 1er AOÛT »…) et l'adresse/ville/GPS11#   viennent de la fiche détail (bloc #property-address-wrap, carte Houzez).12#   Sert de CLASSE DE BASE à la famille Houzez de l'Estrie : Gestimmo Estrie13#   (gestimmo_estrie.py) et Agence de location Sherbrooke / groupe Prestiplex14#   (agence_sherbrooke.py) n'en redéfinissent que les constantes et les15#   crochets carte (statut/étiquette/adresse).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import html as htmllib21import json22import re2324from bs4 import BeautifulSoup2526from ..schema import (Listing, normalize_unit_type, parse_price,27                      strip_accents)28from .base import BaseConnector2930# variantes redimensionnées WordPress (-584x438.jpg) -> pleine taille31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)32_MAP_LATLNG_RE = re.compile(33    r'"lat"\s*:\s*"?(-?\d+\.\d+)"?\s*,\s*"lng"\s*:\s*"?(-?\d+\.\d+)"?')3435# villes réelles desservies par la famille (jamais devinées : on ne retient la36# ville que si elle apparaît telle quelle dans l'adresse ou le bloc « Ville »)37_KNOWN_CITIES = [38    "Sherbrooke", "Magog", "Granby", "East Angus", "Orford", "Waterville",39    "Windsor", "Coaticook", "Ascot Corner", "Lennoxville", "Richmond",40    "Cookshire-Eaton", "Bromptonville", "Cowansville", "Bromont", "Valcourt",41]4243# suffixe de disponibilité dans les titres Dynamic : « IMMÉDIATEMENT »,44# « JUILLET », « 1er AOÛT », « LOUÉ »…45_AVAIL_TITLE_RE = re.compile(46    r"(imm[eé]diatement|d[èe]s maintenant|lou[ée]|"47    r"(?:1er\s+|15\s+)?(?:janvier|f[ée]vrier|mars|avril|mai|juin|juillet|"48    r"ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)(?:\s+20\d\d)?)\s*$",49    re.I)505152def _clean_price_label(label: str) -> str:53    """'1,450$/mois' ou '1 450 $/mois' -> compatible parse_price."""54    return re.sub(r"(\d)[,\s](\d{3})", r"\1\2", label)555657def _city_from_parts(parts: list[str]) -> tuple[str, str]:58    """(ville, secteur) depuis les segments Nominatim de address.item-address :59    « 89, Rue des Pins, East Angus, Le Haut-Saint-François, … » — la ville est60    le segment qui correspond à une ville connue, le secteur le segment qui la61    précède (micro-quartier) quand il ne fait pas partie de l'adresse civique."""62    for i, p in enumerate(parts):63        for city in _KNOWN_CITIES:64            if strip_accents(p.strip().lower()) == strip_accents(city.lower()):65                sector = ""66                if i >= 3:      # [n° civique, rue, quartier, ville, ...]67                    sector = parts[i - 1].strip()68                return city, sector69    return "", ""707172class DynamicConnector(BaseConnector):73    source_id = "dynamic"74    request_delay = 0.675    max_pages = 12       # garde-fou de pagination76    max_details = 80     # garde-fou fiches détail (vraies requêtes)7778    BASE = "https://lesgestionsdynamic.com"79    LIST_PATH = "/a-louer/"80    CITY_DEFAULT = "Sherbrooke"    # repli si la fiche ne précise pas la ville8182    def fetch(self) -> list[Listing]:83        listings: dict[str, Listing] = {}84        for page in range(1, self.max_pages + 1):85            url = (f"{self.BASE}{self.LIST_PATH}" if page == 186                   else f"{self.BASE}{self.LIST_PATH}page/{page}/")87            try:88                html = self.get(url).text89            except Exception:90                break91            soup = BeautifulSoup(html, "html.parser")92            cards = soup.select("div.item-listing-wrap")93            if not cards:94                break95            for card in cards:96                try:97                    self._parse_card(card, listings)98                except Exception:99                    continue100101        # fiches détail (cache BD) : adresse/ville/GPS, description,102        # caractéristiques, type d'unité (aperçu Houzez), galerie complète103        self._fetched = 0104        for lst in listings.values():105            card_key = hashlib.sha1(106                f"{lst.title}|{lst.price_label}|{lst.availability}"107                .encode("utf-8")).hexdigest()108            try:109                payload = self.detail(lst.external_id, card_key,110                                      lambda u=lst.url: self._fetch_detail(u))111            except Exception:112                continue113            self._apply_detail(lst, payload)114        return list(listings.values())115116    # -- crochets par site --------------------------------------------------------117    def _card_availability(self, card, title: str) -> str:118        """Dynamic : la disponibilité est un suffixe du titre de l'annonce."""119        m = _AVAIL_TITLE_RE.search(title)120        return m.group(1).strip() if m else ""121122    # -- carte Houzez ---------------------------------------------------------------123    def _parse_card(self, card, listings: dict[str, Listing]) -> None:124        link = card.select_one("h2.item-title a[href]")125        if not link:126            return127        url = link["href"]128        title = re.sub(r"\s+", " ", link.get_text(" ", strip=True)).strip()129        m = re.search(r"/(?:property|appartement)/([^/]+)/?", url)130        slug = m.group(1) if m else ""131        listid_el = card.select_one("[data-listid]")132        ext_id = (listid_el.get("data-listid") if listid_el else "") or slug133        if not ext_id or str(ext_id) in listings:134            return135136        # exclusions : logements loués + espaces non résidentiels137        if re.search(r"\blou[ée]s?\b", title, re.I):138            return139        if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t",140                     title, re.I):141            return142        availability = self._card_availability(card, title)143        if re.search(r"lou[ée]", availability, re.I):144            return145146        price_el = card.select_one("li.item-price")147        price_label = price_el.get_text(" ", strip=True) if price_el else ""148149        # adresse Nominatim de la carte (présente chez Agence, absente chez150        # Dynamic/Gestimmo — la fiche détail prendra le relais)151        address = sector = ""152        city = self.CITY_DEFAULT153        addr_el = card.select_one("address.item-address")154        if addr_el:155            full = addr_el.get_text(" ", strip=True)156            parts = [p.strip() for p in full.split(",") if p.strip()]157            address = ", ".join(parts[:2]) if len(parts) >= 2 else full158            c, s = _city_from_parts(parts)159            if c:160                city, sector = c, s161162        # commodités des cartes Houzez : chambres / salles de bain / pi²163        amenities: list[str] = []164        for li in card.select("ul.item-amenities li"):165            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))166            t = t.replace("Beds:", "Chambres :").replace("Bath:", "Salle(s) de bain :")167            if t and t not in amenities:168                amenities.append(t)169170        # galerie : attribut data-images (JSON, URLs redimensionnées)171        images: list[str] = []172        raw = card.get("data-images") or ""173        if raw:174            try:175                urls = json.loads(htmllib.unescape(raw))176            except Exception:177                urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw))178            for u in urls:179                if isinstance(u, dict):   # variante Houzez : objets {url: …}180                    u = u.get("url") or u.get("src") or u.get("image") or ""181                if not isinstance(u, str):182                    continue183                u = u.replace("\\/", "/").strip()184                if u.startswith("http"):185                    u = _SIZE_SUFFIX.sub("", u)186                    if u not in images:187                        images.append(u)188        if not images:189            thumb = card.select_one("img.wp-post-image[src]")190            if thumb:191                images = [_SIZE_SUFFIX.sub("", thumb["src"])]192193        # type d'unité depuis le titre, seulement si le motif est net194        # (normalize_unit_type retourne le texte brut quand rien ne matche)195        unit_type = normalize_unit_type(title)196        if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",197                            unit_type or ""):198            unit_type = ""199200        listings[str(ext_id)] = Listing(201            source=self.source_id,202            external_id=str(ext_id),203            url=url,204            title=title,205            address=address,206            sector=sector,207            city=city,208            unit_type=unit_type,209            price=parse_price(_clean_price_label(price_label)),210            price_label=price_label,211            availability=availability,212            amenities=amenities,213            images=images[:30],214        )215216    # -- fiche détail (Houzez) -----------------------------------------------------217    def _fetch_detail(self, url: str) -> dict:218        """Adresse structurée (#property-address-wrap), description,219        caractéristiques, type d'unité (aperçu) et GPS (carte Houzez)."""220        if self._fetched >= self.max_details:221            raise RuntimeError("budget de fiches détail atteint")222        self._fetched += 1223        html = self.get(url).text224        soup = BeautifulSoup(html, "html.parser")225        out: dict = {}226227        desc_el = soup.select_one("#property-description-wrap")228        if desc_el:229            txt = desc_el.get_text("\n", strip=True)230            txt = re.sub(r"^Description\n", "", txt)231            out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]232233        out["amenities"] = [a.get_text(" ", strip=True)234                            for a in soup.select("#property-features-wrap li")235                            if a.get_text(strip=True)][:25]236237        # bloc adresse : « Adresse | 375 rue terrill | Ville |238        # Fleurimont (Sherbrooke) | Code postal | J1E 3S7 »239        for li in soup.select("#property-address-wrap li"):240            st, sp = li.find("strong"), li.find("span")241            if not (st and sp):242                continue243            lab = strip_accents(st.get_text(" ", strip=True).lower())244            val = sp.get_text(" ", strip=True)245            if lab.startswith("adresse"):246                out["address"] = val247            elif lab.startswith("ville"):248                out["city_raw"] = val249            elif "postal" in lab:250                out["postal"] = val251252        # adresse Nominatim complète (repli ville/secteur)253        addr_el = soup.select_one("address.item-address")254        if addr_el:255            out["item_address"] = addr_el.get_text(" ", strip=True)256257        # type d'unité : aperçu Houzez (« Appartement 4 1/2 ») — exiger un258        # motif net (« … 4 1/2 », « Studio »…), jamais le mot « chambres » du259        # compteur de pièces260        ov = soup.select_one(".property-overview-wrap, #property-overview-wrap")261        if ov:262            m = re.search(r"(\d\s*(?:1/2|½)|\bstudio\b|\bloft\b|\bmaison\b)",263                          ov.get_text(" ", strip=True), re.I)264            if m:265                out["type_raw"] = m.group(1)266267        m = _MAP_LATLNG_RE.search(html)268        if m:269            out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))270        return out271272    def _apply_detail(self, lst: Listing, d: dict) -> None:273        """Reporte le payload (frais ou cache BD) sur l'annonce."""274        if not d:275            return276        if d.get("description"):277            lst.description = d["description"]278        if d.get("amenities"):279            lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))280281        # adresse structurée de la fiche ; « Ville » au format282        # « Fleurimont (Sherbrooke) » (secteur (ville)) ou « Magog »283        if d.get("address") and not lst.address:284            lst.address = d["address"]285        city_raw = (d.get("city_raw") or "").strip()286        if city_raw:287            m = re.match(r"^(.*?)\s*\((.+)\)\s*$", city_raw)288            inner = (m.group(2).strip() if m else city_raw)289            outer = (m.group(1).strip() if m else "")290            c, _ = _city_from_parts([inner])291            if c:                       # « Fleurimont (Sherbrooke) »292                lst.city = c293                if outer and not lst.sector:294                    lst.sector = outer295            else:296                c2, _ = _city_from_parts([city_raw])297                if c2:                  # « Magog »298                    lst.city = c2299                elif not lst.sector:    # « Nord », « Centre-ville »…300                    lst.sector = city_raw301        elif d.get("item_address"):302            parts = [p.strip() for p in d["item_address"].split(",") if p.strip()]303            c, s = _city_from_parts(parts)304            if c:305                lst.city = c306                if s and not lst.sector:307                    lst.sector = s308        if not lst.address and d.get("item_address"):309            parts = [p.strip() for p in d["item_address"].split(",") if p.strip()]310            if len(parts) >= 2:311                lst.address = ", ".join(parts[:2])312313        if not lst.unit_type and d.get("type_raw"):314            lst.unit_type = normalize_unit_type(d["type_raw"])315        if d.get("lat") is not None and d.get("lng") is not None:316            lst.lat, lst.lng = d["lat"], d["lng"]317