SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.0 KB · 243 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/viridi.py : connecteur Le Viridi (condosleviridi.ca)5#   Immeuble de 89 unités dans l'Écoquartier Pointe-aux-Lièvres (Québec).6#   Le site présente 16 modèles d'unités (types A à O) avec prix7#   « à partir de » par catégorie (Studio/Loft/3½/4½/5½/6½, maison de ville).8#   Une annonce par modèle (pas de liste d'unités individuelles).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import hashlib13import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, normalize_unit_type, parse_price18from .base import BaseConnector1920BASE = "https://condosleviridi.ca"21LIST_URL = f"{BASE}/condos-a-louer-quebec/"22CONTACT_URL = f"{BASE}/nous-joindre/"23SECTOR = "Pointe-aux-Lièvres (Saint-Roch)"24CITY = "Québec"25FALLBACK_ADDRESS = "Écoquartier Pointe-aux-Lièvres, Québec"2627_SKIP_IMG = re.compile(r"logo|favicon|icon|affichez", re.I)282930class ViridiConnector(BaseConnector):31    source_id = "viridi"32    request_delay = 0.633    max_detail_requests = 60   # plafond de vraies requêtes « fiche modèle »3435    # ------------------------------------------------------------------36    def _site_info(self) -> tuple[str, dict]:37        """Adresse civique + contact depuis la page Nous joindre.3839        « Adresse du projet : 40, rue de la Pointe-aux-Lièvres, Québec,40        QC G1K 0J7 » + liens tel:/mailto: (structurés dans la page).41        """42        address, contact = "", {}43        try:44            html = self.get(CONTACT_URL).text45        except Exception:46            return address, contact47        m = re.search(48            r"(\d{1,5},?\s(?:rue|avenue|boulevard)[^<>&]{0,50}"49            r"Pointe-aux-Li[eè]vres,?)"50            r"(?:<[^>]*>\s*)*"51            r"(Qu[ée]bec[^<>]{0,30})?", html)52        if m:53            address = m.group(1).strip()54            if m.group(2):55                address += " " + m.group(2).strip()56        m = re.search(r'href="tel:([\d\-. ]{10,})"', html)57        if m:58            digits = re.sub(r"\D", "", m.group(1))[-10:]59            if len(digits) == 10:60                contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"61        m = re.search(r'href="mailto:([^"?]+)"', html)62        if m:63            contact["email"] = m.group(1).strip().lower()64        return address, contact6566    # ------------------------------------------------------------------67    def _fetch_model(self, url: str) -> dict:68        """Fiche d'un modèle : description, caractéristiques à icônes, photos."""69        dhtml = self.get(url).text70        dsoup = BeautifulSoup(dhtml, "html.parser")7172        # premier paragraphe descriptif (« Cette unité... », « Ce condo... »)73        description = ""74        el = dsoup.find(string=re.compile(r"Cette unité|Ce (?:condo|loft|modèle)"))75        if el:76            p = el.find_parent("p")77            if p:78                description = p.get_text(" ", strip=True)[:600]7980        # caractéristiques : chaque icône VC est suivie d'un bloc texte81        # (chambres, salles de bain, superficie, inclusions, animaux...)82        amenities: list[str] = []83        for icon in dsoup.select(".vc_icon_element"):84            txt = icon.find_next_sibling("div", class_="wpb_text_column")85            if not txt:86                continue87            t = re.sub(r"\s+", " ", txt.get_text(" ", strip=True))88            if t and len(t) < 120 and t not in amenities:89                amenities.append(t)9091        imgs = re.findall(92            rf'src="({re.escape(BASE)}/wp-content/uploads/[^"]+'93            rf'\.(?:jpg|jpeg|png|webp))"', dhtml, re.I)94        images = [u for u in dict.fromkeys(imgs)95                  if not _SKIP_IMG.search(u)][:10]96        return {"description": description, "amenities": amenities,97                "images": images}9899    # ------------------------------------------------------------------100    def _price_map(self, text: str) -> dict[tuple[str, bool], tuple[float | None, str]]:101        """Construit {(catégorie, maison_de_ville): (prix, libellé)}.102103        Le bloc de prix de la page liste, dans l'ordre :104        Loft 1285$, Studio 1300$, 3½ 1400$, 4½ 1995$,105        4½ MV 2450$, 5½ MV 2800$, 6½ MV 3300$.106        """107        out: dict[tuple[str, bool], tuple[float | None, str]] = {}108        # découpage en lignes propres109        lines = [l.strip() for l in text.split("\n") if l.strip()]110        current: str | None = None111        mv = False112        for line in lines:113            low = line.lower()114            if re.fullmatch(r"(loft|studio|\d\s*½|\d\s*1/2)", low):115                current = ("loft" if low == "loft" else116                           "studio" if low == "studio" else117                           re.search(r"\d", low).group(0))118                mv = False119            elif "maison de ville" in low and current:120                mv = True121            elif current and "à partir de" in low:122                price = parse_price(line)123                label = re.sub(r"\s+", " ", line)124                if mv:125                    label += " (maison de ville)"126                out[(current, mv)] = (price, label)127                current = None128                mv = False129        return out130131    # ------------------------------------------------------------------132    def fetch(self) -> list[Listing]:133        self._detail_requests = 0134        html = self.get(LIST_URL).text135        soup = BeautifulSoup(html, "html.parser")136        prices = self._price_map(soup.get_text("\n", strip=True))137        address, contact = self._site_info()138        if not address:139            address = FALLBACK_ADDRESS140141        # Cartes de modèles : image plan + titre h3 + bouton "Plus d'infos"142        cards: list[tuple[str, str, str]] = []   # (titre, url, img)143        for a in soup.select('a[title="Lien vers le modèle"][href]'):144            href = a["href"].strip()145            if href.startswith("http:"):146                href = "https:" + href[5:]147            wrapper = a.find_parent("div", class_="wpb_wrapper")148            title = img = ""149            if wrapper:150                h3 = wrapper.select_one("h3")151                if h3:152                    title = h3.get_text(" ", strip=True)153                im = wrapper.select_one("img[src]")154                if im:155                    img = im["src"]156            if title and (title, href, img) not in cards:157                cards.append((title, href, img))158159        listings: list[Listing] = []160        for title, url, thumb in cards:161            if not re.search(r"\(type\s", title, re.I):162                continue          # carte non-modèle (ex. bouton de contact)163            try:164                mv = "maison de ville" in title.lower()165                cat = None166                if re.search(r"studio", title, re.I):167                    cat = "studio"168                elif re.search(r"loft", title, re.I):169                    cat = "loft"170                else:171                    d = re.search(r"(\d)\s*½", title)172                    if d:173                        cat = d.group(1)174                price, price_label = prices.get((cat, mv), (None, ""))175                if price is None and cat and not mv:176                    # tolérance si la carte MV/condo ne matche pas exactement177                    price, price_label = prices.get((cat, True), (None, ""))178179                unit_type = normalize_unit_type(title)180                if cat == "studio":181                    unit_type = "Studio"182                elif cat == "loft":183                    unit_type = "Loft"184185                # Fiche du modèle (description, caractéristiques à icônes,186                # photos) via le cache détail : 1 vraie requête par modèle187                # et par changement de carte liste.188                ext_id = url.rstrip("/").split("/")[-1]189                key = hashlib.sha1(190                    f"{title}|{url}|{thumb}|{price_label}".encode("utf-8")191                ).hexdigest()192193                def _fetch(url=url) -> dict:194                    if self._detail_requests >= self.max_detail_requests:195                        return {}196                    self._detail_requests += 1197                    return self._fetch_model(url)198199                try:200                    payload = self.detail(ext_id, key, _fetch) or {}201                except Exception:202                    payload = {}203                description = payload.get("description", "")204                amenities = list(payload.get("amenities") or [])205                images = list(payload.get("images") or [])206207                # « Animaux de compagnie acceptés » : caractéristique à icône208                # explicite de la fiche -> pets structuré209                pets = None210                if any(re.search(r"animaux de compagnie accept",211                                 a, re.I) for a in amenities):212                    pets = "oui"213214                if thumb and thumb not in images and not _SKIP_IMG.search(thumb):215                    # version pleine grandeur de la vignette216                    full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$",217                                  r"\1", thumb)218                    images.insert(0, full)219                images = list(dict.fromkeys(images))220221                listings.append(Listing(222                    source=self.source_id,223                    external_id=ext_id,224                    url=url,225                    title=f"Le Viridi — {title}",226                    address=address,227                    sector=SECTOR,228                    city=CITY,229                    unit_type=unit_type,230                    price=price,231                    price_label=price_label,232                    availability="",233                    pets=pets,234                    description=description,235                    amenities=amenities,236                    details={"contact": dict(contact)} if contact else {},237                    images=images,238                ))239            except Exception:240                continue241242        return listings243