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/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("–", "–").replace("&", "&")114 .replace("’", "'").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