SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.7 KB · 188 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/deschenes_pepin.py : connecteur Groupe Deschênes Pépin5#   (groupedeschenespepin.com — Montérégie). Promoteur-gestionnaire : projets6#   locatifs « Le Blüm » à Longueuil et Sainte-Julie (50 ans et plus).7#   WordPress/Elementor rendu serveur : la page /louer/ liste les projets,8#   chaque fiche /projet/<slug>/ donne les types d'unités (« 3 ½, 4 ½ et9#   5 ½ »), l'adresse du bureau d'information, la description et la galerie.10#   Granularité : une annonce par projet × typologie (pas d'unités ni de prix11#   publiés sur le site).12#   2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS,13#   en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient14#   (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA15#   (même pattern que boreal_abitibi/citiluxx/cromwell, hébergeur identique).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import os20import re21import time22from urllib.parse import quote2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type27from .base import BaseConnector2829BASE = "https://groupedeschenespepin.com"30LIST_URL = f"{BASE}/louer/"3132PROJECT_LINK_RE = re.compile(r"https://groupedeschenespepin\.com/projet/([\w\-]+)/?")33UNIT_TOKEN_RE = re.compile(r"\b(\d)\s*(?:½|1/2)")34IMG_RE = re.compile(35    r"https://groupedeschenespepin\.com/wp-content/uploads/"36    r'[^"\s\\)]+\.(?:jpe?g|png|webp|avif)', re.I)37PHONE_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b")38EMAIL_RE = re.compile(r"[\w.\-]+@[\w.\-]+\.\w{2,}")39# adresse civique « 1825 rue du Caribou, Longueuil, Québec J4N 0C9 »40ADDR_RE = re.compile(41    r"\d{1,5}[,]?\s+(?:rue|avenue|av\.|boulevard|boul\.?|chemin|ch\.|route|"42    r"mont[ée]e|place)\s+[^,<>]{2,60},?\s*(?:Longueuil|Sainte-Julie|Ste-Julie)"43    r"[^<>]{0,40}?(?:[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I)4445# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…)46_SG_MARKER = "/.well-known/sgcaptcha/"474849def _sg_challenge(resp) -> bool:50    """True si la réponse est le challenge anti-bot SiteGround (202 + JS)."""51    if "challenge" in str(resp.headers.get("sg-captcha", "")).lower():52        return True53    return _SG_MARKER in (resp.text or "")[:600]545556def _city_of(slug: str, text: str) -> str:57    key = f"{slug} {text}".lower()58    if "sainte-julie" in key or "ste-julie" in key:59        return "Sainte-Julie"60    return "Longueuil"616263class DeschenesPepinConnector(BaseConnector):64    source_id = "deschenes_pepin"65    request_delay = 0.866    max_projects = 12          # garde-fou de crawl6768    def _enable_residential_proxy(self) -> bool:69        """Route toute la session via Oxylabs résidentiel CA (session collante)."""70        endpoint = os.environ.get("OXYLABS_PROXY")71        user = os.environ.get("OXYLABS_PROXY_USER")72        pwd = os.environ.get("OXYLABS_PROXY_PASS")73        if not (endpoint and user and pwd):74            return False75        puser = f"{user}-cc-CA-sessid-deschenes{int(time.time())}-sesstime-10"76        proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"77        self.session.proxies = {"http": proxy, "https": proxy}78        self.session.verify = False  # CA MITM du proxy Oxylabs (cf. _resilient)79        return True8081    def _get_html(self, url: str) -> str:82        """GET avec contournement du challenge sgcaptcha (proxy résidentiel)."""83        resp = self.get(url)84        if not _sg_challenge(resp):85            return resp.text86        if not self.session.proxies and self._enable_residential_proxy():87            resp = self.get(url)88            if not _sg_challenge(resp):89                return resp.text90        raise RuntimeError(f"challenge sgcaptcha non contourné — {url}")9192    def fetch(self) -> list[Listing]:93        listings: list[Listing] = []94        # la page de liste échoue franchement (fini le « 0 trouvé ok »95        # silencieux du challenge sgcaptcha — ingest journalise ok=0)96        html = self._get_html(LIST_URL)9798        # Slugs des fiches projet référencées depuis la section « Louer »99        slugs = list(dict.fromkeys(PROJECT_LINK_RE.findall(html)))100        for slug in slugs[: self.max_projects]:101            try:102                listings.extend(self._fetch_project(slug))103            except Exception:104                continue105        return listings106107    def _fetch_project(self, slug: str) -> list[Listing]:108        url = f"{BASE}/projet/{slug}/"109        html = self._get_html(url)110        soup = BeautifulSoup(html, "html.parser")111112        h1 = soup.find("h1")113        name = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ").title()114        text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))115116        # à vendre/maisons de ville : on ne garde que les projets locatifs117        # (la page /louer/ ne liste que ceux-là, ceinture et bretelles)118        if re.search(r"\bà vendre\b", name, re.I):119            return []120121        city = _city_of(slug, name)122123        # « Types d'unités 3 ½, 4 ½ et 5 ½ » (bloc d'entête de la fiche)124        m = re.search(r"Types?\s+d'unit[ée]s?\s*([\d\s½,/et]+)", text, re.I)125        unit_zone = m.group(1) if m else text[:2000]126        unit_types = list(dict.fromkeys(127            normalize_unit_type(f"{n}½") for n in UNIT_TOKEN_RE.findall(unit_zone)))128        if not unit_types:129            unit_types = [""]130131        # adresse du bureau d'information (l'immeuble lui-même)132        address = ""133        m = ADDR_RE.search(text)134        if m:135            address = m.group(0).strip().rstrip(",")136137        # description : paragraphe de présentation du projet138        description = ""139        for p in soup.find_all("p"):140            pt = p.get_text(" ", strip=True)141            if len(pt) > 120 and not pt.lower().startswith(("pour offrir", "je reconnais")):142                description = pt[:900]143                break144145        contact: dict = {}146        m = PHONE_RE.search(text)147        if m:148            contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"149        m = EMAIL_RE.search(text)150        if m:151            contact["email"] = m.group(0).lower()152153        images = [u for u in dict.fromkeys(IMG_RE.findall(html))154                  if not re.search(r"logo|icon|favicon|cropped-|plan|"155                                   r"-\d{2,3}x\d{2,3}\.", u, re.I)][:20]156157        amenities = []158        if re.search(r"50 ans et plus", text, re.I):159            amenities.append("Pour les 50 ans et plus")160        for kw, label in [161            (r"salle d'entra[îi]nement", "Salle d'entraînement"),162            (r"terrasse sur le toit", "Terrasse sur le toit"),163            (r"piscine", "Piscine"),164            (r"stationnement int[ée]rieur", "Stationnement intérieur"),165            (r"ascenseur", "Ascenseur"),166            (r"tout inclus", "Formule tout inclus"),167        ]:168            if re.search(kw, text, re.I):169                amenities.append(label)170171        out: list[Listing] = []172        for ut in unit_types:173            ext = f"{slug}-{ut.replace('½', '.5')}" if ut else slug174            out.append(Listing(175                source=self.source_id,176                external_id=ext,177                url=url,178                title=f"{name} — {ut}" if ut else name,179                address=address,180                city=city,181                unit_type=ut,182                description=description,183                amenities=list(dict.fromkeys(amenities)),184                details={"contact": dict(contact)} if contact else {},185                images=images,186            ))187        return out188