SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.4 KB · 262 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/lafrance_mathieu.py : connecteur Lafrance & Mathieu5#   (lafrance-mathieu.com — Québec, Lévis, Val-Bélair, Beauport, etc.)6#   Page liste /louer-appartement-quebec (+ filtres ?boroughs=N), une annonce7#   par immeuble. La page détail /logement/<id> expose : adresse complète avec8#   code postal, lat/lng (script JSON building-coordinates), commodités et9#   caractéristiques (p.attribute-description : chat accepté, balcon, entrées10#   laveuse-sécheuse/lave-vaisselle…), unités individuelles (numéro, étage,11#   disponibilité, prix), année de construction, téléphone de l'immeuble.12#   Fiches visitées via self.detail() (cache BD, max max_details/sync).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import json18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, infer_city, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://lafrance-mathieu.com"26LIST_URL = f"{BASE}/louer-appartement-quebec"2728_IMG_RE = re.compile(29    r'https://gilm-site-vitrine-production\.s3\.amazonaws\.com/'30    r'media/real_estate/[^"\s\)]+\.(?:jpg|jpeg|png|webp)')31_POSTAL_RE = re.compile(r"^[A-Z]\d[A-Z]\s?\d[A-Z]\d$")32_PHONE_RE = re.compile(r"\b\d{3}[-. ]\d{3}[-. ]\d{4}\b")333435class _DetailBudget(Exception):36    """Budget de fiches détail atteint pour cette synchronisation."""373839class LafranceMathieuConnector(BaseConnector):40    source_id = "lafrance_mathieu"41    max_details = 150         # nouvelles fiches détail max par synchronisation4243    # -- fiche immeuble ---------------------------------------------------------44    def _fetch_detail(self, url: str) -> dict:45        """Page /logement/<id> -> payload JSON-sérialisable (cache BD)."""46        html = self.get(url).text47        soup = BeautifulSoup(html, "html.parser")48        payload: dict = {}4950        # photos de l'immeuble (bucket S3)51        payload["images"] = list(dict.fromkeys(_IMG_RE.findall(html)))5253        # lat/lng structurés : <script id="building-coordinates">[{"loc": …}]54        sc = soup.find("script", id="building-coordinates")55        if sc and sc.string:56            try:57                coords = json.loads(sc.string)58                loc = (coords[0] or {}).get("loc") if coords else None59                if loc and len(loc) == 2:60                    payload["lat"] = float(loc[0])61                    payload["lng"] = float(loc[1])62            except (ValueError, TypeError, AttributeError, IndexError):63                pass6465        # adresse complète (bloc « À propos de l'immeuble » > Adresse)66        strong = soup.find("strong", string=re.compile(r"^\s*Adresse\s*$"))67        if strong:68            bloc = strong.find_parent("div")69            lignes = []70            for p in bloc.find_all("p") if bloc else []:71                t = " ".join(p.get_text(" ", strip=True).split())72                if not t or t == "Adresse":73                    continue74                t = re.sub(r"[()]", "", t)75                lignes.append(t)76            if lignes:77                payload["address"] = ", ".join(lignes)7879        # « Plus d'informations » : étages / unités / année de construction80        for sub in soup.select("p.subtext"):81            label = sub.get_text(" ", strip=True).lower()82            val_el = sub.find_next_sibling("p")83            val = val_el.get_text(" ", strip=True) if val_el else ""84            if "année de construction" in label and val.isdigit():85                payload["construction_year"] = int(val)86            elif "nombre total" in label and val:87                payload["total_units"] = val88            elif "nombre d" in label and val:89                payload["building_floors"] = val9091        # commodités + caractéristiques (immeuble, unité, accès et services)92        amenities: list[str] = []93        for p in soup.select("p.attribute-description"):94            label = " ".join(p.get_text(" ", strip=True).split())95            if label and label not in amenities:96                amenities.append(label)97        payload["amenities"] = amenities9899        # unités individuelles : numéro (· étage), disponibilité, prix100        unites: list[dict] = []101        vus: set[tuple] = set()102        for hdr in soup.find_all("p", class_="small-header"):103            if hdr.get_text(strip=True).lower() != "unité":104                continue105            row = hdr.find_parent("div", class_="row")106            if row is None:107                continue108            textes = [" ".join(p.get_text(" ", strip=True).split())109                      for p in row.find_all("p")]110            unite = {}111            for i, t in enumerate(textes):112                low = t.lower()113                if low == "unité" and i + 1 < len(textes):114                    unite["numero"] = textes[i + 1]115                elif low == "disponibilité" and i + 1 < len(textes):116                    unite["dispo"] = textes[i + 1]117                elif low == "à partir de" and i + 1 < len(textes):118                    unite["prix"] = textes[i + 1]119            cle = tuple(sorted(unite.items()))120            if unite.get("numero") and cle not in vus:   # modal = doublon121                vus.add(cle)122                unites.append(unite)123        payload["unites"] = unites124125        # téléphone spécifique à l'immeuble (panneau « S'informer »)126        panel = soup.select_one(".inquire-step-1")127        if panel:128            m = _PHONE_RE.search(panel.get_text(" ", strip=True))129            if m:130                payload["phone"] = m.group(0).replace(".", "-").replace(" ", "-")131132        return payload133134    def _enrich(self, lst: Listing) -> None:135        """Complète l'annonce avec la fiche immeuble (via cache self.detail)."""136        key = hashlib.sha1(137            f"{lst.price_label}|{lst.availability}|{'|'.join(lst.amenities)}"138            .encode("utf-8")).hexdigest()139140        def fetch_fn():141            if self._detail_fetches >= self.max_details:142                raise _DetailBudget()143            self._detail_fetches += 1144            return self._fetch_detail(lst.url)145146        try:147            payload = self.detail(lst.external_id, key, fetch_fn)148        except _DetailBudget:149            return150        if not payload:151            return152153        if payload.get("images"):154            lst.images = payload["images"]155        if payload.get("address"):156            lst.address = payload["address"]157        if payload.get("lat") is not None and payload.get("lng") is not None:158            lst.lat, lst.lng = payload["lat"], payload["lng"]159        if payload.get("amenities"):160            # la carte liste n'a que 3-4 icônes ; la fiche est complète161            lst.amenities = payload["amenities"]162        if payload.get("construction_year"):163            lst.details["construction_year"] = payload["construction_year"]164        if payload.get("phone"):165            lst.details.setdefault("contact", {})["phone"] = payload["phone"]166167        # animaux : items structurés « Chat accepté » / « Chien accepté »168        amen_txt = " | ".join(payload.get("amenities", [])).lower()169        chat = "chat accepté" in amen_txt or "chats acceptés" in amen_txt170        chien = "chien accepté" in amen_txt or "chiens acceptés" in amen_txt171        if "animaux acceptés" in amen_txt or (chat and chien):172            lst.pets = "oui"173        elif chat or chien:174            lst.pets = "conditions"     # une seule espèce acceptée175176        # description : infos immeuble + liste des unités disponibles177        desc_parts: list[str] = []178        infos = []179        if payload.get("construction_year"):180            infos.append(f"construit en {payload['construction_year']}")181        if payload.get("total_units"):182            infos.append(payload["total_units"])183        if infos:184            desc_parts.append("Immeuble " + ", ".join(infos))185        for u in payload.get("unites", []):186            ligne = f"Unité {u.get('numero', '?')}"187            if u.get("dispo"):188                ligne += f" — disponibilité : {u['dispo']}"189            if u.get("prix"):190                ligne += f" — à partir de {u['prix']}"191            desc_parts.append(ligne)192        if desc_parts:193            lst.description = " | ".join(desc_parts)[:600]194195        # disponibilité de secours : première unité listée196        if not lst.availability:197            for u in payload.get("unites", []):198                if u.get("dispo"):199                    lst.availability = u["dispo"]200                    break201202    # -- contrat ---------------------------------------------------------------203    def fetch(self) -> list[Listing]:204        # 1) Découvrir tous les filtres d'arrondissement sur la page principale205        html = self.get(LIST_URL).text206        boroughs = sorted(set(re.findall(r"\?boroughs=(\d+)", html)))207        pages = [html] + [208            self.get(f"{LIST_URL}/?boroughs={b}").text for b in boroughs209        ]210211        # 2) Extraire toutes les cartes (dédupliquées par id)212        listings: dict[str, Listing] = {}213        for page in pages:214            soup = BeautifulSoup(page, "html.parser")215            for card in soup.select('a[href^="/logement/"]'):216                m = re.match(r"/logement/(\d+)", card.get("href", ""))217                if not m:218                    continue219                ext_id = m.group(1)220                if ext_id in listings:221                    continue222                borough_el = card.select_one(".apartment-borough")223                sector = ""224                if borough_el:225                    # "Appartements · Beauport" -> "Beauport"226                    sector = borough_el.get_text(" ", strip=True).split("·")[-1].strip()227                addr_el = card.select_one(".apartment-address")228                infos_el = card.select_one(".apartment-infos")229                avail_el = card.select_one(".apartment-availability")230                infos = infos_el.get_text(" ", strip=True) if infos_el else ""231                amenities = [img.get("alt", "").strip()232                             for img in card.select("img[alt]")233                             if img.get("alt") and img.get("alt") not in234                             ("unit image", "")]235                address = addr_el.get_text(strip=True) if addr_el else ""236                listings[ext_id] = Listing(237                    source=self.source_id,238                    external_id=ext_id,239                    url=f"{BASE}/logement/{ext_id}",240                    title=address or f"Logement {ext_id}",241                    address=address,242                    sector=sector,243                    city=infer_city(sector),244                    unit_type=normalize_unit_type(infos),245                    price=parse_price(infos),246                    price_label=infos,247                    availability=avail_el.get_text(strip=True) if avail_el else "",248                    amenities=amenities,249                )250251        # 3) Fiche immeuble : adresse complète, lat/lng, commodités, unités…252        #    via self.detail() (seules les fiches nouvelles/modifiées sont253        #    réellement visitées, max self.max_details par synchronisation)254        self._detail_fetches = 0255        for lst in listings.values():256            try:257                self._enrich(lst)258            except Exception:259                continue260261        return list(listings.values())262