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%
9.6 KB · 234 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/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS5#   Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET)6#   mais protégé par Cloudflare : on passe par le rendu Scrapfly (get_rendered, ex-Firecrawl) qui7#   franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend8#   pas les cartes. La recherche accepte un blob base64 `criteresJson` ;9#   « AVendre »: false y bascule le même moteur en mode LOCATION (prix10#   « X $ / mois », ~34 cartes/page). Adapté du connecteur « à vendre »11#   d'Immo-Ka (agent-courtage/immoka).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import base6416import html as _htmlmod17import json18import os19import re2021from .base import BaseConnector22from . import _detailutil as du23from ..schema import Listing, parse_price2425DETAIL_LIMIT = int(os.environ.get("LOUKA_VC_DETAIL_LIMIT", "150"))26_FICHE_IMG_RE = re.compile(27    r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I)2829SITE = "https://www.viacapitalevendu.com"30SEARCH = f"{SITE}/recherche/residentiel/"31MAX_PAGES = int(os.environ.get("LOUKA_VC_MAX_PAGES", "40"))3233# La pagination du portail n'est active QUE si `criteresJson` est présent.34# Blob = critères vides + « AVendre »: false (= locations seulement) —35# construit au chargement pour rester lisible/modifiable.36_CRITERES = {37    "Caracteristiques": [], "AutresCritere": [], "TypeDePropriete": [],38    "TypeDeBatiment": None, "PeriodesAffichage": None, "AnneeDeConstruction": None,39    "Region": None, "NomRegion": None, "NombreDeChambre": 0, "NombreDeBain": 0,40    "PrixMinimum": "0", "PrixMaximum": "0",41    "PrixLocationMinimum": "0", "PrixLocationMaximum": "0",42    "OrderBy": None, "Type": 0, "AVendre": False,43    "FromAfficherToutesPropriete": False,44    "SuperficieMinimum": "", "SuperficieMaximum": "", "UniteMesure": "PC",45    "MotsCles": None, "Zonage": None, "NombreUnites": 1,46    "SuccursaleCode": None, "AgenceCode": None, "MembreCode": None,47    "EquipeId": 0, "SuccursaleName": None, "AgenceName": None,48    "MembreName": None, "EquipeName": None, "inputRegions": None,49    "ReturnUrl": None, "Latitude": None, "Longitude": None,50    "NoInscriptionNonDispo": None, "EnRecherche": False,51    "PlusDeCriteres": "false", "Specialite": None,52    "NomPlanEau": None, "NomPlanEauMobile": None,53    "IdPlanEau": None, "IdPlanEauMobile": 0,54    "GenrePropreteFrUrl": None, "GenreProprieteFrUrl": None,55    "GenreProprieteEnUrl": None, "selecPAMobile": None,56    "selecCaracMobile": [None] * 10, "selecACMobile": [None] * 10,57    "selecTBMobile": [None] * 10,58}59CRITERES_JSON = base64.b64encode(60    json.dumps(_CRITERES, separators=(",", ":")).encode()).decode()61IMG_HOST = "https://images.viacapitale.info"6263# Un bloc-carte commence à un lien vers une fiche d'inscription horodatée64# le rendu Scrapfly sérialise les hrefs en RELATIF (Firecrawl donnait65# l'absolu) : on accepte les deux formes, groupe 1 = chemin relatif66CARD_SPLIT = re.compile(67    r'<a href="(?:https://www\.viacapitalevendu\.com)?(/[^"]*?-(\d{6,}))"')68PRICE_RE = re.compile(r'class="price">\s*([\d   ]+\$\s*/\s*mois)', re.I)69ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I)70IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I)71TYPE_RE = re.compile(72    r'(Appartement|Condo[\w\s]*|Maison[\w\s\-àâéèêëîïôûùç]*|Duplex|Triplex|'73    r'Quadruplex|Loft|Studio|Maison de ville|Jumelé|Chalet)', re.I)74_PAREN = re.compile(r"\(([^)]*)\)")757677def _unescape(s: str) -> str:78    return _htmlmod.unescape(s).replace("\xa0", " ").replace("&nbsp;", " ").strip()798081def _split_addr(full: str) -> tuple[str, str, str]:82    """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) »83    -> (adresse, ville, secteur)."""84    if not full:85        return "", "", ""86    parts = [p.strip() for p in full.split(",", 1)]87    address = parts[0]88    city = sector = ""89    if len(parts) > 1:90        muni = parts[1]91        parens = _PAREN.findall(muni)92        city = _PAREN.sub("", muni).strip()93        if parens:94            sector = parens[-1].strip()95    return address, city, sector969798# slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-… »99_REGIONS = (100    "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale",101    "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue",102    "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine",103    "chaudiere-appalaches", "laval", "lanaudiere", "laurentides",104    "monteregie", "centre-du-quebec",105)106107108def _from_slug(url: str) -> tuple[str, str]:109    tail = url.rstrip("/").rsplit("/", 1)[-1]110    for reg in _REGIONS:111        if tail.startswith(reg):112            rest = tail[len(reg) + 1:]113            city = rest.split("-")[0].replace("-", " ").title() if rest else ""114            return reg.replace("-", " ").title(), city115    return "", ""116117118class ViaCapitaleConnector(BaseConnector):119    source_id = "via_capitale"120121    def fetch(self) -> list[Listing]:122        by_id: dict[str, Listing] = {}123        empty_streak = 0124        for page in range(1, MAX_PAGES + 1):125            url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}"126            # Cloudflare renvoie parfois un challenge/vide par intermittence :127            # on retente la page une fois avant de la considérer vraiment vide.128            cards = []129            for _attempt in range(2):130                try:131                    html = self.get_rendered(url)132                except Exception:133                    html = ""134                cards = self._parse_cards(html)135                if cards:136                    break137            new = 0138            for lst in cards:139                if lst.uid not in by_id:140                    by_id[lst.uid] = lst141                    new += 1142            if not cards or new == 0:143                empty_streak += 1144                if empty_streak >= 3:145                    break146            else:147                empty_streak = 0148        listings = list(by_id.values())149        # fiche détail (requête simple, hors Cloudflare) : galerie + description150        du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v1")151        return listings152153    def _parse_cards(self, html: str) -> list[Listing]:154        # découpe la page en segments, un par carte (lien fiche + id)155        matches = list(CARD_SPLIT.finditer(html))156        out = []157        for i, m in enumerate(matches):158            url, code = m.group(1), m.group(2)159            seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches)160                       else m.start() + 2500]161            out.append(self._to_listing(url, code, seg))162        return [x for x in out if x]163164    def _to_listing(self, url: str, code: str, seg: str) -> Listing | None:165        price_m = PRICE_RE.search(seg)166        if not price_m:167            return None        # pas de « $ / mois » = pas une location rendue168        price_label = _unescape(price_m.group(1))169        addr_m = ADDR_RE.search(seg)170        address_full = _unescape(addr_m.group(1).strip()) if addr_m else ""171        type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg))172        prop_type = (re.split(r"\s{2,}|\n", type_m.group(1))[0].strip()173                     if type_m else "")174175        address, city, sector = _split_addr(address_full)176177        images = []178        for im in IMG_RE.finditer(seg):179            u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}"180            if u not in images:181                images.append(u)182183        region, city_slug = _from_slug(url)184        details = {"Courtier": "Via Capitale"}185        if region:186            details["Région"] = region187        if prop_type:188            details["Type"] = prop_type189        unit_type = prop_type if prop_type.lower() in ("studio", "loft") else ""190        return Listing(191            source=self.source_id,192            external_id=code,193            url=f"{SITE}/inscription/fichedescriptive/?code={code}",194            title=f"{prop_type} — {address}".strip(" —") or address_full,195            address=address,196            city=city or city_slug,197            sector=sector,198            unit_type=unit_type,199            price=parse_price(price_label),200            price_label=price_label,201            details=details,202            images=images,203        )204205206def parse_vc_detail(html: str) -> dict:207    """Fiche Via Capitale : galerie complète + description (plus long bloc)."""208    out: dict = {}209    seen, imgs = set(), []210    for m in _FICHE_IMG_RE.finditer(html):211        u = f"{IMG_HOST}/images/inscriptions/{m.group(1)}/{m.group(2)}"212        if u not in seen:213            seen.add(u)214            imgs.append(u)215    if imgs:216        out["images"] = imgs217    # description = plus long bloc de texte visible (marketing de la propriété)218    best = ""219    for b in re.findall(r"<div[^>]*>(.*?)</div>", html, re.S):220        txt = re.sub(r"\s+", " ", _htmlmod.unescape(re.sub(r"<[^>]+>", " ", b))).strip()221        if len(txt) > len(best) and "window" not in txt and "function" not in txt \222                and "{" not in txt:223            best = txt224    if len(best) > 120:225        out["description"] = best[:4000]226    det = du.centris_details(du.flatten(html))227    if det:228        out["details"] = det229        # « Nombre de pièces : 4 pièces » → unité normalisable (4½ etc.)230        pieces = det.get("Nombre de pièces")231        if pieces:232            out["unit_type"] = pieces233    return out234