# -----------------------------------------------------------------------------
# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/logisquebec.py : LogisQuébec (section « à vendre » seulement)
#
# Portail québécois surtout connu pour la location, mais avec ~4 200
# propriétés À VENDRE (particuliers + agences syndiquées, ex. Proprio Direct).
# Pagination serveur classique /a-vendre/{page} (10 cartes/page, ~420 pages),
# cartes riches (type, prix, rue, ville, extrait, photo). La fiche détail
# (JSON-LD SingleFamilyResidence + Product) fournit adresse exacte, code
# postal, géoloc, description et le n° MLS/Centris quand l'annonce vient
# d'une agence ; galerie i.logisquebec.com séquentielle.
#
# source_id « logis_ag_quebec » : l'infixe _ag_ active la dédup Centris —
# les annonces d'agences déjà couvertes (même n° MLS) sont masquées, les
# annonces de particuliers (id interne vNNNNNN) restent toujours visibles.
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import os
import re
from .base import BaseConnector
from . import _detailutil as du
from ..normalize import parse_price
from ..schema import PropertyListing
SITE = "https://www.logisquebec.com"
DETAIL_LIMIT = int(os.environ.get("IMMOKA_LOGISQC_DETAIL_LIMIT",
os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))
AGENCY = "LogisQuébec"
_PAGE_MAX_RE = re.compile(r'href="/a-vendre/(\d+)"')
_CARD_RE = re.compile(
r']*'
r'class="property-result[^"]*"(.*?)', re.S | re.I)
_MLS_RE = re.compile(r"(?:MLS|Centris)[^0-9]{0,15}(\d{7,9})", re.I)
# galerie : la VRAIE galerie est le tableau JS lightGallery « dynamicimgEl »
# dont les src sont RELATIFS (i-a-vendre///N.jpg, sans https://) —
# un motif ancré sur le domaine ne voit que la photo 1 (og:image), les
# vignettes /thumbnail/ et les couvertures des « propriétés similaires »
# (autres vid). D'où le motif src: + repli absolu filtré sur le vid canonique.
_GALLERY_RE = re.compile(
r"src:\s*'(?:https://i\.logisquebec\.com/)?(i-a-vendre/[^']+?\.jpe?g)'", re.I)
_PHOTO_RE = re.compile(
r"https://i\.logisquebec\.com/(i-a-vendre/(\d+)/(\d+)/([^\"'\\ ]+\.jpe?g))", re.I)
_CANON_RE = re.compile(r'canonical"\s+href="[^"]*-v(\d+)"')
_IMG_HOST = "https://i.logisquebec.com/"
class LogisQuebecConnector(BaseConnector):
source_id = "logis_ag_quebec"
request_delay = 0.3
max_pages = 600 # garde-fou (10 cartes/page)
def fetch(self) -> list[PropertyListing]:
by_id: dict[str, PropertyListing] = {}
last_page, dry = None, 0
for page in range(1, self.max_pages + 1):
url = f"{SITE}/a-vendre" + (f"/{page}" if page > 1 else "")
try:
html = self.get(url).text
except Exception:
break
if last_page is None:
nums = [int(n) for n in _PAGE_MAX_RE.findall(html)]
last_page = max(nums) if nums else 1
before = len(by_id)
for lst in self._cards(html):
by_id.setdefault(lst.external_id, lst)
dry = dry + 1 if len(by_id) == before else 0
if dry >= 3 or page >= (last_page or 1):
break
listings = list(by_id.values())
# v2 : galerie filtrée sur le vid de la fiche (v1 mélangeait vignettes
# et « propriétés similaires »), specs du tableau Libellé|Valeur,
# description HTML complète, garde-fou GPS Québec. NB : certaines
# fiches encore listées répondent 410 (fantômes côté site) — l'échec
# est mis en cache {} et ne sera retenté qu'au prochain bump de clé.
du.enrich(self, listings, DETAIL_LIMIT, parse_logisqc_detail, key="v2")
# n° MLS connu -> external_id (dédup Centris contre les bannières)
out: dict[str, PropertyListing] = {}
for lst in listings:
mls = str(lst.details.pop("_mls", "") or "")
if mls:
lst.mls = mls
lst.external_id = mls
addr = lst.details.pop("_addr", "")
if addr:
lst.address = addr
lst.title = addr
city = lst.details.pop("_city", "")
if city:
lst.city = city
out.setdefault(lst.external_id, lst)
return list(out.values())
# -- cartes de résultats ----------------------------------------------------
def _cards(self, html: str):
for m in _CARD_RE.finditer(html):
type_s, city_s, vid, blk = m.groups()
def grab(pat: str) -> str:
g = re.search(pat, blk, re.S)
return _html.unescape(re.sub(r"<[^>]+>", " ", g.group(1))).strip() if g else ""
prix = grab(r'class="prix-valeur">([^<]+)<')
rue = grab(r'class="adresse-rue">([^<]+)<')
ville = grab(r'class="adresse-ville">([^<]+)<')
typ = grab(r'class="box-result-unit-12A">([^<]+)<')
desc = grab(r'class="box-result-unit-3A">(.*?)')
img = re.search(r'src="(https://i\.logisquebec\.com/[^"]+)"', blk)
addr = " ".join(filter(None, [rue])) if rue and rue != "enclavé" else ""
yield PropertyListing(
source=self.source_id, external_id=vid,
url=f"{SITE}/{type_s}-a-vendre-{city_s}-v{vid}",
title=addr or f"{typ} — {ville}",
address=addr,
city=ville,
property_type=typ or type_s.replace("-", " "),
price=parse_price(prix),
price_label=prix,
description=desc[:1000],
images=[img.group(1)] if img else [],
agency=AGENCY,
)
# ---------------------------------------------------------------------------
def parse_logisqc_detail(html: str) -> dict:
"""JSON-LD SingleFamilyResidence (adresse/géo/postal) + Product (prix,
description) ; n° MLS dans le HTML ; galerie i.logisquebec.com complète."""
out: dict = {}
details: dict = {}
for node in du.ld_nodes(html):
t = node.get("@type")
if t in ("SingleFamilyResidence", "Residence", "Apartment"):
addr = node.get("address") or {}
if addr.get("streetAddress"):
details["_addr"] = str(addr["streetAddress"])
if addr.get("addressLocality"):
details["_city"] = str(addr["addressLocality"])
if addr.get("postalCode"):
details["postal_code"] = str(addr["postalCode"])
geo = node.get("geo") or {}
try:
lat, lng = float(geo["latitude"]), float(geo["longitude"])
# le géocodage du site déraille parfois (Guadeloupe, Floride…) :
# on ne garde que des coordonnées plausibles pour le Québec
if 44.0 <= lat <= 63.0 and -80.5 <= lng <= -56.0:
out["lat"], out["lng"] = lat, lng
except (KeyError, TypeError, ValueError):
pass
if node.get("numberOfRooms"):
details["Nombre de pièces"] = node["numberOfRooms"]
elif t == "Product":
if node.get("description"):
out["description"] = _html.unescape(str(node["description"])).strip()[:4000]
offers = node.get("offers") or {}
p = offers.get("price") if isinstance(offers, dict) else None
try:
out["price"] = float(p)
out["price_label"] = f"{float(p):,.0f} $".replace(",", " ")
except (TypeError, ValueError):
pass
mm = _MLS_RE.search(html)
if mm:
details["_mls"] = mm.group(1)
# galerie : tableau lightGallery (src relatifs, ordre de la fiche, pleine
# taille) ; repli sur les URL absolues du vid canonique (sans /thumbnail/
# ni couvertures des « propriétés similaires »)
mc = _CANON_RE.search(html)
want = mc.group(1) if mc else None
seen: set = set()
imgs = []
for rel in _GALLERY_RE.findall(html):
if "/thumbnail/" in rel:
continue
u = _IMG_HOST + rel
if u not in seen:
seen.add(u)
imgs.append(u)
if want:
imgs = [u for u in imgs if f"/{want}/" in u] or imgs
if not imgs:
buckets: dict = {}
for m in _PHOTO_RE.finditer(html):
_rel, _uid, vid, fname = m.groups()
u = m.group(0)
if fname.lower().startswith("thumbnail") or u in seen:
continue
seen.add(u)
buckets.setdefault(vid, []).append(u)
if want and want in buckets:
imgs = buckets[want]
elif buckets:
imgs = max(buckets.values(), key=len)
if imgs:
out["images"] = imgs
# specs du tableau « Libellé | Valeur » (texte aplati) : Chambre à coucher,
# Salle de bain/d'eau, Pièces, Aire habitable (pc/mc), Dimension terrain…
# (0 et « -- » = non renseigné). Prose « N chambres » gardée en repli.
text = du.flatten(html)
mb = (re.search(r"Chambre à coucher\s*\|\s*(\d{1,2})\b", text)
or re.search(r"(\d{1,2})\s*chambre", text, re.I))
if mb and int(mb.group(1)) > 0:
out["bedrooms"] = int(mb.group(1))
ms = (re.search(r"Salle de bain/d[’']eau\s*\|\s*(\d{1,2})\b", text)
or re.search(r"(\d{1,2})\s*salle[s]?\s*de\s*bain", text, re.I))
if ms and int(ms.group(1)) > 0:
out["bathrooms"] = int(ms.group(1))
mp2 = re.search(r"Pièces\s*\|\s*(\d{1,2})\b", text)
if mp2 and int(mp2.group(1)) > 0:
details.setdefault("Nombre de pièces", int(mp2.group(1)))
my = re.search(r"Année de construction\s*\|?\s*:?\s*\|?\s*([12]\d{3})\b", text)
if my:
out["year_built"] = int(my.group(1))
ma = re.search(r"Aire habitable\s*\|\s*([\d\s.,]+?)\s*(pc|pi2|pi²|pi|mc|m2|m²)\b",
text, re.I)
if ma:
v = _num(ma.group(1))
if v:
out["area_sqft"] = round(v * (10.7639 if ma.group(2).lower()
in ("mc", "m2", "m²") else 1), 0)
# « Dimension terrain | 26.02x25.29 mc » (LxW) ou superficie directe
mt = re.search(r"Dimension terrain\s*\|\s*([\d.,]+)\s*[xX]\s*([\d.,]+)"
r"\s*(pc|pi|mc|m)?\b", text)
if mt:
a, b = _num(mt.group(1)), _num(mt.group(2))
if a and b:
f = 10.7639 if (mt.group(3) or "").lower() in ("mc", "m") else 1
out["lot_sqft"] = round(a * b * f, 0)
else:
mt2 = re.search(r"Dimension terrain\s*\|\s*([\d\s.,]+?)\s*(pc|mc)\b", text)
if mt2:
v = _num(mt2.group(1))
if v:
out["lot_sqft"] = round(v * (10.7639 if mt2.group(2).lower() == "mc"
else 1), 0)
mbat = re.search(r"Dimension bâtiment\s*\|\s*([\d][\d\sxX.,]{1,20}?(?:pc|pi|mc|m)?)\s*\|",
text)
if mbat:
details["Dimension bâtiment"] = mbat.group(1).strip()
for lab in ("Étages", "Garage", "Stationnement privé"):
mv = re.search(re.escape(lab) + r"\s*\|\s*(\d{1,3})\b", text)
if mv and int(mv.group(1)) > 0:
details[lab] = int(mv.group(1))
# description HTML de la fiche (le JSON-LD Product la tronque souvent)
mdsc = re.search(r'class="content-annonce-description-text"[^>]*>(.*?)',
html, re.S)
if mdsc:
d = re.sub(r"\s+", " ",
_html.unescape(re.sub(r"<[^>]+>", " ", mdsc.group(1)))).strip()
if len(d) > len(out.get("description") or ""):
out["description"] = d[:4000]
if details:
out["details"] = details
return out
def _num(s: str) -> float | None:
try:
return float(s.replace(" ", "").replace(" ", "").replace(",", "."))
except (AttributeError, ValueError):
return None