spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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