# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/deschenes_pepin.py : connecteur Groupe Deschênes Pépin # (groupedeschenespepin.com — Montérégie). Promoteur-gestionnaire : projets # locatifs « Le Blüm » à Longueuil et Sainte-Julie (50 ans et plus). # WordPress/Elementor rendu serveur : la page /louer/ liste les projets, # chaque fiche /projet// donne les types d'unités (« 3 ½, 4 ½ et # 5 ½ »), l'adresse du bureau d'information, la description et la galerie. # Granularité : une annonce par projet × typologie (pas d'unités ni de prix # publiés sur le site). # 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, # en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient # (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA # (même pattern que boreal_abitibi/citiluxx/cromwell, hébergeur identique). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time from urllib.parse import quote from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://groupedeschenespepin.com" LIST_URL = f"{BASE}/louer/" PROJECT_LINK_RE = re.compile(r"https://groupedeschenespepin\.com/projet/([\w\-]+)/?") UNIT_TOKEN_RE = re.compile(r"\b(\d)\s*(?:½|1/2)") IMG_RE = re.compile( r"https://groupedeschenespepin\.com/wp-content/uploads/" r'[^"\s\\)]+\.(?:jpe?g|png|webp|avif)', re.I) PHONE_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b") EMAIL_RE = re.compile(r"[\w.\-]+@[\w.\-]+\.\w{2,}") # adresse civique « 1825 rue du Caribou, Longueuil, Québec J4N 0C9 » ADDR_RE = re.compile( r"\d{1,5}[,]?\s+(?:rue|avenue|av\.|boulevard|boul\.?|chemin|ch\.|route|" r"mont[ée]e|place)\s+[^,<>]{2,60},?\s*(?:Longueuil|Sainte-Julie|Ste-Julie)" r"[^<>]{0,40}?(?:[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I) # page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) _SG_MARKER = "/.well-known/sgcaptcha/" def _sg_challenge(resp) -> bool: """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): return True return _SG_MARKER in (resp.text or "")[:600] def _city_of(slug: str, text: str) -> str: key = f"{slug} {text}".lower() if "sainte-julie" in key or "ste-julie" in key: return "Sainte-Julie" return "Longueuil" class DeschenesPepinConnector(BaseConnector): source_id = "deschenes_pepin" request_delay = 0.8 max_projects = 12 # garde-fou de crawl def _enable_residential_proxy(self) -> bool: """Route toute la session via Oxylabs résidentiel CA (session collante).""" endpoint = os.environ.get("OXYLABS_PROXY") user = os.environ.get("OXYLABS_PROXY_USER") pwd = os.environ.get("OXYLABS_PROXY_PASS") if not (endpoint and user and pwd): return False puser = f"{user}-cc-CA-sessid-deschenes{int(time.time())}-sesstime-10" proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" self.session.proxies = {"http": proxy, "https": proxy} self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient) return True def _get_html(self, url: str) -> str: """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" resp = self.get(url) if not _sg_challenge(resp): return resp.text if not self.session.proxies and self._enable_residential_proxy(): resp = self.get(url) if not _sg_challenge(resp): return resp.text raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") def fetch(self) -> list[Listing]: listings: list[Listing] = [] # la page de liste échoue franchement (fini le « 0 trouvé ok » # silencieux du challenge sgcaptcha — ingest journalise ok=0) html = self._get_html(LIST_URL) # Slugs des fiches projet référencées depuis la section « Louer » slugs = list(dict.fromkeys(PROJECT_LINK_RE.findall(html))) for slug in slugs[: self.max_projects]: try: listings.extend(self._fetch_project(slug)) except Exception: continue return listings def _fetch_project(self, slug: str) -> list[Listing]: url = f"{BASE}/projet/{slug}/" html = self._get_html(url) soup = BeautifulSoup(html, "html.parser") h1 = soup.find("h1") name = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ").title() text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) # à vendre/maisons de ville : on ne garde que les projets locatifs # (la page /louer/ ne liste que ceux-là, ceinture et bretelles) if re.search(r"\bà vendre\b", name, re.I): return [] city = _city_of(slug, name) # « Types d'unités 3 ½, 4 ½ et 5 ½ » (bloc d'entête de la fiche) m = re.search(r"Types?\s+d'unit[ée]s?\s*([\d\s½,/et]+)", text, re.I) unit_zone = m.group(1) if m else text[:2000] unit_types = list(dict.fromkeys( normalize_unit_type(f"{n}½") for n in UNIT_TOKEN_RE.findall(unit_zone))) if not unit_types: unit_types = [""] # adresse du bureau d'information (l'immeuble lui-même) address = "" m = ADDR_RE.search(text) if m: address = m.group(0).strip().rstrip(",") # description : paragraphe de présentation du projet description = "" for p in soup.find_all("p"): pt = p.get_text(" ", strip=True) if len(pt) > 120 and not pt.lower().startswith(("pour offrir", "je reconnais")): description = pt[:900] break contact: dict = {} m = PHONE_RE.search(text) if m: contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" m = EMAIL_RE.search(text) if m: contact["email"] = m.group(0).lower() images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|favicon|cropped-|plan|" r"-\d{2,3}x\d{2,3}\.", u, re.I)][:20] amenities = [] if re.search(r"50 ans et plus", text, re.I): amenities.append("Pour les 50 ans et plus") for kw, label in [ (r"salle d'entra[îi]nement", "Salle d'entraînement"), (r"terrasse sur le toit", "Terrasse sur le toit"), (r"piscine", "Piscine"), (r"stationnement int[ée]rieur", "Stationnement intérieur"), (r"ascenseur", "Ascenseur"), (r"tout inclus", "Formule tout inclus"), ]: if re.search(kw, text, re.I): amenities.append(label) out: list[Listing] = [] for ut in unit_types: ext = f"{slug}-{ut.replace('½', '.5')}" if ut else slug out.append(Listing( source=self.source_id, external_id=ext, url=url, title=f"{name} — {ut}" if ut else name, address=address, city=city, unit_type=ut, description=description, amenities=list(dict.fromkeys(amenities)), details={"contact": dict(contact)} if contact else {}, images=images, )) return out