# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS # Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET) # mais protégé par Cloudflare : on passe par le rendu Scrapfly (get_rendered, ex-Firecrawl) qui # franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend # pas les cartes. La recherche accepte un blob base64 `criteresJson` ; # « AVendre »: false y bascule le même moteur en mode LOCATION (prix # « X $ / mois », ~34 cartes/page). Adapté du connecteur « à vendre » # d'Immo-Ka (agent-courtage/immoka). # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import html as _htmlmod import json import os import re from .base import BaseConnector from . import _detailutil as du from ..schema import Listing, parse_price DETAIL_LIMIT = int(os.environ.get("LOUKA_VC_DETAIL_LIMIT", "150")) _FICHE_IMG_RE = re.compile( r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I) SITE = "https://www.viacapitalevendu.com" SEARCH = f"{SITE}/recherche/residentiel/" MAX_PAGES = int(os.environ.get("LOUKA_VC_MAX_PAGES", "40")) # La pagination du portail n'est active QUE si `criteresJson` est présent. # Blob = critères vides + « AVendre »: false (= locations seulement) — # construit au chargement pour rester lisible/modifiable. _CRITERES = { "Caracteristiques": [], "AutresCritere": [], "TypeDePropriete": [], "TypeDeBatiment": None, "PeriodesAffichage": None, "AnneeDeConstruction": None, "Region": None, "NomRegion": None, "NombreDeChambre": 0, "NombreDeBain": 0, "PrixMinimum": "0", "PrixMaximum": "0", "PrixLocationMinimum": "0", "PrixLocationMaximum": "0", "OrderBy": None, "Type": 0, "AVendre": False, "FromAfficherToutesPropriete": False, "SuperficieMinimum": "", "SuperficieMaximum": "", "UniteMesure": "PC", "MotsCles": None, "Zonage": None, "NombreUnites": 1, "SuccursaleCode": None, "AgenceCode": None, "MembreCode": None, "EquipeId": 0, "SuccursaleName": None, "AgenceName": None, "MembreName": None, "EquipeName": None, "inputRegions": None, "ReturnUrl": None, "Latitude": None, "Longitude": None, "NoInscriptionNonDispo": None, "EnRecherche": False, "PlusDeCriteres": "false", "Specialite": None, "NomPlanEau": None, "NomPlanEauMobile": None, "IdPlanEau": None, "IdPlanEauMobile": 0, "GenrePropreteFrUrl": None, "GenreProprieteFrUrl": None, "GenreProprieteEnUrl": None, "selecPAMobile": None, "selecCaracMobile": [None] * 10, "selecACMobile": [None] * 10, "selecTBMobile": [None] * 10, } CRITERES_JSON = base64.b64encode( json.dumps(_CRITERES, separators=(",", ":")).encode()).decode() IMG_HOST = "https://images.viacapitale.info" # Un bloc-carte commence à un lien vers une fiche d'inscription horodatée # le rendu Scrapfly sérialise les hrefs en RELATIF (Firecrawl donnait # l'absolu) : on accepte les deux formes, groupe 1 = chemin relatif CARD_SPLIT = re.compile( r'\s*([\d ]+\$\s*/\s*mois)', re.I) ADDR_RE = re.compile(r'addressListe[^>]*>\s*]*title="([^"]+)"', re.I) IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I) TYPE_RE = re.compile( r'(Appartement|Condo[\w\s]*|Maison[\w\s\-àâéèêëîïôûùç]*|Duplex|Triplex|' r'Quadruplex|Loft|Studio|Maison de ville|Jumelé|Chalet)', re.I) _PAREN = re.compile(r"\(([^)]*)\)") def _unescape(s: str) -> str: return _htmlmod.unescape(s).replace("\xa0", " ").replace(" ", " ").strip() def _split_addr(full: str) -> tuple[str, str, str]: """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) » -> (adresse, ville, secteur).""" if not full: return "", "", "" parts = [p.strip() for p in full.split(",", 1)] address = parts[0] city = sector = "" if len(parts) > 1: muni = parts[1] parens = _PAREN.findall(muni) city = _PAREN.sub("", muni).strip() if parens: sector = parens[-1].strip() return address, city, sector # slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-… » _REGIONS = ( "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale", "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue", "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine", "chaudiere-appalaches", "laval", "lanaudiere", "laurentides", "monteregie", "centre-du-quebec", ) def _from_slug(url: str) -> tuple[str, str]: tail = url.rstrip("/").rsplit("/", 1)[-1] for reg in _REGIONS: if tail.startswith(reg): rest = tail[len(reg) + 1:] city = rest.split("-")[0].replace("-", " ").title() if rest else "" return reg.replace("-", " ").title(), city return "", "" class ViaCapitaleConnector(BaseConnector): source_id = "via_capitale" def fetch(self) -> list[Listing]: by_id: dict[str, Listing] = {} empty_streak = 0 for page in range(1, MAX_PAGES + 1): url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}" # Cloudflare renvoie parfois un challenge/vide par intermittence : # on retente la page une fois avant de la considérer vraiment vide. cards = [] for _attempt in range(2): try: html = self.get_rendered(url) except Exception: html = "" cards = self._parse_cards(html) if cards: break new = 0 for lst in cards: if lst.uid not in by_id: by_id[lst.uid] = lst new += 1 if not cards or new == 0: empty_streak += 1 if empty_streak >= 3: break else: empty_streak = 0 listings = list(by_id.values()) # fiche détail (requête simple, hors Cloudflare) : galerie + description du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v1") return listings def _parse_cards(self, html: str) -> list[Listing]: # découpe la page en segments, un par carte (lien fiche + id) matches = list(CARD_SPLIT.finditer(html)) out = [] for i, m in enumerate(matches): url, code = m.group(1), m.group(2) seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches) else m.start() + 2500] out.append(self._to_listing(url, code, seg)) return [x for x in out if x] def _to_listing(self, url: str, code: str, seg: str) -> Listing | None: price_m = PRICE_RE.search(seg) if not price_m: return None # pas de « $ / mois » = pas une location rendue price_label = _unescape(price_m.group(1)) addr_m = ADDR_RE.search(seg) address_full = _unescape(addr_m.group(1).strip()) if addr_m else "" type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg)) prop_type = (re.split(r"\s{2,}|\n", type_m.group(1))[0].strip() if type_m else "") address, city, sector = _split_addr(address_full) images = [] for im in IMG_RE.finditer(seg): u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}" if u not in images: images.append(u) region, city_slug = _from_slug(url) details = {"Courtier": "Via Capitale"} if region: details["Région"] = region if prop_type: details["Type"] = prop_type unit_type = prop_type if prop_type.lower() in ("studio", "loft") else "" return Listing( source=self.source_id, external_id=code, url=f"{SITE}/inscription/fichedescriptive/?code={code}", title=f"{prop_type} — {address}".strip(" —") or address_full, address=address, city=city or city_slug, sector=sector, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, details=details, images=images, ) def parse_vc_detail(html: str) -> dict: """Fiche Via Capitale : galerie complète + description (plus long bloc).""" out: dict = {} seen, imgs = set(), [] for m in _FICHE_IMG_RE.finditer(html): u = f"{IMG_HOST}/images/inscriptions/{m.group(1)}/{m.group(2)}" if u not in seen: seen.add(u) imgs.append(u) if imgs: out["images"] = imgs # description = plus long bloc de texte visible (marketing de la propriété) best = "" for b in re.findall(r"]*>(.*?)", html, re.S): txt = re.sub(r"\s+", " ", _htmlmod.unescape(re.sub(r"<[^>]+>", " ", b))).strip() if len(txt) > len(best) and "window" not in txt and "function" not in txt \ and "{" not in txt: best = txt if len(best) > 120: out["description"] = best[:4000] det = du.centris_details(du.flatten(html)) if det: out["details"] = det # « Nombre de pièces : 4 pièces » → unité normalisable (4½ etc.) pieces = det.get("Nombre de pièces") if pieces: out["unit_type"] = pieces return out