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%
6.6 KB · 162 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/lobato.py : connecteur Groupe Lobato (groupelobato.com —5#   promoteur/gestionnaire de la vallée du Richelieu : Beloeil,6#   McMasterville, Mont-Saint-Hilaire, Saint-Jean-sur-Richelieu).7#   WordPress (thème Vortex Solution) rendu serveur : chaque page8#   /project/<slug>/ contient un tableau HTML des unités (Unit | Bed. |9#   S.F | Price | Availability | Plan). Seules les lignes « Available »10#   sont retenues ; les locaux « Commercial » du Carrefour Laurier sont11#   exclus. Granularité = UNITÉ. external_id = <slug-projet>-<slug-unité>12#   (slug du lien /unit/ si présent, sinon nom affiché slugifié).13#   Colonne Bed. hétérogène : « 1 », « 2 », « 3 », « 1 + den », « 4 1/2 ».14#   Photos servies via le proxy PhastPress (phast.php/<base64>.q.jpg) —15#   on décode le base64 pour retrouver l'URL wp-content/uploads originale.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import base6420import html as _html21import re22from urllib.parse import unquote2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://groupelobato.com"2829# (slug de page projet, nom du projet, adresse, ville)30PROJECTS: tuple[tuple[str, str, str, str], ...] = (31    ("beloeil-faubourg-du-richelieu", "Le Faubourg du Richelieu",32     "2000, rue Richelieu, Beloeil", "Beloeil"),33    ("carrefour-laurier", "Carrefour Laurier",34     "831, boulevard Laurier, McMasterville", "McMasterville"),35    ("mont-st-hilaire-condos-a-louer-2", "Condos rue de la Sucrerie",36     "rue de la Sucrerie, Mont-Saint-Hilaire", "Mont-Saint-Hilaire"),37    ("mcmasterville-rue-constable", "781-783, rue Constable",38     "781-783, rue Constable, McMasterville", "McMasterville"),39    ("havre-du-richelieu-condos-a-louer", "Havre du Richelieu",40     "81-83, rue Richelieu, Saint-Jean-sur-Richelieu",41     "Saint-Jean-sur-Richelieu"),42)4344ROW_RE = re.compile(r"<tr[^>]*>[\s\S]*?</tr>", re.I)45CELL_RE = re.compile(r"<td[^>]*>([\s\S]*?)</td>", re.I)46UNIT_LINK_RE = re.compile(r'href="https://groupelobato\.com/unit/([^/"]+)/?"')47PDF_RE = re.compile(48    r'href="(https://groupelobato\.com/wp-content/uploads/[^"]+\.pdf)"', re.I)49TAG_RE = re.compile(r"<[^>]+>")50PHAST_RE = re.compile(r"phast\.php/([A-Za-z0-9_=-]+)\.q\.(?:jpe?g|png|webp)",51                      re.I)52SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+(\.(?:jpe?g|png|webp))$", re.I)535455def _clean(txt: str) -> str:56    return re.sub(r"\s+", " ", _html.unescape(TAG_RE.sub(" ", txt))).strip()575859def _slug(txt: str) -> str:60    return re.sub(r"[^a-z0-9]+", "-", txt.lower()).strip("-")616263def _images(html: str) -> list[str]:64    """Décode les URLs PhastPress -> originaux wp-content/uploads (photos)."""65    urls: list[str] = []66    for tok in dict.fromkeys(PHAST_RE.findall(html)):67        try:68            raw = base64.urlsafe_b64decode(69                tok + "=" * (-len(tok) % 4)).decode("utf-8", "replace")70        except Exception:71            continue72        m = re.search(r"src=([^&]+)", raw)73        if not m:74            continue75        u = unquote(m.group(1))76        if "/wp-content/uploads/" not in u:77            continue                          # images de thème/icônes78        if re.search(r"logo|icon|favicon", u, re.I):79            continue80        u = SIZE_SUFFIX_RE.sub(r"\1", u)      # variante -WxH -> original81        if u not in urls:82            urls.append(u)83    return urls[:10]848586class LobatoConnector(BaseConnector):87    source_id = "lobato"88    request_delay = 0.88990    def fetch(self) -> list[Listing]:91        listings: list[Listing] = []92        seen: set[str] = set()93        for slug, name, address, city in PROJECTS:94            url = f"{BASE}/project/{slug}/"95            try:96                html = self.get(url).text97            except Exception:98                continue99            images = _images(html)100            for row in ROW_RE.finditer(html):101                chunk = row.group(0)102                cells = CELL_RE.findall(chunk)103                if len(cells) < 5:104                    continue105                unit = _clean(cells[0])106                beds_txt = _clean(cells[1])107                sf_txt = _clean(cells[2])108                price_txt = _clean(cells[3])109                avail_txt = _clean(cells[4])110                if not unit or "commercial" in unit.lower():111                    continue                      # local commercial exclu112                if not re.search(r"\bAvailable\b", avail_txt, re.I):113                    continue                      # unité louée (Leased)114115                lm = UNIT_LINK_RE.search(cells[0])116                ext_id = f"{slug}-{lm.group(1) if lm else _slug(unit)}"117                if ext_id in seen:118                    continue119                seen.add(ext_id)120121                price = None122                pm = re.search(r"(\d[\d\s  ]{2,8})\$", price_txt)123                if pm:124                    price = float(re.sub(r"[\s  ]", "", pm.group(1)))125                area = None126                am = re.search(r"(\d{3,5})", sf_txt.replace(" ", ""))127                if am:128                    area = float(am.group(1))129130                bedrooms = None131                unit_type = ""132                bm = re.match(r"^(\d+)(?:\s*\+\s*den)?$", beds_txt, re.I)133                if bm:134                    bedrooms = float(bm.group(1))135                    if "den" in beds_txt.lower():136                        unit_type = f"{bm.group(1)} ch. + den"137                elif re.match(r"^\d+\s*1/2$", beds_txt):138                    unit_type = beds_txt          # « 4 1/2 » -> finalize139140                pdf = PDF_RE.search(chunk)141142                listings.append(Listing(143                    source=self.source_id,144                    external_id=ext_id,145                    url=url,146                    title=f"{name} — unité {unit}",147                    address=address,148                    city=city,149                    unit_type=unit_type,150                    bedrooms=bedrooms,151                    price=price,152                    price_label=price_txt if price else "",153                    availability="Disponible",154                    area_sqft=area,155                    description=f"Unité {unit} au {name} ({address}) — "156                                "immeuble locatif du Groupe Lobato, "157                                "promoteur de la vallée du Richelieu.",158                    details={"plan_pdf": pdf.group(1)} if pdf else {},159                    images=images,160                ))161        return listings162