# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gparadis.py : connecteur GParadis (gparadis.com) # Secteurs : Montcalm, St-Sauveur, St-Roch, Limoilou, Vieux-Québec, # Ste-Foy, Duberger, Lévis. Les pages de secteurs (WordPress) décrivent # chaque immeuble (bloc wp-block-group + h5) avec adresses, disponibilités # (« 4 1/2 disponible MAINTENANT »), commodités et galeries de photos. # Une annonce par immeuble ayant des unités disponibles. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://gparadis.com" SECTOR_PAGES = { "montcalm": "Montcalm", "saint-sauveur": "Saint-Sauveur", "limoilou": "Limoilou", "levis": "Lévis", "vieux-quebec": "Vieux-Québec", "ste-foy": "Ste-Foy", "duberger": "Duberger", "saint-roch": "Saint-Roch", } BAD_IMG_RE = re.compile(r"logo|icon|favicon|cropped-|gparadis-69fb", re.I) # Contact « Pour louer » du pied de page (numéro dédié aux locations ; # le Vieux-Québec a son propre numéro) PHONE_VQ_RE = re.compile( r"Pour louer dans Vieux-Qu[ée]bec\s*:?\s*(\d{3})\s*-?\s*(\d{3})\s*-?\s*(\d{4})", re.I) PHONE_RE = re.compile( r"Pour louer\s*:?\s*(\d{3})\s*-?\s*(\d{3})\s*-?\s*(\d{4})", re.I) EMAIL_RE = re.compile(r"\b[\w.+-]+@gparadis\.com\b", re.I) def _slugify(s: str) -> str: s = strip_accents(s.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-")[:60] class GParadisConnector(BaseConnector): source_id = "gparadis" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for slug, sector in SECTOR_PAGES.items(): url = f"{BASE}/{slug}/" try: html = self.get(url).text except Exception: continue try: self._parse_sector(html, url, sector, listings) except Exception: continue return list(listings.values()) def _parse_sector(self, html: str, url: str, sector: str, listings: dict[str, Listing]) -> None: soup = BeautifulSoup(html, "html.parser") seen_groups: set[int] = set() # contact « Pour louer » du pied de page (structuré à la source) — # le Vieux-Québec a son numéro dédié page_text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) contact: dict = {} pm = (PHONE_VQ_RE.search(page_text) if "vieux" in strip_accents(sector.lower()) else None) or \ PHONE_RE.search(page_text) if pm: contact["phone"] = f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}" em = EMAIL_RE.search(page_text) if em: contact["email"] = em.group(0).lower() for h in soup.select("h5.wp-block-heading"): # le bloc « carte d'immeuble » = plus proche ancêtre wp-block-group # (le nom et l'adresse peuvent être dans des h5 distincts du bloc) group = h.find_parent("div", class_="wp-block-group") if group is None or id(group) in seen_groups: continue seen_groups.add(id(group)) headings = group.find_all("h5", class_="wp-block-heading") text = re.sub(r"\s+", " ", group.get_text(" ", strip=True)) # Immeubles complets : rien à annoncer if re.search(r"\bComplet\b", text) and \ not re.search(r"disponible", text, re.I): continue # Disponibilités : « 5 1/2 disponible MAINTENANT », « ... le 1er # juillet », « lofts disponibles le 1 er juillet » avail_parts = re.findall( r"((?:\d\s*1/2|\d\s*½|Studios?|Lofts?)" r"(?:\s*(?:,|et)\s*(?:\d\s*1/2|\d\s*½))*" r"\s*disponibles?\s*(?:MAINTENANT|le\s*1\s*er\s*[a-zû]+|" r"d[èe]s\s+maintenant)?)", text, re.I) if not avail_parts: continue # aucune unité disponible décrite availability = " ; ".join(p.strip() for p in avail_parts)[:200] # nom + adresse(s) : lignes des h5 du bloc (séparées par
) lines: list[str] = [] for hh in headings: for l in re.split(r"", hh.decode_contents()): l = re.sub(r"<[^>]+>", "", l) l = (l.replace("–", "–").replace("&", "&") .replace("’", "'").strip()) if l: lines.append(l) name = "" addresses = [] for l in lines: if re.match(r"^\d", l): addresses.append(l) elif not name: name = l address = addresses[0] if addresses else "" title = name or address if not title: continue # type d'unité : premier type disponible mentionné tm = re.search(r"(\d)\s*(?:1/2|½)", availability) if tm: unit_type = f"{tm.group(1)}½" elif re.search(r"lofts?", availability, re.I): unit_type = "Loft" elif re.search(r"studios?", availability, re.I): unit_type = "Studio" else: unit_type = "" # commodités amenities = [li.get_text(" ", strip=True) for li in group.select("li") if li.get_text(strip=True)][:15] # images de la galerie de l'immeuble images = [] for im in group.select("img"): u = im.get("data-orig-file") or im.get("src") or "" u = u.split("?")[0] if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \ and not BAD_IMG_RE.search(u): images.append(u) images = list(dict.fromkeys(images))[:25] if not images and len(soup.select("h5.wp-block-heading")) == 1: # page à immeuble unique : galerie au niveau de la page for im in soup.select("img"): u = (im.get("data-orig-file") or im.get("src") or "") u = u.split("?")[0] if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \ and not BAD_IMG_RE.search(u): images.append(u) images = list(dict.fromkeys(images))[:25] ext_id = f"{_slugify(sector)}-{_slugify(title if not addresses else f'{title}-{address}')}" if ext_id in listings: continue listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=address, sector=sector, city=infer_city(sector), unit_type=normalize_unit_type(unit_type), price=None, # GParadis n'affiche pas les prix price_label="", availability=availability, description=text[:600], amenities=amenities, details={"contact": dict(contact)} if contact else {}, images=images, )