SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.2 KB · 168 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/immeubles_guillot.py : connecteur Les Immeubles Guillot5#   (immeublesguillot.com) — 3 immeubles / 61 unités à Québec :6#     - Ader (Beauport, 7 unités, 7½)7#     - Boul. Ste-Anne (Beauport, 28 unités, 1½/3½/4½)8#     - des Cyprès (Charlesbourg, 26 unités, 3½/4½/5½)9#   Site WordPress rendu serveur : une page par immeuble avec l'adresse,10#   les types offerts (« Types d'appartements dans cet immeuble »), un11#   tableau de commodités (colonnes Logement / Immeuble / Quartier) et des12#   galeries « Appartement modèle » par type. Aucun prix ni disponibilité13#   affichés -> price=None, availability vide (rien d'inventé).14#   Granularité = immeuble × type d'appartement (comme utile.py).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing23from .base import BaseConnector2425BASE = "https://immeublesguillot.com"26INDEX_URL = f"{BASE}/appartements-a-louer/"2728# images de contenu (uploads) ; icônes/logos exclus29_SKIP_IMG = re.compile(r"/icon-|logo|favicon|-\d{2,4}x\d{2,4}\.", re.I)30_TYPE_RE = re.compile(r"^\d\s*(?:1/2|½)$")31_ADDR_RE = re.compile(r"G\d[A-Z]\s?\d[A-Z]\d", re.I)   # code postal québécois323334def _type_slug(t: str) -> str:35    return re.sub(r"[^a-z0-9]+", "-", t.lower().replace("½", "1-2")).strip("-")363738class ImmeublesGuillotConnector(BaseConnector):39    source_id = "immeubles_guillot"40    request_delay = 0.641    max_buildings = 10       # garde-fou42    max_images = 124344    def fetch(self) -> list[Listing]:45        index = self.get(INDEX_URL).text46        slugs = list(dict.fromkeys(47            re.findall(r'href="https?://immeublesguillot\.com'48                       r'/appartements-a-louer/([a-z0-9\-]+)/"', index)))49        listings: list[Listing] = []50        for slug in slugs[: self.max_buildings]:51            try:52                listings.extend(self._building(slug))53            except Exception:54                continue55        return listings5657    # -- une page d'immeuble ----------------------------------------------------58    def _building(self, slug: str) -> list[Listing]:59        url = f"{BASE}/appartements-a-louer/{slug}/"60        html = self.get(url).text61        soup = BeautifulSoup(html, "html.parser")6263        # nom affiché : dernier élément du fil d'Ariane (« Beauport / Ader »)64        name = slug65        crumb = soup.select_one(".breadcrumbs, #breadcrumbs, .nectar-breadcrumbs")66        if crumb:67            parts = [t.strip() for t in crumb.get_text("»").split("»")68                     if t.strip()]69            if parts:70                name = parts[-1]71        sector = name.split("/")[0].strip() if "/" in name else ""7273        # adresse : titre contenant un code postal (« 3608 Boul. Ste-Anne,74        # Québec, G1E 3M1 »)75        address = ""76        for h in soup.find_all(["h1", "h2", "h3", "h4"]):77            t = re.sub(r"\s+", " ", h.get_text(" ", strip=True))78            if _ADDR_RE.search(t):79                address = t80                break8182        # types offerts : <p> qui suit « Types d'appartements dans cet immeuble »83        types: list[str] = []84        for h3 in soup.find_all(["h3", "h2"]):85            if "types d" not in h3.get_text(strip=True).lower():86                continue87            p = h3.find_next("p")88            if p:89                for line in p.get_text("\n", strip=True).split("\n"):90                    line = re.sub(r"\s+", " ", line).strip()91                    if _TYPE_RE.match(line) and line not in types:92                        types.append(line)93            break9495        # commodités : tableau tablepress (colonnes Logement et Immeuble ;96        # la colonne Quartier décrit le voisinage, pas le logement)97        amenities: list[str] = []98        for td in soup.select("table.tablepress td.column-2, "99                              "table.tablepress td.column-4"):100            for line in td.get_text("\n", strip=True).split("\n"):101                t = re.sub(r"\s+", " ", line).strip()102                if 3 <= len(t) <= 90 and t not in amenities:103                    amenities.append(t)104105        # description : premiers paragraphes après l'adresse106        desc_parts: list[str] = []107        for p in soup.find_all("p"):108            t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))109            if len(t) >= 80 and not p.find_parent(("footer", "nav")):110                desc_parts.append(t)111            if len(desc_parts) >= 2:112                break113        description = " ".join(desc_parts)[:600]114115        # galeries « Appartement modèle – X 1/2 » : photos par type ;116        # repli : toutes les photos de la page117        all_photos = [u for u in dict.fromkeys(re.findall(118            r'https://immeublesguillot\.com/wp-content/uploads/'119            r'[^"\s]+\.(?:jpe?g|webp|png)', html))120            if not _SKIP_IMG.search(u)]121        by_type: dict[str, list[str]] = {}122        for h2 in soup.find_all("h2"):123            head = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))124            m = re.match(r"Appartement modèle(?:\s*[–-]\s*(\d\s*1/2))?",125                         head, re.I)126            if not m:127                continue128            key = re.sub(r"\s+", " ", m.group(1)).strip() if m.group(1) else ""129            gallery = h2.find_parent("div", class_="wpb_wrapper")130            root = (gallery.find_parent("div", class_="vc_column-inner")131                    or gallery) if gallery else h2.parent132            imgs = []133            # la galerie lie chaque vignette (-600x375) à l'original plein format134            for a in root.select('a[href*="/wp-content/uploads/"]'):135                href = a.get("href") or ""136                if (href.startswith("http") and not _SKIP_IMG.search(href)137                        and re.search(r"\.(?:jpe?g|webp|png)$", href, re.I)138                        and href not in imgs):139                    imgs.append(href)140            for img in root.find_all("img"):141                src = img.get("src") or img.get("data-src") or ""142                if (src.startswith("http") and not _SKIP_IMG.search(src)143                        and src not in imgs):144                    imgs.append(src)145            if imgs:146                by_type[key] = imgs[: self.max_images]147148        out: list[Listing] = []149        for t in types:150            images = by_type.get(t) or by_type.get("") or all_photos151            out.append(Listing(152                source=self.source_id,153                external_id=f"{slug}-{_type_slug(t)}",154                url=url,155                title=f"Immeubles Guillot — {name} ({t})",156                address=address,157                sector=sector,158                city="Québec",159                unit_type=t,160                price=None,              # aucun prix affiché sur le site161                price_label="",162                availability="",         # aucune disponibilité affichée163                description=description,164                amenities=list(amenities),165                images=list(images)[: self.max_images],166            ))167        return out168