SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.1 KB · 178 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/roussin.py : connecteur Immeubles Roussin (immeublesroussin.com)5#   Immeubles/projets résidentiels (L'Aromate, L'Allié, La Vigie, etc.) —6#   une annonce par type d'unité et par immeuble, prix « à partir de ».7#   Les pages commerciales (locaux, bureaux, entrepôts) sont exclues.8#   Commodités : paires icône+texte Elementor (Gym, Eau chaude, Animaux9#   acceptés (20lb et moins)…) ; contact : liens tel:/mailto: structurés.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, infer_city, normalize_unit_type, parse_price18from .base import BaseConnector1920BASE = "https://immeublesroussin.com"21INDEX = f"{BASE}/location-condo-appartement-quebec/"2223# Lignes de prix : « Loft à partir de 1200$ », « 3 ½ à partir de 1595$ »...24PRICE_LINE_RE = re.compile(25    r"((?:\d\s*(?:½|1/2))|Loft|Studio|Maison)[^<>$]{0,30}?"26    r"à\s+partir\s+de\s*([\d][\d\s ,]*)\s*\$",27    re.I,28)29IMG_RE = re.compile(30    r"https://immeublesroussin\.com/wp-content/uploads/[^\"'\\\s\)]+"31    r"\.(?:jpg|jpeg|png|webp)", re.I)32IMG_NOISE_RE = re.compile(r"favicon|logo|icon|cropped|header", re.I)33ADDR_RE = re.compile(34    r"\d{2,5}[,]?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|place)"35    r"\s[^<>{}\"]{3,60}", re.I)36BUILDING_RE = re.compile(r"/location-condo-appartement-quebec/([a-z0-9\-]+)/?$")373839class RoussinConnector(BaseConnector):40    source_id = "roussin"41    request_delay = 0.642    max_buildings = 30           # garde-fou4344    def fetch(self) -> list[Listing]:45        # 1) Découvrir les pages d'immeubles résidentiels46        html = self.get(INDEX).text47        slugs: list[str] = []48        for href in re.findall(r'href="([^"]+)"', html):49            m = BUILDING_RE.search(href.split("#")[0].split("?")[0])50            if m and m.group(1) not in slugs:51                slugs.append(m.group(1))5253        listings: list[Listing] = []54        for slug in slugs[: self.max_buildings]:55            url = f"{INDEX}{slug}/"56            try:57                page = self.get(url).text58            except Exception:59                continue60            try:61                listings.extend(self._parse_building(slug, url, page))62            except Exception:63                continue64        return listings6566    def _parse_building(self, slug: str, url: str, page: str) -> list[Listing]:67        soup = BeautifulSoup(page, "html.parser")6869        # Nom + secteur depuis le <title> :70        # « L'Aromate - Location de condos à Ste-Foy | Immeubles Roussin »71        title_tag = soup.find("title")72        raw_title = title_tag.get_text(strip=True) if title_tag else slug73        name = re.split(r"\s+[-–|]\s+", raw_title)[0].strip() or slug74        if re.search(r"louer|condos? neufs?", name, re.I):75            # Titre générique (« Condos à louer à Lévis ») : essayer la partie76            # après « : », sinon un nom dérivé du slug.77            m = re.search(r":\s*([^|]+)", raw_title)78            name = (m.group(1).strip() if m79                    else slug.replace("-", " ").strip().title())8081        # Description (og:description)82        desc = ""83        og = soup.find("meta", attrs={"property": "og:description"})84        if og and og.get("content"):85            desc = og["content"].strip()[:600]8687        # Adresse civique (en excluant le bureau administratif de Roussin)88        address = ""89        for cand in ADDR_RE.findall(page):90            if re.search(r"bureau", cand, re.I):91                continue92            address = re.sub(r"\s+", " ", cand).strip().rstrip(",")93            break9495        # Secteur : mots-clés dans le titre puis dans l'adresse96        sector = ""97        m = re.search(r"(Sainte-Foy|Ste-Foy|L[ée]vis|Beauport|Sillery|"98                      r"Cap-Rouge|St-Augustin)", f"{raw_title} {address}", re.I)99        if m:100            sector = m.group(1)101102        # Images (galerie de l'immeuble)103        images = [u for u in dict.fromkeys(IMG_RE.findall(page))104                  if not IMG_NOISE_RE.search(u)][:25]105106        # Commodités : paires icône (« iconeGym.jpg ») + widget texte Elementor107        amenities: list[str] = []108        for img in soup.select('img[src*="icone"]'):109            cont = img.find_parent(class_="elementor-container")110            if not cont:111                continue112            for te in cont.select(".elementor-widget-text-editor "113                                  ".elementor-widget-container"):114                t = te.get_text(" ", strip=True)115                if 2 < len(t) < 45 and t not in amenities:116                    amenities.append(t)117        amenities = amenities[:20]118119        # Contact du gestionnaire (liens tel:/mailto: structurés)120        details: dict = {}121        contact: dict = {}122        tel = soup.select_one('a[href^="tel:"]')123        if tel:124            tm = re.search(r"\(?([2-9]\d{2})\)?[\s.%20\-]*(\d{3})[\s.\-]?(\d{4})",125                           (tel.get("href") or "").replace("%20", ""))126            if tm:127                contact["phone"] = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}"128        mail = soup.select_one('a[href^="mailto:"]')129        if mail:130            contact["email"] = (mail.get("href") or "")[7:].split("?")[0]131        if contact:132            details["contact"] = contact133134        # Types d'unités avec prix « à partir de »135        text = soup.get_text(" ", strip=True)136        seen: set[str] = set()137        out: list[Listing] = []138        for type_raw, amount in PRICE_LINE_RE.findall(text):139            unit_type = normalize_unit_type(type_raw)140            if not unit_type or unit_type in seen:141                continue142            seen.add(unit_type)143            price_label = f"{type_raw.strip()} à partir de {amount.strip()}$"144            key = re.sub(r"[^a-z0-9]+", "-", unit_type.lower().replace("½", "5"))145            out.append(Listing(146                source=self.source_id,147                external_id=f"{slug}--{key}",148                url=url,149                title=f"{name}{unit_type}",150                address=address,151                sector=sector,152                city=infer_city(sector),153                unit_type=unit_type,154                price=parse_price(f"{amount}$"),155                price_label=price_label,156                description=desc,157                amenities=list(amenities),158                details=dict(details),159                images=images,160            ))161162        # Aucun prix affiché : une annonce par immeuble quand même163        if not out:164            out.append(Listing(165                source=self.source_id,166                external_id=slug,167                url=url,168                title=name,169                address=address,170                sector=sector,171                city=infer_city(sector),172                description=desc,173                amenities=list(amenities),174                details=dict(details),175                images=images,176            ))177        return out178