spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/carat_immobilier.py : connecteur Carat Immobilier5# (caratimmobilier.ca — Rouyn-Noranda, Abitibi-Témiscamingue). WordPress +6# thème immobilier Houzez (cartes v2), tout rendu serveur — même famille que7# gimcote.py / immeubles_bc.py. Archive /appartements/ : cartes8# `.item-listing-wrap` (statut « Disponible… », prix « 1 ,058$ », adresse9# complète, galerie dans data-images). Fiche détail /property/<slug>/ (via10# cache BD) : description, commodités (#property-features-wrap), bloc11# « Détails » structuré (type 4 1/2, chambres, salle de bain, superficie) et12# bloc adresse (ville, quartier, immeuble). Pas de GPS réel (carte Houzez13# sur coordonnées par défaut). robots.txt Yoast ouvert, sitemap XML.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import html as htmllib19import json20import re2122from bs4 import BeautifulSoup2324from ..schema import (Listing, normalize_unit_type, parse_area_sqft,25 parse_price, strip_accents)26from .base import BaseConnector2728BASE = "https://caratimmobilier.ca"29LIST_URL = f"{BASE}/appartements/"3031# suffixe de redimensionnement WordPress (« -584x438.jpg » -> pleine taille)32_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)333435def _clean_price_label(label: str) -> str:36 """'1 ,058$' (Houzez) -> '1058$' compatible parse_price (virgule = milliers)."""37 label = re.sub(r"(\d)\s*,\s*(\d{3})", r"\1\2", label)38 return re.sub(r"\s+", " ", label).strip()394041class CaratImmobilierConnector(BaseConnector):42 source_id = "carat_immobilier"43 request_delay = 0.644 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync)4546 def fetch(self) -> list[Listing]:47 html = self.get(LIST_URL).text48 soup = BeautifulSoup(html, "html.parser")4950 listings: dict[str, Listing] = {}51 for card in soup.select("div.item-listing-wrap"):52 try:53 self._parse_card(card, listings)54 except Exception:55 continue5657 # fiches détail (cache BD) : description, commodités, type, superficie58 self._fetched = 059 for lst in listings.values():60 key = hashlib.sha1(61 f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"62 .encode("utf-8")).hexdigest()63 try:64 payload = self.detail(lst.external_id, key,65 lambda u=lst.url: self._fetch_detail(u))66 except Exception:67 continue68 self._apply_detail(lst, payload)69 return list(listings.values())7071 # -- carte Houzez v2 ---------------------------------------------------------------72 def _parse_card(self, card, listings: dict[str, Listing]) -> None:73 link = card.select_one("h2.item-title a[href]") \74 or card.select_one('a[href*="/property/"]')75 if not link:76 return77 url = link["href"]78 m = re.search(r"/property/([^/?#]+)", url)79 slug = m.group(1) if m else ""80 ext_id = card.get("data-hz-id") or slug81 if not ext_id or str(ext_id) in listings:82 return83 title = link.get_text(strip=True)8485 # exclusions : commercial / stationnement / rangement86 if re.search(r"commercial|bureau|local|stationnement|garage|entrep[oô]t",87 title, re.I):88 return8990 # statut (« Disponible 1er septembre », « Loué ») — on saute les loués91 status_el = card.select_one(".label-status")92 availability = status_el.get_text(strip=True) if status_el else ""93 if re.search(r"lou[ée]", availability, re.I):94 return9596 # adresse complète : « 769 Av. Murdoch, Rouyn-Noranda, QC J9X 1H9, Canada »97 addr_el = card.select_one("address.item-address")98 address = addr_el.get_text(" ", strip=True) if addr_el else ""99 city = "Rouyn-Noranda" # tout le parc est à Rouyn-Noranda100 parts = [p.strip() for p in address.split(",") if p.strip()]101 for p in parts[1:]:102 if not re.match(r"^(QC|Québec|Quebec|Canada|[A-Z]\d[A-Z])", p, re.I):103 city = re.sub(r"\s+(QC|Québec|Quebec).*$", "", p, flags=re.I).strip() or city104 break105106 price_el = card.select_one("li.item-price")107 price_label = _clean_price_label(108 price_el.get_text(strip=True)) if price_el else ""109110 # galerie complète : attribut data-images (JSON, URLs redimensionnées)111 images: list[str] = []112 raw = card.get("data-images") or ""113 if raw:114 try:115 entries = json.loads(htmllib.unescape(raw))116 urls = [e.get("image", "") for e in entries if isinstance(e, dict)]117 except Exception:118 urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw))119 for u in urls:120 u = u.replace("\\/", "/").strip()121 if not u.startswith("http"):122 continue123 u = _SIZE_SUFFIX.sub("", u) # version pleine taille (WordPress)124 if u not in images:125 images.append(u)126 if not images:127 thumb = card.select_one("img.wp-post-image[src]")128 if thumb:129 images = [_SIZE_SUFFIX.sub("", thumb["src"])]130131 # type d'unité : « 4 ½ » présent dans le titre de l'annonce132 unit_type = ""133 m_type = re.search(r"\b(\d)\s*(?:1/2|½)", title)134 if m_type:135 unit_type = normalize_unit_type(f"{m_type.group(1)} 1/2")136137 listings[str(ext_id)] = Listing(138 source=self.source_id,139 external_id=str(ext_id),140 url=url,141 title=title,142 address=address,143 city=city,144 unit_type=unit_type,145 price=parse_price(price_label),146 price_label=price_label,147 availability=availability,148 images=images[:30],149 )150151 # -- fiche détail (Houzez) -----------------------------------------------------152 def _fetch_detail(self, url: str) -> dict:153 """Description, commodités, bloc « Détails » et bloc adresse."""154 if self._fetched >= self.max_details:155 raise RuntimeError("budget de fiches détail atteint")156 self._fetched += 1157 html = self.get(url).text158 soup = BeautifulSoup(html, "html.parser")159 out: dict = {}160161 desc_el = soup.select_one("#property-description-wrap")162 if desc_el:163 txt = desc_el.get_text("\n", strip=True)164 txt = re.sub(r"^Description\n", "", txt)165 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]166167 out["amenities"] = [a.get_text(" ", strip=True)168 for a in soup.select("#property-features-wrap li")169 if a.get_text(strip=True)][:20]170171 # bloc « Détails » : paires <strong>label</strong><span>valeur</span>172 fields: dict[str, str] = {}173 for li in soup.select("#property-detail-wrap li"):174 strong = li.select_one("strong")175 span = li.select_one("span")176 if strong and span:177 key = strip_accents(strong.get_text(strip=True).lower())178 fields[key] = span.get_text(" ", strip=True)179 out["fields"] = fields180181 # bloc adresse : « Ville: Rouyn Noranda », « Quartier: Rouyn »…182 for li in soup.select("#property-address-wrap li"):183 txt = li.get_text(" ", strip=True)184 m = re.match(r"(Ville|Quartier)\s*:\s*(.+)$", txt, re.I)185 if m:186 out[strip_accents(m.group(1).lower())] = m.group(2).strip()187 return out188189 def _apply_detail(self, lst: Listing, d: dict) -> None:190 """Reporte le payload (frais ou en cache) sur l'annonce."""191 if not d:192 return193 desc = d.get("description") or ""194 if desc and desc.strip() != lst.address.strip():195 lst.description = desc196197 fields = d.get("fields") or {}198 extra: list[str] = []199 if fields.get("chambres"):200 extra.append(f"{fields['chambres']} chambre(s)")201 if fields.get("salle de bain"):202 extra.append(f"{fields['salle de bain']} salle(s) de bain")203 if d.get("amenities") or extra:204 lst.amenities = list(dict.fromkeys(205 lst.amenities + extra + (d.get("amenities") or [])))206 if not lst.unit_type and fields.get("type de propriete"):207 lst.unit_type = normalize_unit_type(fields["type de propriete"])208 if lst.area_sqft is None and fields.get("superficie"):209 lst.area_sqft = parse_area_sqft(fields["superficie"])210 if d.get("quartier"):211 lst.sector = d["quartier"]212