SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.3 KB · 207 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/groupe_jacques.py : connecteur Groupe Jacques (groupejacques.com)5#   Promoteur-gestionnaire de Victoriaville. CMS custom (arborescence /fr/,6#   blocs « grid-stack ») : les pages projet /fr/condos-locatifs/* et7#   /fr/appartements/* exposent des cartes par type d'unité (h2 « 4½ -8#   Disponible / Complet », superficie « 1024 à 1479 pi² », « À partir de9#   1522 $ /mois ») — les types « Complet » sont sautés. La page10#   /fr/appartements/appartements-a-louer/ ajoute 4 immeubles (L'Éden,11#   Terrasses De Coursol, De Bigarré, De la Paix) avec adresse, description12#   et GPS (lien Google Maps). Le volet « résidences pour aînés » est EXCLU.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price, strip_accents21from .base import BaseConnector2223BASE = "https://www.groupejacques.com"24LIST_URL = f"{BASE}/fr/appartements/appartements-a-louer/"2526# pages projet admissibles (les /fr/residences-pour-aines/ sont exclues)27_PROJECT_PATH = re.compile(r"^/fr/(?:condos-locatifs|appartements)/([^/]+)/$")28_TYPE_H2 = re.compile(r"^(\d)\s*½\s*(?:-\s*(.+))?$")29_AREA_RE = re.compile(r"(\d[\d\s]{2,6})(?:\s*à\s*\d[\d\s]{2,6})?\s*pi\b", re.I)30_PRICE_RE = re.compile(r"À partir de\s*[\d\s]+\s*\$\s*/?\s*mois", re.I)31_GMAPS_LATLNG = re.compile(r"!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)")32_GMAPS_AT = re.compile(r"@(-?\d+\.\d+),(-?\d+\.\d+)")333435def _slug(text: str) -> str:36    s = strip_accents(text.lower()).replace("½", "-1-2")37    return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-")383940class GroupeJacquesConnector(BaseConnector):41    source_id = "groupe_jacques"42    request_delay = 0.743    max_pages = 12      # garde-fou : nombre max de pages projet visitées4445    def fetch(self) -> list[Listing]:46        listings: list[Listing] = []4748        # 1) page « Appartements à louer » : immeubles + liens des projets49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")51        listings.extend(self._parse_buildings(soup))5253        paths: dict[str, str] = {}       # slug -> chemin /fr/…/54        for a in soup.select("a[href]"):55            href = a["href"].replace(BASE, "")56            m = _PROJECT_PATH.match(href)57            if m and m.group(1) != "appartements-a-louer":58                paths.setdefault(m.group(1), href)5960        # 2) pages projet : cartes par type d'unité61        for slug, path in list(paths.items())[: self.max_pages]:62            try:63                page = self.get(f"{BASE}{path}").text64            except Exception:65                continue66            listings.extend(self._parse_project(slug, page))6768        return listings6970    # -- page projet : cartes h2 « 4½ - Disponible » -----------------------------71    def _parse_project(self, slug: str, html: str) -> list[Listing]:72        soup = BeautifulSoup(html, "html.parser")73        url = ""74        canon = soup.select_one('link[rel="canonical"][href]')75        title_tag = soup.title.get_text() if soup.title else ""76        if re.search(r"a[îi]n[ée]s|retraite|manoir|seigneurie", title_tag, re.I):77            return []                                # volet aînés : exclu78        project = title_tag.split(" - ")[0].strip() or slug79        url = (canon["href"] if canon else80               f"{BASE}/fr/condos-locatifs/{slug}/")8182        text = re.sub(r"[\s\xa0]+", " ", soup.get_text(" ", strip=True))83        # adresse du projet : « … est situé au 414, rue de Bigarré, Victoriaville. »84        address = ""85        m = re.search(r"[Ss]itu[ée]?s?\s+au\s+(\d[\w\s-]*,\s*(?:rue|boul\w*|"86                      r"av\w*|chemin)[^,.]*(?:,\s*(?:à\s+)?[A-Z][\w-]+)?)", text)87        if m:88            address = m.group(1).replace(", à ", ", ").strip(" ,")89        # description : paragraphe « Le projet »90        description = ""91        for p in soup.find_all("p"):92            t = re.sub(r"[\s\xa0]+", " ", p.get_text(" ", strip=True))93            if len(t) > 150:94                description = t[:900]95                break96        img = soup.select_one('img[src*="fichiersUpload"][width="100%"]') \97            or soup.select_one('img[src*="fichiersUpload"]')98        images = [img["src"]] if img and img.get("src", "").startswith("http") else []99100        out: list[Listing] = []101        for h2 in soup.find_all(["h2", "h3"]):102            ht = re.sub(r"[\s\xa0]+", " ", h2.get_text(" ", strip=True))103            m = _TYPE_H2.match(ht)104            if not m:105                continue106            n, suffix = m.group(1), (m.group(2) or "").strip()107            if re.search(r"complet", suffix, re.I):108                continue                             # type complet : sauté109            availability = suffix if re.search(r"disponible", suffix, re.I) else ""110            variant = "" if availability else suffix  # ex. « Flex »111112            card = h2.find_parent(class_="grid-stack-item-content") or h2.parent113            ctext = re.sub(r"[\s\xa0]+", " ", card.get_text(" ", strip=True))114            price_label = ""115            mp = _PRICE_RE.search(ctext)116            if mp:117                price_label = mp.group(0)118            area_sqft = None119            ma = _AREA_RE.search(ctext)120            if ma:121                area_sqft = float(ma.group(1).replace(" ", ""))  # borne basse122123            ext_id = _slug(f"{slug}-{n}-1-2" + (f"-{variant}" if variant else ""))124            if any(l.external_id == ext_id for l in out):125                continue126            out.append(Listing(127                source=self.source_id,128                external_id=ext_id,                  # slug projet + type (stable)129                url=url,130                title=f"{project}{ht}",131                address=address,132                sector="",133                city="Victoriaville",   # tout le parc locatif Groupe Jacques134                unit_type=normalize_unit_type(f"{n} ½"),135                price=parse_price(price_label),136                price_label=price_label,137                availability=availability,138                area_sqft=area_sqft,139                description=description,140                images=images,141            ))142        return out143144    # -- page « Appartements à louer » : immeubles -------------------------------145    def _parse_buildings(self, soup: BeautifulSoup) -> list[Listing]:146        out: list[Listing] = []147        for block in soup.select(".grid-stack-item-content"):148            btext = re.sub(r"[\s\xa0]+", " ", block.get_text(" ", strip=True))149            head = block.find("h4")150            if not head or "Victoriaville" not in btext or len(btext) < 80:151                continue152            if re.search(r"Nom complet|Groupe Jacques", btext):153                continue                             # formulaire / pied de page154            header = re.sub(r"[\s\xa0]+", " ", head.get_text(" ", strip=True))155            if not re.search(r"rue|boulevard|avenue", header, re.I):156                continue157158            # « L'Éden : 155, rue St-Georges, Victoriaville » -> nom + adresse159            if ":" in header:160                name, address = (x.strip() for x in header.split(":", 1))161            else:162                name, address = header, header163            ext_id = _slug(address)164            if not ext_id or any(l.external_id == ext_id for l in out):165                continue166167            # bandeau « 4½ disponible - Contactez-nous » dans le même bloc168            availability = ""169            h3 = block.find("h3")170            if h3 and re.search(r"disponible", h3.get_text(), re.I):171                availability = re.sub(r"[\s\xa0]+", " ",172                                      h3.get_text(" ", strip=True))173174            paras = [re.sub(r"[\s\xa0]+", " ", p.get_text(" ", strip=True))175                     for p in block.find_all("p")]176            description = "\n".join(p for p in paras if len(p) > 60)[:900]177            # type d'unité seulement si l'immeuble n'offre qu'UNE grandeur178            types = set(re.findall(r"(\d)\s*½", description))179            unit_type = normalize_unit_type(f"{types.pop()} ½") \180                if len(types) == 1 else ""181182            lat = lng = None183            gm = block.select_one('a[href*="google."][href*="maps"]')184            if gm:185                mm = _GMAPS_LATLNG.search(gm["href"]) or _GMAPS_AT.search(gm["href"])186                if mm:187                    lat, lng = float(mm.group(1)), float(mm.group(2))188            img = block.select_one('img[src*="fichiersUpload"][width="100%"]')189            images = [img["src"]] if img and img.get("src", "").startswith("http") else []190191            out.append(Listing(192                source=self.source_id,193                external_id=ext_id,                  # slug de l'adresse (stable)194                url=f"{LIST_URL}#{ext_id}",          # pas de fiche individuelle195                title=header,196                address=address,197                sector="",198                city="Victoriaville",199                unit_type=unit_type,200                availability=availability,           # texte source, si affiché201                description=description,202                images=images,203                lat=lat,204                lng=lng,205            ))206        return out207