SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.1 KB · 254 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/plan_a.py : connecteur Plan A Immobilier (plan-a.ca)5#   ~3500 unités dans des complexes locatifs (Pierrefonds-Roxboro,6#   Pointe-aux-Trembles, Ahuntsic, Laval, Vaudreuil-Dorion, Sherbrooke —7#   les projets de Saguenay sont encore exclus). WordPress rendu serveur :8#   la page « appartements et condos à louer » expose des cartes-projets9#   a.c-card[data-listing=project] avec un JSON data-filters-item (ville,10#   budget min, types), le carrousel d'images et le prix « à partir de ».11#   Granularité = 1 annonce par complexe (prix plancher), fiches /project/12#   pour la description et la disponibilité.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import json18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, parse_price23from .base import BaseConnector2425BASE = "https://plan-a.ca"26LIST_URL = f"{BASE}/plan-a-appartements-et-condos-a-louer/"2728# Villes couvertes (valeurs du champ "city" des cartes) : Grand Montréal29# + Sherbrooke (expansion provinciale 2026-08). Saguenay reste exclu30# (District Jonquière : prévu au lot Est & Nord).31ALLOWED_CITIES = {32    "montreal": "Montréal",33    "laval": "Laval",34    "vaudreuil-dorion": "Vaudreuil-Dorion",   # Montérégie ouest / CMM35    "longueuil": "Longueuil",36    "sherbrooke": "Sherbrooke",37}3839# Secteur (quartier) déduit du slug pour les projets montréalais40_SECTOR_HINTS = [41    ("pierrefonds", "Pierrefonds-Roxboro"),42    ("roxboro", "Pierrefonds-Roxboro"),43    ("gouin", "Pierrefonds-Roxboro"),44    ("pointe-aux-trembles", "Pointe-aux-Trembles"),45    ("ahuntsic", "Ahuntsic"),46    ("cremazie", "Ahuntsic"),47    ("community-high-school", "Pierrefonds-Roxboro"),48]495051class PlanAConnector(BaseConnector):52    source_id = "plan_a"53    request_delay = 0.654    max_details = 20         # garde-fou de fetch des fiches projets5556    def fetch(self) -> list[Listing]:57        listings: list[Listing] = []58        try:59            html = self.get(LIST_URL).text60        except Exception:61            return listings62        soup = BeautifulSoup(html, "html.parser")6364        for card in soup.select('a.c-card[data-listing="project"]'):65            try:66                lst = self._parse_card(card)67            except Exception:68                continue69            if lst:70                listings.append(lst)7172        # Fiches projets (via cache self.detail : une vraie requête seulement73        # si la carte liste a changé) : description, disponibilité, quartier,74        # commodités des unités, services de l'immeuble, types, galerie.75        budget = {"n": 0}76        for lst in listings:77            key = hashlib.sha1("|".join(78                (lst.price_label, lst.availability, lst.unit_type,79                 lst.address)).encode()).hexdigest()8081            def _fetch(url=lst.url):82                if budget["n"] >= self.max_details:83                    raise RuntimeError("plafond de fiches atteint")84                budget["n"] += 185                return self._parse_detail(self.get(url).text)8687            try:88                payload = self.detail(lst.external_id, key, _fetch)89            except Exception:90                continue91            if payload.get("description"):92                lst.description = payload["description"]93            if payload.get("availability"):94                lst.availability = payload["availability"]95            if payload.get("unit_type"):96                lst.unit_type = payload["unit_type"]97            if payload.get("amenities"):98                lst.amenities = payload["amenities"]99            if payload.get("images"):100                imgs = lst.images + [u for u in payload["images"]101                                     if u not in lst.images]102                lst.images = imgs[:20]103            if not lst.sector and lst.city == "Montréal" and payload.get("sector"):104                lst.sector = payload["sector"]105106        return listings107108    # -- fiche /project/<slug>/ ------------------------------------------------109    @staticmethod110    def _parse_detail(detail: str) -> dict:111        payload: dict = {}112        dsoup = BeautifulSoup(detail, "html.parser")113114        og = dsoup.find("meta", attrs={"property": "og:description"}) or \115            dsoup.find("meta", attrs={"name": "description"})116        if og and og.get("content"):117            payload["description"] = og["content"].strip()[:600]118119        text = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True))120        m = re.search(r"Prochaine disponibilité\s+(.{3,40}?)\s+Unités", text)121        availability = m.group(1).strip() if m else ""122        m = re.search(r"Unités? disponibles?\s+(\d+\s*/\s*\d+)", text)123        if m:124            availability = f"{availability} ({m.group(1)} unités)".strip()125        if availability:126            payload["availability"] = availability[:120]127128        m = re.search(r"QUARTIER\s+([A-ZÀ-Ü][\w\-]+(?:[ \-][A-ZÀ-Ü][\w\-]+)*)",129                      text)130        if m:131            payload["sector"] = m.group(1).strip()132133        # Types d'unités disponibles (entête de fiche, plus complet que la carte)134        types = [re.sub(r"\s+", "", el.get_text(strip=True))135                 for el in dsoup.select(".c-info_unit")]136        types = [t for t in dict.fromkeys(types)137                 if re.fullmatch(r"\d½|Studio|Loft", t)]138        if types:139            payload["unit_type"] = ", ".join(types)140141        # Commodités des unités (section .c-feature) + services de l'immeuble142        # (liste à puces sous « SERVICES DANS L'IMMEUBLE ») + atouts (carrousel)143        amenities: list[str] = []144        for sec in dsoup.select("section"):145            over = sec.select_one(".c-overline")146            if over and "commodit" in over.get_text(strip=True).lower():147                for p in sec.select(".c-feature p"):148                    t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))149                    if t and t not in amenities:150                        amenities.append(t)151        for h4 in dsoup.find_all("h4"):152            if "services dans l" in h4.get_text(strip=True).lower():153                ul = h4.find_next("ul")154                if ul:155                    for li in ul.find_all("li"):156                        t = li.get_text(" ", strip=True).lstrip("• ").strip()157                        if t and t not in amenities:158                            amenities.append(t)159                break160        for h4 in dsoup.select(".c-cell_title h4"):161            t = re.sub(r"\s+", " ", h4.get_text(" ", strip=True))162            if t and t not in amenities:163                amenities.append(t)164        if amenities:165            payload["amenities"] = amenities[:30]166167        # Galerie : photos wp-content (jamais les icônes svg)168        imgs = []169        for img in dsoup.find_all("img", src=True):170            u = img["src"]171            if re.search(r"/wp-content/uploads/.*\.(?:jpe?g|png|webp)$", u, re.I) \172                    and u not in imgs:173                imgs.append(u)174        if imgs:175            payload["images"] = imgs[:20]176177        return payload178179    # -- parsing d'une carte-projet ---------------------------------------------180    def _parse_card(self, card) -> Listing | None:181        href = (card.get("href") or "").split("?")[0]182        m = re.search(r"/project/([a-z0-9\-]+)/?$", href)183        if not m:184            return None185        slug = m.group(1)186187        try:188            filters = json.loads(card.get("data-filters-item") or "{}")189        except (ValueError, TypeError):190            filters = {}191        city_key = (filters.get("city") or "").strip().lower()192        if city_key not in ALLOWED_CITIES:193            return None                     # Saguenay... exclus194        city = ALLOWED_CITIES[city_key]195196        name_el = card.select_one("h6")197        name = name_el.get_text(" ", strip=True) if name_el else slug198        overline = card.select_one(".c-card_overline")199        locality = overline.get_text(" ", strip=True) if overline else ""200201        subtitles = [s.get_text(" ", strip=True)202                     for s in card.select(".c-card_subtitle")]203        address = subtitles[0] if subtitles else ""204        if "Complet" in subtitles or any("complet" in s.lower() for s in subtitles):205            return None                     # aucun logement disponible206        price_label = next((s for s in subtitles if "$" in s), "")207        unit_types = [s for s in subtitles208                      if re.fullmatch(r"\d\s*½|\d\s*1/2|Studio|Loft", s)]209210        notice = card.select_one(".c-card_notice")211        availability = notice.get_text(" ", strip=True) if notice else ""212        if availability.lower() == "maintenant":213            availability = "Disponible maintenant"214215        # prix plancher : JSON budget-min sinon texte « À partir de … $ »216        price = None217        bmin = str(filters.get("budget-min") or "").strip()218        if bmin.isdigit():219            val = float(bmin)220            if 100 <= val <= 20000:221                price = val222        if price is None:223            price = parse_price(price_label)224225        images = [img.get("src") for img in card.select("img.c-card_image")226                  if img.get("src")]227        images = list(dict.fromkeys(images))[:20]228229        sector = ""230        if city == "Montréal":231            for key, sec in _SECTOR_HINTS:232                if key in slug:233                    sector = sec234                    break235        elif locality and locality != city:236            sector = locality237238        return Listing(239            source=self.source_id,240            external_id=slug,241            url=f"{BASE}/project/{slug}/",242            title=name,243            address=f"{address}, {locality}" if address else locality,244            sector=sector,245            city=city,246            unit_type=", ".join(dict.fromkeys(247                t.replace(" ", "").replace("1/2", "½") for t in unit_types)),248            price=price,249            price_label=price_label,250            availability=availability,251            amenities=[],252            images=images,253        )254