# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/carat_immobilier.py : connecteur Carat Immobilier # (caratimmobilier.ca — Rouyn-Noranda, Abitibi-Témiscamingue). WordPress + # thème immobilier Houzez (cartes v2), tout rendu serveur — même famille que # gimcote.py / immeubles_bc.py. Archive /appartements/ : cartes # `.item-listing-wrap` (statut « Disponible… », prix « 1 ,058$ », adresse # complète, galerie dans data-images). Fiche détail /property// (via # cache BD) : description, commodités (#property-features-wrap), bloc # « Détails » structuré (type 4 1/2, chambres, salle de bain, superficie) et # bloc adresse (ville, quartier, immeuble). Pas de GPS réel (carte Houzez # sur coordonnées par défaut). robots.txt Yoast ouvert, sitemap XML. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import json import re from bs4 import BeautifulSoup from ..schema import (Listing, normalize_unit_type, parse_area_sqft, parse_price, strip_accents) from .base import BaseConnector BASE = "https://caratimmobilier.ca" LIST_URL = f"{BASE}/appartements/" # suffixe de redimensionnement WordPress (« -584x438.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) def _clean_price_label(label: str) -> str: """'1 ,058$' (Houzez) -> '1058$' compatible parse_price (virgule = milliers).""" label = re.sub(r"(\d)\s*,\s*(\d{3})", r"\1\2", label) return re.sub(r"\s+", " ", label).strip() class CaratImmobilierConnector(BaseConnector): source_id = "carat_immobilier" request_delay = 0.6 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for card in soup.select("div.item-listing-wrap"): try: self._parse_card(card, listings) except Exception: continue # fiches détail (cache BD) : description, commodités, type, superficie self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte Houzez v2 --------------------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one("h2.item-title a[href]") \ or card.select_one('a[href*="/property/"]') if not link: return url = link["href"] m = re.search(r"/property/([^/?#]+)", url) slug = m.group(1) if m else "" ext_id = card.get("data-hz-id") or slug if not ext_id or str(ext_id) in listings: return title = link.get_text(strip=True) # exclusions : commercial / stationnement / rangement if re.search(r"commercial|bureau|local|stationnement|garage|entrep[oô]t", title, re.I): return # statut (« Disponible 1er septembre », « Loué ») — on saute les loués status_el = card.select_one(".label-status") availability = status_el.get_text(strip=True) if status_el else "" if re.search(r"lou[ée]", availability, re.I): return # adresse complète : « 769 Av. Murdoch, Rouyn-Noranda, QC J9X 1H9, Canada » addr_el = card.select_one("address.item-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" city = "Rouyn-Noranda" # tout le parc est à Rouyn-Noranda parts = [p.strip() for p in address.split(",") if p.strip()] for p in parts[1:]: if not re.match(r"^(QC|Québec|Quebec|Canada|[A-Z]\d[A-Z])", p, re.I): city = re.sub(r"\s+(QC|Québec|Quebec).*$", "", p, flags=re.I).strip() or city break price_el = card.select_one("li.item-price") price_label = _clean_price_label( price_el.get_text(strip=True)) if price_el else "" # galerie complète : attribut data-images (JSON, URLs redimensionnées) images: list[str] = [] raw = card.get("data-images") or "" if raw: try: entries = json.loads(htmllib.unescape(raw)) urls = [e.get("image", "") for e in entries if isinstance(e, dict)] except Exception: urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw)) for u in urls: u = u.replace("\\/", "/").strip() if not u.startswith("http"): continue u = _SIZE_SUFFIX.sub("", u) # version pleine taille (WordPress) if u not in images: images.append(u) if not images: thumb = card.select_one("img.wp-post-image[src]") if thumb: images = [_SIZE_SUFFIX.sub("", thumb["src"])] # type d'unité : « 4 ½ » présent dans le titre de l'annonce unit_type = "" m_type = re.search(r"\b(\d)\s*(?:1/2|½)", title) if m_type: unit_type = normalize_unit_type(f"{m_type.group(1)} 1/2") listings[str(ext_id)] = Listing( source=self.source_id, external_id=str(ext_id), url=url, title=title, address=address, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, images=images[:30], ) # -- fiche détail (Houzez) ----------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description, commodités, bloc « Détails » et bloc adresse.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} desc_el = soup.select_one("#property-description-wrap") if desc_el: txt = desc_el.get_text("\n", strip=True) txt = re.sub(r"^Description\n", "", txt) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] out["amenities"] = [a.get_text(" ", strip=True) for a in soup.select("#property-features-wrap li") if a.get_text(strip=True)][:20] # bloc « Détails » : paires labelvaleur fields: dict[str, str] = {} for li in soup.select("#property-detail-wrap li"): strong = li.select_one("strong") span = li.select_one("span") if strong and span: key = strip_accents(strong.get_text(strip=True).lower()) fields[key] = span.get_text(" ", strip=True) out["fields"] = fields # bloc adresse : « Ville: Rouyn Noranda », « Quartier: Rouyn »… for li in soup.select("#property-address-wrap li"): txt = li.get_text(" ", strip=True) m = re.match(r"(Ville|Quartier)\s*:\s*(.+)$", txt, re.I) if m: out[strip_accents(m.group(1).lower())] = m.group(2).strip() return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return desc = d.get("description") or "" if desc and desc.strip() != lst.address.strip(): lst.description = desc fields = d.get("fields") or {} extra: list[str] = [] if fields.get("chambres"): extra.append(f"{fields['chambres']} chambre(s)") if fields.get("salle de bain"): extra.append(f"{fields['salle de bain']} salle(s) de bain") if d.get("amenities") or extra: lst.amenities = list(dict.fromkeys( lst.amenities + extra + (d.get("amenities") or []))) if not lst.unit_type and fields.get("type de propriete"): lst.unit_type = normalize_unit_type(fields["type de propriete"]) if lst.area_sqft is None and fields.get("superficie"): lst.area_sqft = parse_area_sqft(fields["superficie"]) if d.get("quartier"): lst.sector = d["quartier"]