SPB Git

spb/lou-ka Public

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

HTML 99.7%
12.9 KB · 312 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/groupe_dallaire.py : connecteur Groupe Dallaire — Faubourg du5#   Moulin (faubourgdumoulin.ca). Complexes Alizé, Noroît (I-II), Sirocco,6#   Sonora, Zéphyr (I-II) — rue Lionel-Audet, Québec (Beauport).7#   La page /disponibilite charge les unités via JetEngine (admin-ajax.php) :8#   on rejoue l'appel `get_listing` (lazy-load) puis la pagination9#   `jet_smart_filters` pour récupérer toutes les unités disponibles10#   (numéro, étage, prix, date de disponibilité, plan). Les fiches d'unité11#   (via cache BD) ajoutent type d'unité, superficie nette (« Pieds carrés »)12#   et inclusions réelles (« Incluant »).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import html as htmllib18import json19import re20import time2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price25from .base import BaseConnector2627BASE = "https://faubourgdumoulin.ca"28AJAX = f"{BASE}/wp-admin/admin-ajax.php"29DISPO_URL = f"{BASE}/disponibilite/"30SECTOR = "Beauport"31CITY = "Québec"32ADDRESS = "rue Lionel-Audet, Québec (Faubourg du Moulin)"3334COMPLEXES = {35    "alize": "Alizé", "alize-2": "Alizé (Phase II)",36    "noroit": "Noroît", "noroit-2": "Noroît II",37    "sirocco": "Sirocco", "sonora": "Sonora",38    "zephyr": "Zéphyr", "zephyr-2": "Zéphyr II",39}4041# paramètres du widget « dispo » de la page /disponibilite42PAGE_SETTINGS = {43    "page_settings[post_id]": "105",44    "page_settings[queried_id]": "105|WP_Post",45    "page_settings[element_id]": "cb310a3",46    "page_settings[page]": "1",47    "listing_type": "elementor",48    "isEditMode": "false",49}505152def _flatten(prefix: str, obj, out: dict) -> None:53    """Aplati un dict/list en champs de formulaire PHP (a[b][0]=x)."""54    if isinstance(obj, dict):55        for k, v in obj.items():56            _flatten(f"{prefix}[{k}]", v, out)57    elif isinstance(obj, list):58        for i, v in enumerate(obj):59            _flatten(f"{prefix}[{i}]", v, out)60    elif isinstance(obj, bool):61        out[prefix] = "true" if obj else "false"62    else:63        out[prefix] = "" if obj is None else str(obj)646566class GroupeDallaireConnector(BaseConnector):67    source_id = "groupe_dallaire"68    request_delay = 0.669    max_pages = 25            # garde-fou pagination70    max_details = 150         # garde-fou fiches d'unité (vraies requêtes)7172    # -- POST throttlé ---------------------------------------------------73    def _post(self, url: str, data: dict):74        wait = self.request_delay - (time.time() - self._last_request)75        if wait > 0:76            time.sleep(wait)77        resp = self.session.post(url, data=data, timeout=self.timeout)78        self._last_request = time.time()79        resp.raise_for_status()80        return resp8182    # ---------------------------------------------------------------------83    def fetch(self) -> list[Listing]:84        # 1) Premier appel lazy-load : items page 1 + query/widget_settings85        first = self._post(AJAX, {86            "action": "jet_engine_ajax",87            "handler": "get_listing",88            **PAGE_SETTINGS,89        }).json()90        html1 = (first.get("data") or {}).get("html", "")9192        nav = None93        m = re.search(r'data-nav="([^"]*)"', html1)94        if m:95            try:96                nav = json.loads(htmllib.unescape(m.group(1)))97            except Exception:98                nav = None99100        listings: dict[str, Listing] = {}101        for lst in self._parse_items(html1):102            listings.setdefault(lst.external_id, lst)103104        # 2) Pagination via jet_smart_filters (si le data-nav est disponible)105        if nav and nav.get("query") and nav.get("widget_settings"):106            page = 2107            while page <= self.max_pages:108                data = {109                    "action": "jet_smart_filters",110                    "provider": "jet-engine/dispo",111                    "paged": str(page),112                }113                _flatten("defaults", nav["query"], data)114                _flatten("settings", nav["widget_settings"], data)115                try:116                    resp = self._post(AJAX, data).json()117                except Exception:118                    break119                content = resp.get("content") or ""120                new = 0121                for lst in self._parse_items(content):122                    if lst.external_id not in listings:123                        listings[lst.external_id] = lst124                        new += 1125                pag = resp.get("pagination") or {}126                max_pages = int(pag.get("max_num_pages") or 0)127                if new == 0 or (max_pages and page >= max_pages):128                    break129                page += 1130131        # 3) Fiche de l'unité (cache BD) : type d'unité (« 3½ TYPE D'UNITÉ »),132        #    superficie nette (« Pieds carrés »), inclusions réelles133        #    (« Incluant ») et plans (unité + niveau)134        self._fetched = 0135        for lst in listings.values():136            card_key = hashlib.sha1(137                f"{lst.title}|{lst.price_label}|{lst.availability}"138                .encode("utf-8")).hexdigest()139            try:140                payload = self.detail(lst.external_id, card_key,141                                      lambda u=lst.url: self._fetch_detail(u))142            except Exception:143                continue144            self._apply_detail(lst, payload)145146        return list(listings.values())147148    # ---------------------------------------------------------------------149    def _fetch_detail(self, url: str) -> dict:150        """Fiche d'unité : type, superficie nette, inclusions, plans.151        Retour JSON-sérialisable (cache BD)."""152        if self._fetched >= self.max_details:153            raise RuntimeError("budget de fiches d'unité atteint")154        self._fetched += 1155        dhtml = self.get(url).text156        out: dict = {}157158        dtext = re.sub(r"<script.*?</script>", "", dhtml, flags=re.S)159        dtext = re.sub(r"<style.*?</style>", "", dtext, flags=re.S)160        dtext = re.sub(r"<[^>]+>", "\n", dtext)161        tm = re.search(r"(\d\s*½\s*\+?|Studio)\s*\n+\s*TYPE D['’]UNITÉ", dtext)162        if tm:163            out["unit_type"] = tm.group(1)164        sm = re.search(r"(\d{3,4})\s*\n+\s*Pieds carrés", dtext)165        if sm:166            out["area_sqft"] = float(sm.group(1))   # champ « Pieds carrés »167168        # Inclusions réelles (« Incluant » -> répéteur JetEngine)169        dsoup = BeautifulSoup(dhtml, "html.parser")170        amen = [sp.get_text(" ", strip=True)171                for sp in dsoup.select(".jet-listing-dynamic-repeater__item span")]172        out["amenities"] = [a for a in dict.fromkeys(amen) if a][:15]173174        # Plans : plan de l'unité (jpg) + plan du niveau (NIVEAU_x.png)175        plans = []176        for u in dict.fromkeys(re.findall(177                r"https://faubourgdumoulin\.ca/wp-content/uploads/"178                r"[^\"'\s\\]+\.(?:jpe?g|webp|png)", dhtml, re.I)):179            if re.search(r"logo|_rgb_|descripteur|favicon|cropped-|dalcon|"180                         r"alpha-|-\d+x\d+\.", u, re.I):181                continue182            if u.lower().endswith(".png") and "niveau" not in u.lower():183                continue184            plans.append(u)185        out["images"] = plans[:10]186        return out187188    def _apply_detail(self, lst: Listing, d: dict) -> None:189        """Reporte le payload (frais/cache) sur l'annonce."""190        if not d:191            return192        if d.get("unit_type") and not lst.unit_type:193            lst.unit_type = normalize_unit_type(d["unit_type"])194            if lst.unit_type and "½" not in lst.title \195                    and "Studio" not in lst.title:196                lst.title += f" ({lst.unit_type})"197        if d.get("area_sqft"):198            lst.area_sqft = d["area_sqft"]199        if d.get("amenities"):200            # inclusions réelles de la fiche (remplacent le repli générique)201            extra = [a for a in lst.amenities if a == "En promotion"]202            lst.amenities = d["amenities"] + extra203        if d.get("images"):204            lst.images = list(dict.fromkeys(lst.images + d["images"]))[:15]205206    # ---------------------------------------------------------------------207    def _parse_items(self, content: str) -> list[Listing]:208        out: list[Listing] = []209        if not content:210            return out211        soup = BeautifulSoup(content, "html.parser")212        for item in soup.select(".jet-listing-grid__item[data-post-id]"):213            try:214                a = item.select_one('a[href*="/unites/"]')215                if not a:216                    continue217                url = a["href"]218                slug = url.rstrip("/").split("/")[-1]219220                for st in item.select("style"):221                    st.decompose()222                text = item.get_text(" ", strip=True)223224                # Complexe depuis le slug (ex. sonora-101, noroit-2-151)225                cslug = re.match(r"([a-z]+(?:-2)?)-\d+", slug)226                complexe = COMPLEXES.get(cslug.group(1) if cslug else "", "")227                if not complexe and cslug:228                    complexe = cslug.group(1).replace("-", " ").title()229230                num_m = re.search(r"Unité\s+(\w+)\s*-?", text)231                unit_no = num_m.group(1) if num_m else slug.split("-")[-1]232                floor_m = re.search(r"Étage\s+(\d+)", text)233234                price = None235                price_label = ""236                pm = re.search(r"(\d{3,5})\s*\$\s*(/mois\*?)", text)237                if pm:238                    price = parse_price(pm.group(1) + "$")239                    price_label = (pm.group(1) + "$" + pm.group(2))240                    if "*" in pm.group(2):241                        price_label += (" (chauffé, éclairé, climatisé, "242                                        "stationnement int.)")243244                avail_m = re.search(r"Disponibilité\s*:\s*([^|]+?)(?:Unité|$)",245                                    text)246                availability = (f"Disponibilité : {avail_m.group(1).strip()}"247                                if avail_m else "Disponible")248249                promo = "En promotion" in text250251                # Images : logo du complexe exclu, plan de l'unité conservé252                images: list[str] = []253                unit_type = ""254                for img in item.select("img[src]"):255                    src = img["src"]256                    if not src.startswith("http"):257                        continue258                    if re.search(r"logo|_rgb_|descripteur|\.svg$", src, re.I):259                        continue260                    full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$",261                                  r"\1", src)262                    images.append(full)263                    # type d'unité encodé dans le nom du plan264                    tm = (re.search(r"pieces[-_](\d)\.5", src, re.I)265                          or re.search(r"_(\d)\.5\b", src))266                    if tm and not unit_type:267                        unit_type = normalize_unit_type(f"{tm.group(1)} 1/2")268                images = list(dict.fromkeys(images))269270                desc_parts = [f"Complexe {complexe}" if complexe else ""]271                if floor_m:272                    desc_parts.append(f"Étage {floor_m.group(1)}")273                if promo:274                    desc_parts.append("En promotion")275                desc_parts = [d for d in desc_parts if d]276277                # repli générique (remplacé par la liste « Incluant » réelle278                # de la fiche d'unité quand elle est disponible)279                amenities = ["Eau chaude incluse",280                             "Internet haute vitesse inclus",281                             "Stationnement extérieur ou intérieur",282                             "Accès aux aires communes"]283                if promo:284                    amenities.append("En promotion")285286                details: dict = {}287                if floor_m:288                    details["floor"] = int(floor_m.group(1))289290                out.append(Listing(291                    source=self.source_id,292                    external_id=slug,293                    url=url,294                    title=f"Faubourg du Moulin ({complexe or 'complexe'}) — "295                          f"Unité {unit_no}"296                          f"{' (' + unit_type + ')' if unit_type else ''}",297                    address=ADDRESS,298                    sector=SECTOR,299                    city=CITY,300                    unit_type=unit_type,301                    price=price,302                    price_label=price_label,303                    availability=availability,304                    description=" | ".join(desc_parts),305                    amenities=amenities,306                    details=details,307                    images=images,308                ))309            except Exception:310                continue311        return out312