SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.0 KB · 205 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_di.py : connecteur Location DI / Groupe Sphère DI5#   (locationdi.com) — +750 logements : Trois-Rivières, Bécancour, MRC de6#   Joliette (Saint-Charles-Borromée, Saint-Félix-de-Valois…), Berthierville,7#   Lachute, Montréal.8#   Site SSR (Astro) très propre : la page /logements affiche toutes les9#   unités disponibles sous forme de cartes avec ATTRIBUTS DE DONNÉES10#   structurés (data-slug, data-ville, data-region, data-type, data-etage,11#   data-prix, data-date). Chaque fiche embarque un JSON-LD schema.org12#   « Apartment » (adresse, superficie, chambres, photos, description).13#   Les pages régionales /logements/region/<r> sont aussi balayées (filet).14#   robots.txt : pas de bloc User-agent générique ni de crawl-delay ; les15#   bots IA « live fetch » sont explicitement autorisés, seuls les bots16#   d'ENTRAÎNEMENT sont bloqués — notre usage (indexation d'annonces avec17#   lien vers la source, UA transparent LouKaBot) est conforme.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import hashlib22import json23import re2425from bs4 import BeautifulSoup2627from ..schema import Listing, parse_availability_date28from .base import BaseConnector2930BASE = "https://locationdi.com"3132# JSON-LD des fiches : <script type="application/ld+json">{...}</script>33_LD_RE = re.compile(34    r'<script type="application/ld\+json">(.*?)</script>', re.S)353637class _CapAtteint(Exception):38    """Plafond de requêtes détail atteint pour cette synchronisation."""394041class LocationDIConnector(BaseConnector):42    source_id = "location_di"43    request_delay = 0.7          # politesse (petit site régional)44    max_region_pages = 12        # garde-fou : pages régionales balayées45    max_real_details = 60        # vraies requêtes fiches par sync (cache exclu)4647    # -- liste ------------------------------------------------------------------48    def _cards(self, html: str, found: dict[str, dict]) -> list[str]:49        """Cartes .logement-card (attributs data-*) ; retourne les pages50        régionales découvertes (/logements/region/…)."""51        soup = BeautifulSoup(html, "html.parser")52        for card in soup.select("div.logement-card[data-slug]"):53            slug = card.get("data-slug", "").strip()54            if not slug or slug in found:55                continue56            text = card.get_text(" ", strip=True)57            # badge de disponibilité affiché sur la carte (texte source)58            avail = ""59            m = re.search(r"(Disponible dès maintenant|Libre le [^·|]+)", text)60            if m:61                avail = m.group(1).strip()62            found[slug] = {63                "ville": card.get("data-ville", "").strip(),64                "region": card.get("data-region", "").strip(),65                "type": card.get("data-type", "").strip(),66                "etage": card.get("data-etage", "").strip(),67                "prix": card.get("data-prix", "").strip(),68                "date": card.get("data-date", "").strip(),69                "avail": avail,70                "from": "À partir de" in text,71            }72        regions = []73        for a in soup.select('a[href^="/logements/region/"]'):74            href = (a.get("href") or "").split("?")[0].split("#")[0]75            if href:76                regions.append(BASE + href)77        return regions7879    # -- fiche (JSON-LD + commodités) --------------------------------------------80    def _unit_detail(self, slug: str, key: str) -> dict:81        def _fetch() -> dict:82            if self._real_details >= self.max_real_details:83                raise _CapAtteint()84            self._real_details += 185            resp = self.get(f"{BASE}/logements/{slug}")86            resp.encoding = "utf-8"      # pages servies sans charset explicite87            html = resp.text88            out: dict = {}89            for m in _LD_RE.finditer(html):90                try:91                    ld = json.loads(m.group(1))92                except ValueError:93                    continue94                if ld.get("@type") != "Apartment":95                    continue96                out["description"] = str(ld.get("description") or "")97                adr = ld.get("address") or {}98                out["street"] = str(adr.get("streetAddress") or "")99                out["locality"] = str(adr.get("addressLocality") or "")100                fs = ld.get("floorSize") or {}101                try:102                    v = float(fs.get("value"))103                    if 80 <= v <= 20000:104                        out["area"] = v105                except (TypeError, ValueError):106                    pass107                beds = ld.get("numberOfRooms")108                if isinstance(beds, int):109                    out["bedrooms"] = beds110                out["images"] = [u for u in (ld.get("image") or [])111                                 if isinstance(u, str) and u.startswith("http")]112                break113            # commodités : noms des items sous « Commodités et inclusions »114            soup = BeautifulSoup(html, "html.parser")115            amenities: list[str] = []116            for h2 in soup.find_all("h2"):117                if "Commodités" not in h2.get_text():118                    continue119                cont = h2.find_next_sibling("div") or h2.parent120                for p in cont.find_all("p"):121                    cls = " ".join(p.get("class") or [])122                    if "font-semibold" not in cls:123                        continue124                    t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))125                    if 3 <= len(t) <= 60 and t not in amenities:126                        amenities.append(t)127                break128            out["amenities"] = amenities[:20]129            return out130131        try:132            return self.detail(slug, key, _fetch)133        except Exception:134            return {}135136    # -- fetch --------------------------------------------------------------------137    def fetch(self) -> list[Listing]:138        self._real_details = 0139        found: dict[str, dict] = {}140141        resp = self.get(f"{BASE}/logements")142        resp.encoding = "utf-8"          # pages servies sans charset explicite143        regions = self._cards(resp.text, found)144        # filet : pages régionales (au cas où une unité manque à la liste globale)145        for url in list(dict.fromkeys(regions))[: self.max_region_pages]:146            try:147                r = self.get(url)148                r.encoding = "utf-8"149                self._cards(r.text, found)150            except Exception:151                continue152153        listings: list[Listing] = []154        for slug, c in found.items():155            m = re.search(r"(\d+)$", slug)156            ext_id = m.group(1) if m else slug     # id numérique stable du slug157            key = hashlib.sha1(json.dumps(c, sort_keys=True, ensure_ascii=False)158                               .encode("utf-8")).hexdigest()[:16]159            det = self._unit_detail(slug, key)160161            try:162                price = float(c["prix"]) if c["prix"] else None163            except ValueError:164                price = None165            availability = c["avail"] or (f"Libre le {c['date']}" if c["date"]166                                          else "")167            # data-date ISO de la carte (une date passée = libre maintenant)168            avail_date = parse_availability_date(c["date"]) if c["date"] else None169170            address = det.get("street") or ""171            title_addr = ""172            if address:173                title_addr = address + (f", {det['locality']}"174                                        if det.get("locality") else "")175            details: dict = {}176            if c["etage"]:177                details["floor_label"] = c["etage"]      # ex. « 3e Étage »178            if isinstance(det.get("bedrooms"), int):179                details["bedrooms"] = det["bedrooms"]180            if c["from"]:181                details["price_from"] = True182183            listings.append(Listing(184                source=self.source_id,185                external_id=ext_id,186                url=f"{BASE}/logements/{slug}",187                title=(f"{c['type']}{title_addr}" if title_addr188                       else f"{c['type']}{slug}"),189                address=address,190                sector=c["region"],                       # ex. « Mauricie »191                city=det.get("locality") or c["ville"],   # vraie ville192                unit_type=c["type"],193                price=price,194                price_label=(f"À partir de {c['prix']}$ / mois"195                             if c["from"] and c["prix"] else ""),196                availability=availability,197                availability_date=avail_date,198                area_sqft=det.get("area"),199                description=str(det.get("description") or "")[:600],200                amenities=det.get("amenities") or [],201                details=details,202                images=(det.get("images") or [])[:20],203            ))204        return listings205