spb/immo-ka Public
Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 66.4%
TypeScript 19.9%
CSS 13.2%
HTML 0.5%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/via_capitale.py : Via Capitale (viacapitalevendu.com)5# Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET)6# mais protégé par Cloudflare : on passe donc par Firecrawl (get_rendered) qui7# franchit le challenge. Les résultats résidentiels sont paginés (34/page,8# ~5 300 propriétés). On parse les cartes HTML et on dédoublonne par code9# d'inscription. Photos : images.viacapitale.info/images/inscriptions/{id}/…10# -----------------------------------------------------------------------------11from __future__ import annotations1213import html as _htmlmod214import os15import re1617from .base import BaseConnector18from . import _detailutil as du19from ..normalize import parse_price20from ..schema import PropertyListing2122# Fiche descriptive accessible en requête simple (galerie ~60 photos + description).23DETAIL_LIMIT = int(os.environ.get("IMMOKA_VC_DETAIL_LIMIT", "300"))24_FICHE_IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I)2526SITE = "https://www.viacapitalevendu.com"27SEARCH = f"{SITE}/recherche/residentiel/"28MAX_PAGES = 200 # garde-fou (~155 pages attendues)29# La pagination du portail n'est active QUE si le paramètre `criteresJson`30# (blob base64 des critères de recherche) est présent — sinon `?page=N` retombe31# toujours sur la 1re page. Ce blob = critères vides + « AVendre:true » ; il est32# statique et réutilisable pour parcourir les ~155 pages (~5 300 propriétés).33CRITERES_JSON = (34 "eyJDYXJhY3RlcmlzdGlxdWVzIjpbXSwiQXV0cmVzQ3JpdGVyZSI6W10sIlR5cGVEZVByb3ByaWV0ZSI6"35 "W10sIlR5cGVEZUJhdGltZW50IjpudWxsLCJQZXJpb2Rlc0FmZmljaGFnZSI6bnVsbCwiQW5uZWVEZUNv"36 "bnN0cnVjdGlvbiI6bnVsbCwiUmVnaW9uIjpudWxsLCJOb21SZWdpb24iOm51bGwsIk5vbWJyZURlQ2hh"37 "bWJyZSI6MCwiTm9tYnJlRGVCYWluIjowLCJQcml4TWluaW11bSI6IjAiLCJQcml4TWF4aW11bSI6IjAi"38 "LCJQcml4TG9jYXRpb25NaW5pbXVtIjoiMCIsIlByaXhMb2NhdGlvbk1heGltdW0iOiIwIiwiT3JkZXJC"39 "eSI6bnVsbCwiVHlwZSI6MCwiQVZlbmRyZSI6dHJ1ZSwiRnJvbUFmZmljaGVyVG91dGVzUHJvcHJpZXRl"40 "IjpmYWxzZSwiU3VwZXJmaWNpZU1pbmltdW0iOiIiLCJTdXBlcmZpY2llTWF4aW11bSI6IiIsIlVuaXRl"41 "TWVzdXJlIjoiUEMiLCJNb3RzQ2xlcyI6bnVsbCwiWm9uYWdlIjpudWxsLCJOb21icmVVbml0ZXMiOjEs"42 "IlN1Y2N1cnNhbGVDb2RlIjpudWxsLCJBZ2VuY2VDb2RlIjpudWxsLCJNZW1icmVDb2RlIjpudWxsLCJF"43 "cXVpcGVJZCI6MCwiU3VjY3Vyc2FsZU5hbWUiOm51bGwsIkFnZW5jZU5hbWUiOm51bGwsIk1lbWJyZU5h"44 "bWUiOm51bGwsIkVxdWlwZU5hbWUiOm51bGwsImlucHV0UmVnaW9ucyI6bnVsbCwiUmV0dXJuVXJsIjpu"45 "dWxsLCJMYXRpdHVkZSI6bnVsbCwiTG9uZ2l0dWRlIjpudWxsLCJOb0luc2NyaXB0aW9uTm9uRGlzcG8i"46 "Om51bGwsIkVuUmVjaGVyY2hlIjpmYWxzZSwiUGx1c0RlQ3JpdGVyZXMiOiJmYWxzZSIsIlNwZWNpYWxp"47 "dGUiOm51bGwsIk5vbVBsYW5FYXUiOm51bGwsIk5vbVBsYW5FYXVNb2JpbGUiOm51bGwsIklkUGxhbkVh"48 "dSI6bnVsbCwiSWRQbGFuRWF1TW9iaWxlIjowLCJHZW5yZVByb3ByaWV0ZUZyVXJsIjpudWxsLCJHZW5y"49 "ZVByb3ByaWV0ZUVuVXJsIjpudWxsLCJzZWxlY1BBTW9iaWxlIjpudWxsLCJzZWxlY0NhcmFjTW9iaWxl"50 "IjpbbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbF0sInNlbGVj"51 "QUNNb2JpbGUiOltudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxs"52 "XSwic2VsZWNUQk1vYmlsZSI6W251bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxu"53 "dWxsLG51bGxdfQ=="54)55IMG_HOST = "https://images.viacapitale.info"5657# Un bloc-carte commence à un lien vers une fiche d'inscription horodatée58CARD_SPLIT = re.compile(r'<a href="(https://www\.viacapitalevendu\.com/[^"]*?-(\d{6,}))"')59PRICE_RE = re.compile(r'class="price">\s*([\d ]+\$)', re.I)60ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I)61IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I)62TYPE_RE = re.compile(63 r'(Maison[\w\s\-àâéèêëîïôûùç]*|Condo[\w\s]*|Duplex|Triplex|Quadruplex|Quintuplex|'64 r'Plex|Terrain|Chalet|Fermette|Ferme|Loft|Terre|Maison de ville|Jumelé)',65 re.I)666768class ViaCapitaleConnector(BaseConnector):69 source_id = "via_capitale"7071 def fetch(self) -> list[PropertyListing]:72 by_id: dict[str, PropertyListing] = {}73 empty_streak = 074 for page in range(1, MAX_PAGES + 1):75 url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}"76 # Cloudflare renvoie parfois un challenge/vide par intermittence :77 # on retente la page une fois avant de la considérer vraiment vide.78 cards = []79 for attempt in range(2):80 try:81 # Firecrawl (proxy stealth) : c'est LUI qui rend les cartes de82 # Via Capitale. Testé : Scrapfly ASP franchit Cloudflare mais83 # ne déclenche pas le rendu des inscriptions (0 carte) ; on84 # garde donc Firecrawl, qui ramène bien les ~5 300 propriétés.85 html = self.get_rendered(url, wait_for=6000, proxy="stealth")86 except Exception:87 html = ""88 cards = self._parse_cards(html)89 if cards:90 break91 new = 092 for lst in cards:93 if lst.uid not in by_id:94 by_id[lst.uid] = lst95 new += 196 if not cards or new == 0:97 empty_streak += 198 # tolérance élevée : ~155 pages attendues, on ne s'arrête qu'après99 # plusieurs pages consécutives réellement sans nouveauté100 if empty_streak >= 5:101 break102 else:103 empty_streak = 0104 listings = list(by_id.values())105 # fiche détail (requête simple) : galerie complète + description106 du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v2")107 return listings108109 def _parse_cards(self, html: str) -> list[PropertyListing]:110 # découpe la page en segments, un par carte (lien fiche + id)111 matches = list(CARD_SPLIT.finditer(html))112 out = []113 for i, m in enumerate(matches):114 url, code = m.group(1), m.group(2)115 seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches) else m.start() + 2500]116 out.append(self._to_listing(url, code, seg))117 return [x for x in out if x]118119 def _to_listing(self, url: str, code: str, seg: str) -> PropertyListing | None:120 price_m = PRICE_RE.search(seg)121 price_label = (price_m.group(1).strip() if price_m else "").replace(" ", " ")122 addr_m = ADDR_RE.search(seg)123 address_full = _unescape(addr_m.group(1).strip()) if addr_m else ""124 type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg))125 # ne garder que le libellé de type (avant l'adresse recopiée dans le bloc)126 prop_type = re.split(r"\s{2,}|\n", type_m.group(1))[0].strip() if type_m else ""127128 address, city, sector = _split_addr(address_full)129130 images = []131 for im in IMG_RE.finditer(seg):132 u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}"133 if u not in images:134 images.append(u)135136 region, _city_slug = _from_slug(url)137 return PropertyListing(138 source=self.source_id,139 external_id=code,140 url=f"{SITE}/inscription/fichedescriptive/?code={code}",141 title=f"{prop_type} — {address}".strip(" —") or address_full,142 address=address,143 city=city or _city_slug,144 sector=sector,145 region=region,146 property_type=prop_type,147 price=parse_price(price_label),148 price_label=price_label,149 images=images,150 broker_name="Via Capitale",151 )152153154# slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-maison-... »155_REGIONS = (156 "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale",157 "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue",158 "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine",159 "chaudiere-appalaches", "laval", "lanaudiere", "laurentides",160 "monteregie", "centre-du-quebec",161)162163164import html as _htmlmod165166_PAREN = re.compile(r"\(([^)]*)\)")167168169def _unescape(s: str) -> str:170 return _htmlmod.unescape(s).replace("\xa0", " ").replace(" ", " ").strip()171172173def _split_addr(full: str) -> tuple[str, str, str]:174 """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) »175 -> (adresse, ville, secteur)."""176 if not full:177 return "", "", ""178 parts = [p.strip() for p in full.split(",", 1)]179 address = parts[0]180 city = sector = ""181 if len(parts) > 1:182 muni = parts[1]183 parens = _PAREN.findall(muni)184 city = _PAREN.sub("", muni).strip()185 if parens:186 sector = parens[-1].strip()187 return address, city, sector188189190def parse_vc_detail(html: str) -> dict:191 """Fiche Via Capitale : galerie complète + description (plus long bloc)."""192 out: dict = {}193 seen, imgs = set(), []194 for m in _FICHE_IMG_RE.finditer(html):195 u = f"https://images.viacapitale.info/images/inscriptions/{m.group(1)}/{m.group(2)}"196 if u not in seen:197 seen.add(u)198 imgs.append(u)199 if imgs:200 out["images"] = imgs201 # description = plus long bloc de texte visible (marketing de la propriété)202 best = ""203 for b in re.findall(r"<div[^>]*>(.*?)</div>", html, re.S):204 txt = re.sub(r"\s+", " ", _htmlmod2.unescape(re.sub(r"<[^>]+>", " ", b))).strip()205 if len(txt) > len(best) and "window" not in txt and "function" not in txt and "{" not in txt:206 best = txt207 if len(best) > 120:208 out["description"] = best[:4000]209 _det = du.centris_details(du.flatten(html))210 if _det:211 out.setdefault("details", {}).update(_det)212 return out213214215def _from_slug(url: str) -> tuple[str, str]:216 tail = url.rstrip("/").rsplit("/", 1)[-1]217 for reg in _REGIONS:218 if tail.startswith(reg):219 rest = tail[len(reg) + 1:]220 city = rest.split("-")[0].replace("-", " ").title() if rest else ""221 return reg.replace("-", " ").title(), city222 return "", ""223