# ----------------------------------------------------------------------------- # 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