SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.5 KB · 184 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/gparadis.py : connecteur GParadis (gparadis.com)5#   Secteurs : Montcalm, St-Sauveur, St-Roch, Limoilou, Vieux-Québec,6#   Ste-Foy, Duberger, Lévis. Les pages de secteurs (WordPress) décrivent7#   chaque immeuble (bloc wp-block-group + h5) avec adresses, disponibilités8#   (« 4 1/2 disponible MAINTENANT »), commodités et galeries de photos.9#   Une annonce par immeuble ayant des unités disponibles.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, infer_city, normalize_unit_type, strip_accents18from .base import BaseConnector1920BASE = "https://gparadis.com"21SECTOR_PAGES = {22    "montcalm": "Montcalm",23    "saint-sauveur": "Saint-Sauveur",24    "limoilou": "Limoilou",25    "levis": "Lévis",26    "vieux-quebec": "Vieux-Québec",27    "ste-foy": "Ste-Foy",28    "duberger": "Duberger",29    "saint-roch": "Saint-Roch",30}31BAD_IMG_RE = re.compile(r"logo|icon|favicon|cropped-|gparadis-69fb", re.I)3233# Contact « Pour louer » du pied de page (numéro dédié aux locations ;34# le Vieux-Québec a son propre numéro)35PHONE_VQ_RE = re.compile(36    r"Pour louer dans Vieux-Qu[ée]bec\s*:?\s*(\d{3})\s*-?\s*(\d{3})\s*-?\s*(\d{4})", re.I)37PHONE_RE = re.compile(38    r"Pour louer\s*:?\s*(\d{3})\s*-?\s*(\d{3})\s*-?\s*(\d{4})", re.I)39EMAIL_RE = re.compile(r"\b[\w.+-]+@gparadis\.com\b", re.I)404142def _slugify(s: str) -> str:43    s = strip_accents(s.lower())44    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")[:60]454647class GParadisConnector(BaseConnector):48    source_id = "gparadis"49    request_delay = 0.65051    def fetch(self) -> list[Listing]:52        listings: dict[str, Listing] = {}53        for slug, sector in SECTOR_PAGES.items():54            url = f"{BASE}/{slug}/"55            try:56                html = self.get(url).text57            except Exception:58                continue59            try:60                self._parse_sector(html, url, sector, listings)61            except Exception:62                continue63        return list(listings.values())6465    def _parse_sector(self, html: str, url: str, sector: str,66                      listings: dict[str, Listing]) -> None:67        soup = BeautifulSoup(html, "html.parser")68        seen_groups: set[int] = set()6970        # contact « Pour louer » du pied de page (structuré à la source) —71        # le Vieux-Québec a son numéro dédié72        page_text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))73        contact: dict = {}74        pm = (PHONE_VQ_RE.search(page_text)75              if "vieux" in strip_accents(sector.lower()) else None) or \76            PHONE_RE.search(page_text)77        if pm:78            contact["phone"] = f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}"79        em = EMAIL_RE.search(page_text)80        if em:81            contact["email"] = em.group(0).lower()8283        for h in soup.select("h5.wp-block-heading"):84            # le bloc « carte d'immeuble » = plus proche ancêtre wp-block-group85            # (le nom et l'adresse peuvent être dans des h5 distincts du bloc)86            group = h.find_parent("div", class_="wp-block-group")87            if group is None or id(group) in seen_groups:88                continue89            seen_groups.add(id(group))90            headings = group.find_all("h5", class_="wp-block-heading")9192            text = re.sub(r"\s+", " ", group.get_text(" ", strip=True))93            # Immeubles complets : rien à annoncer94            if re.search(r"\bComplet\b", text) and \95                    not re.search(r"disponible", text, re.I):96                continue97            # Disponibilités : « 5 1/2 disponible MAINTENANT », « ... le 1er98            # juillet », « lofts disponibles le 1 er juillet »99            avail_parts = re.findall(100                r"((?:\d\s*1/2|\d\s*½|Studios?|Lofts?)"101                r"(?:\s*(?:,|et)\s*(?:\d\s*1/2|\d\s*½))*"102                r"\s*disponibles?\s*(?:MAINTENANT|le\s*1\s*er\s*[a-zû]+|"103                r"d[èe]s\s+maintenant)?)", text, re.I)104            if not avail_parts:105                continue  # aucune unité disponible décrite106            availability = " ; ".join(p.strip() for p in avail_parts)[:200]107108            # nom + adresse(s) : lignes des h5 du bloc (séparées par <br>)109            lines: list[str] = []110            for hh in headings:111                for l in re.split(r"<br\s*/?>", hh.decode_contents()):112                    l = re.sub(r"<[^>]+>", "", l)113                    l = (l.replace("&#8211;", "–").replace("&amp;", "&")114                         .replace("&#8217;", "'").strip())115                    if l:116                        lines.append(l)117            name = ""118            addresses = []119            for l in lines:120                if re.match(r"^\d", l):121                    addresses.append(l)122                elif not name:123                    name = l124            address = addresses[0] if addresses else ""125            title = name or address126            if not title:127                continue128129            # type d'unité : premier type disponible mentionné130            tm = re.search(r"(\d)\s*(?:1/2|½)", availability)131            if tm:132                unit_type = f"{tm.group(1)}½"133            elif re.search(r"lofts?", availability, re.I):134                unit_type = "Loft"135            elif re.search(r"studios?", availability, re.I):136                unit_type = "Studio"137            else:138                unit_type = ""139140            # commodités141            amenities = [li.get_text(" ", strip=True)142                         for li in group.select("li")143                         if li.get_text(strip=True)][:15]144145            # images de la galerie de l'immeuble146            images = []147            for im in group.select("img"):148                u = im.get("data-orig-file") or im.get("src") or ""149                u = u.split("?")[0]150                if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \151                        and not BAD_IMG_RE.search(u):152                    images.append(u)153            images = list(dict.fromkeys(images))[:25]154            if not images and len(soup.select("h5.wp-block-heading")) == 1:155                # page à immeuble unique : galerie au niveau de la page156                for im in soup.select("img"):157                    u = (im.get("data-orig-file") or im.get("src") or "")158                    u = u.split("?")[0]159                    if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \160                            and not BAD_IMG_RE.search(u):161                        images.append(u)162                images = list(dict.fromkeys(images))[:25]163164            ext_id = f"{_slugify(sector)}-{_slugify(title if not addresses else f'{title}-{address}')}"165            if ext_id in listings:166                continue167            listings[ext_id] = Listing(168                source=self.source_id,169                external_id=ext_id,170                url=url,171                title=title,172                address=address,173                sector=sector,174                city=infer_city(sector),175                unit_type=normalize_unit_type(unit_type),176                price=None,          # GParadis n'affiche pas les prix177                price_label="",178                availability=availability,179                description=text[:600],180                amenities=amenities,181                details={"contact": dict(contact)} if contact else {},182                images=images,183            )184