SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.7 KB · 161 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/bons_locataires.py : Bons Locataires (bonslocataires.com) — agence5#   de location Montréal/Laval/Longueuil. WordPress, aucun anti-bot (seul6#   mod_security bloque les sitemaps XML — on ne les utilise pas).7#   Source de vérité : la page carte MapListPro (~8 Mo) dont le tableau JSON8#   inline contient les 1 600+ emplacements avec lat/lng, taxonomies9#   (région/quartier/taille) et description complète. Le site garde les10#   logements loués sur la carte (« DÉJÀ LOUÉ », pin PouceVert) : on ne11#   conserve que les annonces encore offertes, et on valide que leur page12#   (locationUrl) répond toujours 200 (certains vieux liens sont morts).13#   Prix et disponibilité en texte libre -> regex. ~20-40 annonces actives.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as H18import json19import re2021from ..schema import Listing, normalize_unit_type22from .base import BaseConnector2324MAP_PAGE = ("https://www.bonslocataires.com/logement-appartement-a-louer-"25            "enquete-proprio-credit-locataire-visite-virtuelle-360/")2627_PRICE_RE = re.compile(r"(\d{3,4})\s*\$\s*(?:/|par)?\s*mois", re.I)28_PRICE_ALT_RE = re.compile(r"(\d{3,4})\s*\$")29_IMG_RE = re.compile(r'src="(https?://www\.bonslocataires\.com/wp-content/'30                     r'uploads/[^"]+)"')31_LOC_ID_RE = re.compile(r"loc-(\d+)")32_TAG_RE = re.compile(r"<[^>]+>")333435def _texte(fragment: str) -> str:36    return re.sub(r"\s+\n", "\n", H.unescape(_TAG_RE.sub("", fragment or ""))).strip()373839class BonsLocatairesConnector(BaseConnector):40    source_id = "bons_locataires"41    request_delay = 1.042    timeout = 120                      # la page carte fait ~8 Mo4344    def _map_objects(self) -> list[dict]:45        import time46        html = ""47        for attempt in range(3):        # mod_security coupe parfois la page 8 Mo48            try:49                html = self.get(MAP_PAGE).text50                break51            except Exception:52                if attempt == 2:53                    raise54                time.sleep(20 * (attempt + 1))55        i = html.find('[{"title":')56        if i < 0:57            return []58        depth, end = 0, None59        for j in range(i, len(html)):60            if html[j] == "[":61                depth += 162            elif html[j] == "]":63                depth -= 164                if depth == 0:65                    end = j + 166                    break67        try:68            return json.loads(html[i:end])69        except ValueError:70            return []7172    @staticmethod73    def _available(obj: dict) -> bool:74        blob = (obj.get("title", "") + (obj.get("description") or "")75                + (obj.get("icon") or "") + (obj.get("pinImageUrl") or ""))76        return "LOU" not in blob.upper() or "PouceVert" not in blob7778    def fetch(self) -> list[Listing]:79        out: list[Listing] = []80        for obj in self._map_objects():81            desc_html = obj.get("description") or ""82            blob = (obj.get("title", "") + desc_html).upper()83            pin = (obj.get("pinImageUrl") or "") + (obj.get("icon") or "")84            if "DÉJÀ LOU" in blob or "DEJA LOU" in blob or "LOUÉ!" in blob \85                    or "PouceVert" in pin:86                continue87            url = (obj.get("locationUrl") or "").replace("http://", "https://")88            if not url:89                continue90            try:                        # vieux liens morts -> annonce écartée91                page_html = self.get(url).text92            except Exception:93                continue94            m = _LOC_ID_RE.search(obj.get("cssClass") or "")95            eid = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]9697            cats = obj.get("customCategories") or {}98            def _first(tax: str) -> str:99                terms = cats.get(tax) or []100                return terms[0].get("name", "") if terms else ""101            city = _first("map_location_categories_region") or "Montréal"102            sector = _first("map_location_categories_city")103            taille = _first("map_location_categories_taille")104105            title = _texte(obj.get("title", "")).strip("* ")106            unit_type = normalize_unit_type(taille or title)107            if not re.match(r"^\d½$|^6½\+$|^Studio$|^Loft$|^Chambre", unit_type or ""):108                unit_type = ""109110            # la fiche WordPress (déjà téléchargée pour la validation) est plus111            # riche que le popup carte : prix, galerie, disponibilité112            page_main = ""113            m = re.search(r'<div class="entry-content[^"]*">(.*?)</div>\s*'114                          r'(?:<footer|<div class="entry-footer)', page_html, re.S)115            if not m:116                m = re.search(r"<article\b.*?</article>", page_html, re.S)117            if m:118                page_main = m.group(0)119            page_text = _texte(page_main)120121            description = _texte(desc_html)122            if 100 < len(page_text) and len(page_text) > len(description):123                description = page_text124            price = None                # le popup carte prime : texte ciblé125            for texte in (_texte(desc_html), page_text):126                m = _PRICE_RE.search(texte) or _PRICE_ALT_RE.search(texte)127                if m and 400 <= float(m.group(1)) <= 5000:128                    price = float(m.group(1))129                    break130            availability = ""131            m = re.search(r"[Dd]isponible[^.\n<]{0,70}", description)132            if m:133                availability = m.group(0).strip()134            images = []135            for u in _IMG_RE.findall(page_main) + _IMG_RE.findall(desc_html):136                u = u.replace("http://", "https://")137                if u not in images:138                    images.append(u)139            try:140                lat, lng = float(obj.get("latitude")), float(obj.get("longitude"))141            except (TypeError, ValueError):142                lat = lng = None143            out.append(Listing(144                source=self.source_id,145                external_id=str(eid),146                url=url,147                title=title,148                sector=sector,149                city=city,150                unit_type=unit_type,151                price=price,152                price_label=(f"{price:,.0f} $/mois".replace(",", " ")153                             if price else ""),154                availability=availability,155                description=description[:6000],156                images=images,157                lat=lat,158                lng=lng,159            ))160        return out161