Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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(" ", " ").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