SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.5 KB · 181 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/picard.py : connecteur Picard Immobilier (picardimmobilier.net)5#   La page /recherche/ liste tous les logements disponibles (rendu serveur),6#   cartes .property-box-2 avec lien /logement/<secteur>/ref=<id>/.7#   Fiches détaillées : photos S3, disponibilité, description, commodités.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import hashlib12import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price17from .base import BaseConnector1819BASE = "https://picardimmobilier.net"20SEARCH_URL = f"{BASE}/recherche/"2122SECTOR_LABELS = {23    "charny": "Charny",24    "les-saules": "Les Saules",25    "sainte-foy": "Sainte-Foy",26    "beauport": "Beauport",27    "limoilou": "Limoilou",28    "loretteville": "Loretteville",29    "vieux-quebec": "Vieux-Québec",30    "montcalm": "Montcalm",31}32IMG_RE = re.compile(r"https://picardimmobilier\.s3\.amazonaws\.com/media/"33                    r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)34LATLNG_RE = re.compile(r'data-lat="(-?[\d.]+)"\s+data-lng="(-?[\d.]+)"')353637class _DetailBudget(Exception):38    """Plafond de requêtes de fiches atteint pour cette synchronisation."""394041class PicardConnector(BaseConnector):42    source_id = "picard"43    request_delay = 0.644    max_details = 80        # garde-fou de fetch des fiches4546    def fetch(self) -> list[Listing]:47        listings: dict[str, Listing] = {}48        try:49            html = self.get(SEARCH_URL).text50        except Exception:51            return []52        soup = BeautifulSoup(html, "html.parser")5354        for card in soup.select("div.property-box-2"):55            try:56                a = card.select_one('a[href*="ref="]')57                if not a:58                    continue59                m = re.search(r"/logement/([a-z\-]+)/ref=(\d+)/", a.get("href", ""))60                if not m:61                    continue62                sector_slug, ext_id = m.group(1), m.group(2)63                if ext_id in listings:64                    continue65                sector = SECTOR_LABELS.get(sector_slug,66                                           sector_slug.replace("-", " ").title())67                title_el = card.select_one("h3.title a")68                title = title_el.get_text(" ", strip=True) if title_el else ""69                # Exclure commercial / stationnement / rangement70                if re.search(r"Commercial|Stationnement|Rangement|Parking",71                             title, re.I):72                    continue73                loc_el = card.select_one("h5.location a")74                address = loc_el.get_text(" ", strip=True) if loc_el else ""75                price_el = card.select_one(".price-box")76                price_label = (re.sub(r"\s+", " ", price_el.get_text(" ", strip=True))77                               if price_el else "")78                # ex. "$1055.00 Par mois" -> normaliser pour parse_price79                pm = re.search(r"\$\s*([\d\s,\.]+)", price_label)80                price = None81                if pm:82                    try:83                        price = float(pm.group(1).replace(",", "").replace(" ", ""))84                        if not (100 <= price <= 20000):85                            price = None86                    except ValueError:87                        price = None88                unit_type = ""89                for li in card.select("ul.facilities-list li"):90                    t = li.get_text(" ", strip=True)91                    if "Chambre" in t:92                        unit_type = t.replace("Chambres", "").strip()93                        break94                img = card.select_one(".property-photo img")95                images = [img["src"]] if img and img.get("src") else []96                listings[ext_id] = Listing(97                    source=self.source_id,98                    external_id=ext_id,99                    url=f"{BASE}/logement/{sector_slug}/ref={ext_id}/",100                    title=title or (address or f"Logement {ext_id}"),101                    address=address,102                    sector=sector,103                    city=infer_city(sector),104                    unit_type=normalize_unit_type(unit_type),105                    price=price,106                    price_label=price_label,107                    images=images,108                )109            except Exception:110                continue111112        # Fiches détaillées (via cache self.detail : une vraie requête113        # seulement si la carte liste a changé) : photos, disponibilité,114        # adresse complète, description, commodités, lat/lng.115        budget = {"n": 0}116        for lst in listings.values():117            key = hashlib.sha1("|".join(118                (lst.title, lst.price_label, lst.address, lst.unit_type,119                 lst.images[0] if lst.images else "")).encode()).hexdigest()120121            def _fetch(url=lst.url):122                if budget["n"] >= self.max_details:123                    raise _DetailBudget(url)124                budget["n"] += 1125                return self._parse_detail(self.get(url).text)126127            try:128                payload = self.detail(lst.external_id, key, _fetch)129            except Exception:130                continue131            if payload.get("images"):132                lst.images = payload["images"][:25]133            if payload.get("availability"):134                lst.availability = payload["availability"]135            if payload.get("address"):136                lst.address = payload["address"]137            if payload.get("description"):138                lst.description = payload["description"]139            if payload.get("amenities"):140                lst.amenities = payload["amenities"]141            if payload.get("lat") is not None:142                lst.lat, lst.lng = payload["lat"], payload["lng"]143144        return list(listings.values())145146    # -- fiche /logement/<secteur>/ref=<id>/ ----------------------------------147    @staticmethod148    def _parse_detail(detail: str) -> dict:149        payload: dict = {}150        imgs = [u for u in dict.fromkeys(IMG_RE.findall(detail))151                if not re.search(r"logo|icon|favicon|static", u, re.I)]152        if imgs:153            payload["images"] = imgs154        dsoup = BeautifulSoup(detail, "html.parser")155        text = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True))156        # Adresse complète + disponibilité en tête de fiche157        am = re.search(r"(Disponible[^#]{0,40}?)\s*#?\s*Référence", text)158        if am:159            payload["availability"] = am.group(1).strip()160        adm = re.search(161            r"par mois\s+(.{5,90}?)\s+(?:Disponible|#\s*Référence)", text)162        if adm:163            payload["address"] = adm.group(1).strip()164        dm = re.search(r"Description\s+(.{10,600}?)\s+Caractéristiques", text)165        if dm:166            payload["description"] = dm.group(1).strip()167        amenities = [re.sub(r"\s+", " ", d.get_text(" ", strip=True))168                     for d in dsoup.select("div.col-6.text-dark")169                     if d.get_text(strip=True)][:15]170        if amenities:171            payload["amenities"] = amenities172        # Coordonnées : carte Google `#map-custom` (attributs data-lat/lng)173        mm = LATLNG_RE.search(detail)174        if mm:175            try:176                payload["lat"] = float(mm.group(1))177                payload["lng"] = float(mm.group(2))178            except ValueError:179                pass180        return payload181