# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/groupe_jacques.py : connecteur Groupe Jacques (groupejacques.com) # Promoteur-gestionnaire de Victoriaville. CMS custom (arborescence /fr/, # blocs « grid-stack ») : les pages projet /fr/condos-locatifs/* et # /fr/appartements/* exposent des cartes par type d'unité (h2 « 4½ - # Disponible / Complet », superficie « 1024 à 1479 pi² », « À partir de # 1522 $ /mois ») — les types « Complet » sont sautés. La page # /fr/appartements/appartements-a-louer/ ajoute 4 immeubles (L'Éden, # Terrasses De Coursol, De Bigarré, De la Paix) avec adresse, description # et GPS (lien Google Maps). Le volet « résidences pour aînés » est EXCLU. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.groupejacques.com" LIST_URL = f"{BASE}/fr/appartements/appartements-a-louer/" # pages projet admissibles (les /fr/residences-pour-aines/ sont exclues) _PROJECT_PATH = re.compile(r"^/fr/(?:condos-locatifs|appartements)/([^/]+)/$") _TYPE_H2 = re.compile(r"^(\d)\s*½\s*(?:-\s*(.+))?$") _AREA_RE = re.compile(r"(\d[\d\s]{2,6})(?:\s*à\s*\d[\d\s]{2,6})?\s*pi\b", re.I) _PRICE_RE = re.compile(r"À partir de\s*[\d\s]+\s*\$\s*/?\s*mois", re.I) _GMAPS_LATLNG = re.compile(r"!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)") _GMAPS_AT = re.compile(r"@(-?\d+\.\d+),(-?\d+\.\d+)") def _slug(text: str) -> str: s = strip_accents(text.lower()).replace("½", "-1-2") return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-") class GroupeJacquesConnector(BaseConnector): source_id = "groupe_jacques" request_delay = 0.7 max_pages = 12 # garde-fou : nombre max de pages projet visitées def fetch(self) -> list[Listing]: listings: list[Listing] = [] # 1) page « Appartements à louer » : immeubles + liens des projets html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings.extend(self._parse_buildings(soup)) paths: dict[str, str] = {} # slug -> chemin /fr/…/ for a in soup.select("a[href]"): href = a["href"].replace(BASE, "") m = _PROJECT_PATH.match(href) if m and m.group(1) != "appartements-a-louer": paths.setdefault(m.group(1), href) # 2) pages projet : cartes par type d'unité for slug, path in list(paths.items())[: self.max_pages]: try: page = self.get(f"{BASE}{path}").text except Exception: continue listings.extend(self._parse_project(slug, page)) return listings # -- page projet : cartes h2 « 4½ - Disponible » ----------------------------- def _parse_project(self, slug: str, html: str) -> list[Listing]: soup = BeautifulSoup(html, "html.parser") url = "" canon = soup.select_one('link[rel="canonical"][href]') title_tag = soup.title.get_text() if soup.title else "" if re.search(r"a[îi]n[ée]s|retraite|manoir|seigneurie", title_tag, re.I): return [] # volet aînés : exclu project = title_tag.split(" - ")[0].strip() or slug url = (canon["href"] if canon else f"{BASE}/fr/condos-locatifs/{slug}/") text = re.sub(r"[\s\xa0]+", " ", soup.get_text(" ", strip=True)) # adresse du projet : « … est situé au 414, rue de Bigarré, Victoriaville. » address = "" m = re.search(r"[Ss]itu[ée]?s?\s+au\s+(\d[\w\s-]*,\s*(?:rue|boul\w*|" r"av\w*|chemin)[^,.]*(?:,\s*(?:à\s+)?[A-Z][\w-]+)?)", text) if m: address = m.group(1).replace(", à ", ", ").strip(" ,") # description : paragraphe « Le projet » description = "" for p in soup.find_all("p"): t = re.sub(r"[\s\xa0]+", " ", p.get_text(" ", strip=True)) if len(t) > 150: description = t[:900] break img = soup.select_one('img[src*="fichiersUpload"][width="100%"]') \ or soup.select_one('img[src*="fichiersUpload"]') images = [img["src"]] if img and img.get("src", "").startswith("http") else [] out: list[Listing] = [] for h2 in soup.find_all(["h2", "h3"]): ht = re.sub(r"[\s\xa0]+", " ", h2.get_text(" ", strip=True)) m = _TYPE_H2.match(ht) if not m: continue n, suffix = m.group(1), (m.group(2) or "").strip() if re.search(r"complet", suffix, re.I): continue # type complet : sauté availability = suffix if re.search(r"disponible", suffix, re.I) else "" variant = "" if availability else suffix # ex. « Flex » card = h2.find_parent(class_="grid-stack-item-content") or h2.parent ctext = re.sub(r"[\s\xa0]+", " ", card.get_text(" ", strip=True)) price_label = "" mp = _PRICE_RE.search(ctext) if mp: price_label = mp.group(0) area_sqft = None ma = _AREA_RE.search(ctext) if ma: area_sqft = float(ma.group(1).replace(" ", "")) # borne basse ext_id = _slug(f"{slug}-{n}-1-2" + (f"-{variant}" if variant else "")) if any(l.external_id == ext_id for l in out): continue out.append(Listing( source=self.source_id, external_id=ext_id, # slug projet + type (stable) url=url, title=f"{project} — {ht}", address=address, sector="", city="Victoriaville", # tout le parc locatif Groupe Jacques unit_type=normalize_unit_type(f"{n} ½"), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area_sqft, description=description, images=images, )) return out # -- page « Appartements à louer » : immeubles ------------------------------- def _parse_buildings(self, soup: BeautifulSoup) -> list[Listing]: out: list[Listing] = [] for block in soup.select(".grid-stack-item-content"): btext = re.sub(r"[\s\xa0]+", " ", block.get_text(" ", strip=True)) head = block.find("h4") if not head or "Victoriaville" not in btext or len(btext) < 80: continue if re.search(r"Nom complet|Groupe Jacques", btext): continue # formulaire / pied de page header = re.sub(r"[\s\xa0]+", " ", head.get_text(" ", strip=True)) if not re.search(r"rue|boulevard|avenue", header, re.I): continue # « L'Éden : 155, rue St-Georges, Victoriaville » -> nom + adresse if ":" in header: name, address = (x.strip() for x in header.split(":", 1)) else: name, address = header, header ext_id = _slug(address) if not ext_id or any(l.external_id == ext_id for l in out): continue # bandeau « 4½ disponible - Contactez-nous » dans le même bloc availability = "" h3 = block.find("h3") if h3 and re.search(r"disponible", h3.get_text(), re.I): availability = re.sub(r"[\s\xa0]+", " ", h3.get_text(" ", strip=True)) paras = [re.sub(r"[\s\xa0]+", " ", p.get_text(" ", strip=True)) for p in block.find_all("p")] description = "\n".join(p for p in paras if len(p) > 60)[:900] # type d'unité seulement si l'immeuble n'offre qu'UNE grandeur types = set(re.findall(r"(\d)\s*½", description)) unit_type = normalize_unit_type(f"{types.pop()} ½") \ if len(types) == 1 else "" lat = lng = None gm = block.select_one('a[href*="google."][href*="maps"]') if gm: mm = _GMAPS_LATLNG.search(gm["href"]) or _GMAPS_AT.search(gm["href"]) if mm: lat, lng = float(mm.group(1)), float(mm.group(2)) img = block.select_one('img[src*="fichiersUpload"][width="100%"]') images = [img["src"]] if img and img.get("src", "").startswith("http") else [] out.append(Listing( source=self.source_id, external_id=ext_id, # slug de l'adresse (stable) url=f"{LIST_URL}#{ext_id}", # pas de fiche individuelle title=header, address=address, sector="", city="Victoriaville", unit_type=unit_type, availability=availability, # texte source, si affiché description=description, images=images, lat=lat, lng=lng, )) return out