# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/guidehabitation.py : Guide Habitation (guidehabitation.ca) # Répertoire de référence des PROJETS NEUFS au Québec (condos et maisons # neuves des promoteurs). Pages région /fr/projets-immobiliers/{region}/ # rendues serveur avec microdonnées schema.org complètes par carte projet : # adresse (streetAddress), ville, GPS, prix « À partir de » (itemprop=price), # type (SingleFamilyResidence → maison, ApartmentComplex → condo), # description, chambres offertes, photo. Aucune clé, aucun rendu JS. # On écarte les projets LOCATIFS (prix mensuel / « locatif » dans le texte) : # Immo-Ka couvre le neuf À VENDRE. external_id = data-id du projet. # # Page détail projet : UN gros JSON-LD RealEstateListing très riche — # about.articleBody (présentation longue, pseudo-markdown), mainEntity.image # (galerie complète pleine résolution, ordre d'origine), mainEntity.geo, # amenityFeature, offers[] (promoteur offeredBy : nom/téléphone/site, # chambres, garantie, date de livraison), additionalProperty (statut du # projet, site web externe). Enrichissement plafonné + cache (du.enrich). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..schema import PropertyListing SITE = "https://www.guidehabitation.ca" DETAIL_LIMIT = int(os.environ.get("IMMOKA_GH_DETAIL_LIMIT", "150")) # Régions du répertoire (repli si le sitemap est indisponible). REGIONS = ["montreal", "monteregie", "laval", "laurentides", "lanaudiere", "quebec", "estrie", "outaouais", "mauricie"] _SITEMAP_REGION_RE = re.compile(r'/fr/projets-immobiliers/([a-z-]+)/') _CARD_SPLIT_RE = re.compile(r'(?=
]+src="([^"]+)"') _META_RE = re.compile(r'itemprop="(name|streetAddress|addressLocality|latitude|' r'longitude|price)"[^>]*content="([^"]*)"') _LOCALITY_RE = re.compile(r'itemprop="addressLocality">([^<]*)<') _NAME_H3_RE = re.compile(r'

([^<]*)

') _DESC_RE = re.compile(r'itemprop="description">([^<]*)<') _TYPE_RE = re.compile(r'itemtype="https://schema\.org/(SingleFamilyResidence|' r'ApartmentComplex|Residence)"') _BEDROOMS_RE = re.compile(r'class="card-bedrooms">([^<]*)<') _PRICE_TXT_RE = re.compile(r'À partir de\s*([\d\s ,.]+)\s*\$') _RENTAL_RE = re.compile(r'locati|à louer|for rent', re.I) _SQFT_RE = re.compile(r'[Ss]uperficie habitable[\s ]*:?[\s ]*' r'([\d][\d\s  ,]*)\s*pi') # En deçà de ce prix « à partir de », c'est un loyer mensuel (projet locatif). MIN_SALE_PRICE = 50_000 class GuideHabitationConnector(BaseConnector): source_id = "guidehabitation" request_delay = 1.0 def _regions(self) -> list[str]: try: xml = self.get(f"{SITE}/sitemap.xml").text found = sorted({m.group(1) for m in _SITEMAP_REGION_RE.finditer(xml)}) if found: return found except Exception: pass return REGIONS def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} for region in self._regions(): try: html = self.get(f"{SITE}/fr/projets-immobiliers/{region}/").text except Exception: continue for card in _CARD_SPLIT_RE.split(html): if 'data-id="' not in card: continue lst = self._to_listing(card, region) if lst and lst.external_id not in by_id: by_id[lst.external_id] = lst listings = list(by_id.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_gh_detail, key="v1") return listings def _to_listing(self, card: str, region: str) -> PropertyListing | None: mid = _ID_RE.search(card) murl = _URL_RE.search(card) if not mid or not murl: return None meta = {k: _html.unescape(v).strip() for k, v in _META_RE.findall(card)} name = meta.get("name") or "" mh3 = _NAME_H3_RE.search(card) if mh3: name = _html.unescape(mh3.group(1)).strip() or name desc = "" mdesc = _DESC_RE.search(card) if mdesc: desc = _html.unescape(mdesc.group(1)).strip() # prix « À partir de » — absent ou mensuel (locatif) → hors périmètre price = None try: price = float(meta.get("price")) except (TypeError, ValueError): mtxt = _PRICE_TXT_RE.search(card) if mtxt: try: price = float(re.sub(r"[\s ,]", "", mtxt.group(1))) except ValueError: price = None if price is None or price < MIN_SALE_PRICE: return None if _RENTAL_RE.search(name) or _RENTAL_RE.search(desc): return None mtype = _TYPE_RE.search(card) ptype = {"SingleFamilyResidence": "Maison", "ApartmentComplex": "Condo"}.get( mtype.group(1) if mtype else "", "") city = meta.get("addressLocality") or "" if not city: mloc = _LOCALITY_RE.search(card) if mloc: city = _html.unescape(mloc.group(1)).strip() address = meta.get("streetAddress") or "" # « Chemin du Golf, Sainte-Julie, QC, Canada » → rue seulement address = re.sub(r",\s*(QC|Québec|Quebec)\b.*$", "", address).strip() if city and address.lower().endswith(", " + city.lower()): address = address[: -(len(city) + 2)].strip().rstrip(",") lst = PropertyListing( source=self.source_id, external_id=mid.group(1), url=murl.group(1), title=name, address=address or name, city=city, property_type=ptype, price=price, price_label=f"À partir de {price:,.0f} $".replace(",", " "), description=desc, broker_name="Guide Habitation — projet neuf", agency="Guide Habitation (projets neufs)", ) mimg = _IMG_RE.search(card) if mimg: lst.images = [mimg.group(1)] try: lst.lat = float(meta.get("latitude")) lst.lng = float(meta.get("longitude")) except (TypeError, ValueError): pass mbeds = _BEDROOMS_RE.search(card) if mbeds: beds = re.findall(r"(\d+)\s*ch", mbeds.group(1)) if beds: lst.bedrooms = int(beds[0]) # minimum offert lst.details["Chambres offertes"] = _html.unescape( mbeds.group(1)).strip() lst.details["Projet neuf"] = True return lst def _clean_md(text: str) -> str: """articleBody pseudo-markdown -> texte propre (garde les paragraphes).""" t = _html.unescape(text).replace("\xa0", " ") t = re.sub(r"\*\*+", "", t) # **gras** t = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", t) # [lien](url) t = re.sub(r"^[\s]*[-•#]+\s*", "", t, flags=re.M) # puces/titres t = re.sub(r"[ \t]+", " ", t) return re.sub(r"\n{3,}", "\n\n", t).strip() def _parse_gh_detail(html: str) -> dict: """Fiche projet Guide Habitation : tout est dans le JSON-LD RealEstateListing.""" out: dict = {} node = None for n in du.ld_nodes(html): t = n.get("@type") if "RealEstateListing" in (t if isinstance(t, list) else [t]): node = n break if not node: return out about = node.get("about") or {} body = str(about.get("articleBody") or "") if body: desc = _clean_md(body) if desc: out["description"] = desc main = node.get("mainEntity") or {} imgs = main.get("image") or [] if isinstance(imgs, str): imgs = [imgs] imgs = [u for u in imgs if isinstance(u, str) and u.startswith("http")] if imgs: out["images"] = imgs geo = main.get("geo") or {} try: lat, lng = float(geo.get("latitude")), float(geo.get("longitude")) if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0: out["lat"], out["lng"] = lat, lng except (TypeError, ValueError): pass details: dict = {} features: list[str] = [] for af in main.get("amenityFeature") or []: v = str(af.get("value") or "").strip() if v and v.lower() not in ("true", "false"): features.append(v) elif v.lower() == "true" and af.get("name"): features.append(str(af["name"])) if features: out["features"] = features for ap in node.get("additionalProperty") or []: pid, val = ap.get("propertyID") or "", str(ap.get("value") or "").strip() if not val: continue if pid == "gh:status": details["Statut du projet"] = val elif pid == "gh:deliveryDate": details["Livraison"] = val.split(" ")[0] elif ap.get("name") == "externalUrl": details["Site du projet"] = val # offres (par modèle) : promoteur, chambres min, garantie offers = node.get("offers") or [] if isinstance(offers, dict): offers = [offers] beds = [] for of in offers: item = of.get("itemOffered") or {} try: beds.append(int(item.get("numberOfBedrooms"))) except (TypeError, ValueError): pass if of.get("warranty") and "Garantie" not in details: details["Garantie"] = str(of["warranty"]) by = of.get("offeredBy") or {} if by.get("name") and "broker_name" not in out: out["broker_name"] = str(by["name"]) if by.get("telephone"): out["broker_phone"] = str(by["telephone"]) if beds: out["bedrooms"] = min(beds) # superficies habitables des modèles (texte de présentation) : minimum # offert (cohérent avec le prix « à partir de ») areas = [] for m in _SQFT_RE.finditer(body): try: areas.append(float(re.sub(r"[^\d]", "", m.group(1)))) except ValueError: pass areas = [a for a in areas if 300 <= a <= 20000] if areas: out["area_sqft"] = min(areas) if len(areas) > 1: details["Superficies offertes"] = ( f"{min(areas):,.0f} à {max(areas):,.0f} pi²".replace(",", " ")) if details: out["details"] = details return out