Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/logisquebec.py : LogisQuébec (section « à vendre » seulement)5#6# Portail québécois surtout connu pour la location, mais avec ~4 2007# propriétés À VENDRE (particuliers + agences syndiquées, ex. Proprio Direct).8# Pagination serveur classique /a-vendre/{page} (10 cartes/page, ~420 pages),9# cartes riches (type, prix, rue, ville, extrait, photo). La fiche détail10# (JSON-LD SingleFamilyResidence + Product) fournit adresse exacte, code11# postal, géoloc, description et le n° MLS/Centris quand l'annonce vient12# d'une agence ; galerie i.logisquebec.com séquentielle.13#14# source_id « logis_ag_quebec » : l'infixe _ag_ active la dédup Centris —15# les annonces d'agences déjà couvertes (même n° MLS) sont masquées, les16# annonces de particuliers (id interne vNNNNNN) restent toujours visibles.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import html as _html21import os22import re2324from .base import BaseConnector25from . import _detailutil as du26from ..normalize import parse_price27from ..schema import PropertyListing2829SITE = "https://www.logisquebec.com"30DETAIL_LIMIT = int(os.environ.get("IMMOKA_LOGISQC_DETAIL_LIMIT",31 os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))32AGENCY = "LogisQuébec"3334_PAGE_MAX_RE = re.compile(r'href="/a-vendre/(\d+)"')35_CARD_RE = re.compile(36 r'<a href="/([a-z0-9_-]+?)-a-vendre-([a-z0-9_-]+)-v(\d+)"[^>]*'37 r'class="property-result[^"]*"(.*?)</a>', re.S | re.I)38_MLS_RE = re.compile(r"(?:MLS|Centris)[^0-9]{0,15}(\d{7,9})", re.I)39# galerie : la VRAIE galerie est le tableau JS lightGallery « dynamicimgEl »40# dont les src sont RELATIFS (i-a-vendre/<uid>/<vid>/N.jpg, sans https://) —41# un motif ancré sur le domaine ne voit que la photo 1 (og:image), les42# vignettes /thumbnail/ et les couvertures des « propriétés similaires »43# (autres vid). D'où le motif src: + repli absolu filtré sur le vid canonique.44_GALLERY_RE = re.compile(45 r"src:\s*'(?:https://i\.logisquebec\.com/)?(i-a-vendre/[^']+?\.jpe?g)'", re.I)46_PHOTO_RE = re.compile(47 r"https://i\.logisquebec\.com/(i-a-vendre/(\d+)/(\d+)/([^\"'\\ ]+\.jpe?g))", re.I)48_CANON_RE = re.compile(r'canonical"\s+href="[^"]*-v(\d+)"')49_IMG_HOST = "https://i.logisquebec.com/"505152class LogisQuebecConnector(BaseConnector):53 source_id = "logis_ag_quebec"54 request_delay = 0.355 max_pages = 600 # garde-fou (10 cartes/page)5657 def fetch(self) -> list[PropertyListing]:58 by_id: dict[str, PropertyListing] = {}59 last_page, dry = None, 060 for page in range(1, self.max_pages + 1):61 url = f"{SITE}/a-vendre" + (f"/{page}" if page > 1 else "")62 try:63 html = self.get(url).text64 except Exception:65 break66 if last_page is None:67 nums = [int(n) for n in _PAGE_MAX_RE.findall(html)]68 last_page = max(nums) if nums else 169 before = len(by_id)70 for lst in self._cards(html):71 by_id.setdefault(lst.external_id, lst)72 dry = dry + 1 if len(by_id) == before else 073 if dry >= 3 or page >= (last_page or 1):74 break75 listings = list(by_id.values())76 # v2 : galerie filtrée sur le vid de la fiche (v1 mélangeait vignettes77 # et « propriétés similaires »), specs du tableau Libellé|Valeur,78 # description HTML complète, garde-fou GPS Québec. NB : certaines79 # fiches encore listées répondent 410 (fantômes côté site) — l'échec80 # est mis en cache {} et ne sera retenté qu'au prochain bump de clé.81 du.enrich(self, listings, DETAIL_LIMIT, parse_logisqc_detail, key="v2")82 # n° MLS connu -> external_id (dédup Centris contre les bannières)83 out: dict[str, PropertyListing] = {}84 for lst in listings:85 mls = str(lst.details.pop("_mls", "") or "")86 if mls:87 lst.mls = mls88 lst.external_id = mls89 addr = lst.details.pop("_addr", "")90 if addr:91 lst.address = addr92 lst.title = addr93 city = lst.details.pop("_city", "")94 if city:95 lst.city = city96 out.setdefault(lst.external_id, lst)97 return list(out.values())9899 # -- cartes de résultats ----------------------------------------------------100 def _cards(self, html: str):101 for m in _CARD_RE.finditer(html):102 type_s, city_s, vid, blk = m.groups()103104 def grab(pat: str) -> str:105 g = re.search(pat, blk, re.S)106 return _html.unescape(re.sub(r"<[^>]+>", " ", g.group(1))).strip() if g else ""107108 prix = grab(r'class="prix-valeur">([^<]+)<')109 rue = grab(r'class="adresse-rue">([^<]+)<')110 ville = grab(r'class="adresse-ville">([^<]+)<')111 typ = grab(r'class="box-result-unit-12A">([^<]+)<')112 desc = grab(r'class="box-result-unit-3A">(.*?)</div>')113 img = re.search(r'src="(https://i\.logisquebec\.com/[^"]+)"', blk)114 addr = " ".join(filter(None, [rue])) if rue and rue != "enclavé" else ""115 yield PropertyListing(116 source=self.source_id, external_id=vid,117 url=f"{SITE}/{type_s}-a-vendre-{city_s}-v{vid}",118 title=addr or f"{typ} — {ville}",119 address=addr,120 city=ville,121 property_type=typ or type_s.replace("-", " "),122 price=parse_price(prix),123 price_label=prix,124 description=desc[:1000],125 images=[img.group(1)] if img else [],126 agency=AGENCY,127 )128129130# ---------------------------------------------------------------------------131def parse_logisqc_detail(html: str) -> dict:132 """JSON-LD SingleFamilyResidence (adresse/géo/postal) + Product (prix,133 description) ; n° MLS dans le HTML ; galerie i.logisquebec.com complète."""134 out: dict = {}135 details: dict = {}136 for node in du.ld_nodes(html):137 t = node.get("@type")138 if t in ("SingleFamilyResidence", "Residence", "Apartment"):139 addr = node.get("address") or {}140 if addr.get("streetAddress"):141 details["_addr"] = str(addr["streetAddress"])142 if addr.get("addressLocality"):143 details["_city"] = str(addr["addressLocality"])144 if addr.get("postalCode"):145 details["postal_code"] = str(addr["postalCode"])146 geo = node.get("geo") or {}147 try:148 lat, lng = float(geo["latitude"]), float(geo["longitude"])149 # le géocodage du site déraille parfois (Guadeloupe, Floride…) :150 # on ne garde que des coordonnées plausibles pour le Québec151 if 44.0 <= lat <= 63.0 and -80.5 <= lng <= -56.0:152 out["lat"], out["lng"] = lat, lng153 except (KeyError, TypeError, ValueError):154 pass155 if node.get("numberOfRooms"):156 details["Nombre de pièces"] = node["numberOfRooms"]157 elif t == "Product":158 if node.get("description"):159 out["description"] = _html.unescape(str(node["description"])).strip()[:4000]160 offers = node.get("offers") or {}161 p = offers.get("price") if isinstance(offers, dict) else None162 try:163 out["price"] = float(p)164 out["price_label"] = f"{float(p):,.0f} $".replace(",", " ")165 except (TypeError, ValueError):166 pass167168 mm = _MLS_RE.search(html)169 if mm:170 details["_mls"] = mm.group(1)171172 # galerie : tableau lightGallery (src relatifs, ordre de la fiche, pleine173 # taille) ; repli sur les URL absolues du vid canonique (sans /thumbnail/174 # ni couvertures des « propriétés similaires »)175 mc = _CANON_RE.search(html)176 want = mc.group(1) if mc else None177 seen: set = set()178 imgs = []179 for rel in _GALLERY_RE.findall(html):180 if "/thumbnail/" in rel:181 continue182 u = _IMG_HOST + rel183 if u not in seen:184 seen.add(u)185 imgs.append(u)186 if want:187 imgs = [u for u in imgs if f"/{want}/" in u] or imgs188 if not imgs:189 buckets: dict = {}190 for m in _PHOTO_RE.finditer(html):191 _rel, _uid, vid, fname = m.groups()192 u = m.group(0)193 if fname.lower().startswith("thumbnail") or u in seen:194 continue195 seen.add(u)196 buckets.setdefault(vid, []).append(u)197 if want and want in buckets:198 imgs = buckets[want]199 elif buckets:200 imgs = max(buckets.values(), key=len)201 if imgs:202 out["images"] = imgs203204 # specs du tableau « Libellé | Valeur » (texte aplati) : Chambre à coucher,205 # Salle de bain/d'eau, Pièces, Aire habitable (pc/mc), Dimension terrain…206 # (0 et « -- » = non renseigné). Prose « N chambres » gardée en repli.207 text = du.flatten(html)208 mb = (re.search(r"Chambre à coucher\s*\|\s*(\d{1,2})\b", text)209 or re.search(r"(\d{1,2})\s*chambre", text, re.I))210 if mb and int(mb.group(1)) > 0:211 out["bedrooms"] = int(mb.group(1))212 ms = (re.search(r"Salle de bain/d[’']eau\s*\|\s*(\d{1,2})\b", text)213 or re.search(r"(\d{1,2})\s*salle[s]?\s*de\s*bain", text, re.I))214 if ms and int(ms.group(1)) > 0:215 out["bathrooms"] = int(ms.group(1))216 mp2 = re.search(r"Pièces\s*\|\s*(\d{1,2})\b", text)217 if mp2 and int(mp2.group(1)) > 0:218 details.setdefault("Nombre de pièces", int(mp2.group(1)))219 my = re.search(r"Année de construction\s*\|?\s*:?\s*\|?\s*([12]\d{3})\b", text)220 if my:221 out["year_built"] = int(my.group(1))222223 ma = re.search(r"Aire habitable\s*\|\s*([\d\s.,]+?)\s*(pc|pi2|pi²|pi|mc|m2|m²)\b",224 text, re.I)225 if ma:226 v = _num(ma.group(1))227 if v:228 out["area_sqft"] = round(v * (10.7639 if ma.group(2).lower()229 in ("mc", "m2", "m²") else 1), 0)230231 # « Dimension terrain | 26.02x25.29 mc » (LxW) ou superficie directe232 mt = re.search(r"Dimension terrain\s*\|\s*([\d.,]+)\s*[xX]\s*([\d.,]+)"233 r"\s*(pc|pi|mc|m)?\b", text)234 if mt:235 a, b = _num(mt.group(1)), _num(mt.group(2))236 if a and b:237 f = 10.7639 if (mt.group(3) or "").lower() in ("mc", "m") else 1238 out["lot_sqft"] = round(a * b * f, 0)239 else:240 mt2 = re.search(r"Dimension terrain\s*\|\s*([\d\s.,]+?)\s*(pc|mc)\b", text)241 if mt2:242 v = _num(mt2.group(1))243 if v:244 out["lot_sqft"] = round(v * (10.7639 if mt2.group(2).lower() == "mc"245 else 1), 0)246247 mbat = re.search(r"Dimension bâtiment\s*\|\s*([\d][\d\sxX.,]{1,20}?(?:pc|pi|mc|m)?)\s*\|",248 text)249 if mbat:250 details["Dimension bâtiment"] = mbat.group(1).strip()251 for lab in ("Étages", "Garage", "Stationnement privé"):252 mv = re.search(re.escape(lab) + r"\s*\|\s*(\d{1,3})\b", text)253 if mv and int(mv.group(1)) > 0:254 details[lab] = int(mv.group(1))255256 # description HTML de la fiche (le JSON-LD Product la tronque souvent)257 mdsc = re.search(r'class="content-annonce-description-text"[^>]*>(.*?)</div>',258 html, re.S)259 if mdsc:260 d = re.sub(r"\s+", " ",261 _html.unescape(re.sub(r"<[^>]+>", " ", mdsc.group(1)))).strip()262 if len(d) > len(out.get("description") or ""):263 out["description"] = d[:4000]264265 if details:266 out["details"] = details267 return out268269270def _num(s: str) -> float | None:271 try:272 return float(s.replace(" ", "").replace(" ", "").replace(",", "."))273 except (AttributeError, ValueError):274 return None275