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/mercini.py : connecteur Groupe Mercini (Gestion Faleschini Mercier)5# (groupemercini.com — Limoilou, Duberger, Sainte-Foy, Beauport, Charlesbourg,6# Saint-Sauveur, Sillery… + Lévis). WordPress + thème WpResidence, tout est7# rendu serveur. Liste /appartements/ paginée (12 cartes/page) : titre complet8# (alt de l'image), catégorie (« 4½ », « Studio ou 2½ », « Chambre »…), statut9# (« Disponible pour visite », « Bientôt disponible »), prix, chambres/sdb.10# Fiche détail (via cache BD) : adresse complète avec code postal11# (.property_categs), secteur (taxonomie property_city), description riche12# (n° de référence REF…, inclusions, animaux…), caractéristiques, galerie13# complète (#owl-demo). Les lat/lng du thème sont erronées (latitude négative14# identique partout) : ignorées, le géocodeur Lou-Ka prend le relais.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re20import unicodedata2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price25from .base import BaseConnector2627BASE = "https://groupemercini.com"28LIST_URL = f"{BASE}/appartements/"2930# suffixe de redimensionnement WordPress (« -525x328.jpg » -> pleine taille)31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)32_BG_URL_RE = re.compile(r"background-image\s*:\s*url\(([^)]+)\)")33_REF_RE = re.compile(r"\bREF\w+")343536def _plain(txt: str) -> str:37 """Normalise l'Unicode décoratif du site (𝐏𝐫𝐢𝐱 -> Prix, 𝘙𝘦́𝘧 -> Réf)."""38 return unicodedata.normalize("NFKC", txt or "")394041def _clean_img(url: str) -> str:42 return _SIZE_SUFFIX.sub("", url.strip())434445class MerciniConnector(BaseConnector):46 source_id = "mercini"47 request_delay = 0.648 max_pages = 10 # garde-fou de pagination (3 pages actuellement)49 max_details = 60 # garde-fou fiches détail (vraies requêtes par sync)5051 def fetch(self) -> list[Listing]:52 listings: dict[str, Listing] = {}53 for page in range(1, self.max_pages + 1):54 url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"55 try:56 html = self.get(url).text57 except Exception:58 break59 soup = BeautifulSoup(html, "html.parser")60 cards = soup.select("div.property_listing")61 if not cards:62 break63 for card in cards:64 try:65 self._parse_card(card, listings)66 except Exception:67 continue6869 # fiches détail (cache BD) : adresse complète, secteur, description,70 # caractéristiques, galerie — max self.max_details vraies requêtes71 self._fetched = 072 for lst in listings.values():73 key = hashlib.sha1(74 f"{lst.title}|{lst.price_label}|{lst.availability}"75 .encode("utf-8")).hexdigest()76 try:77 payload = self.detail(lst.external_id, key,78 lambda u=lst.url: self._fetch_detail(u))79 except Exception:80 continue81 self._apply_detail(lst, payload)82 return list(listings.values())8384 # -- carte liste (WpResidence) ----------------------------------------------85 def _parse_card(self, card, listings: dict[str, Listing]) -> None:86 link = card.select_one('h4 a[href*="/estate_property/"]')87 if not link:88 return89 url = link["href"]90 m = re.search(r"/estate_property/([^/]+)/?", url)91 if not m:92 return93 ext_id = m.group(1) # slug WordPress stable94 if ext_id in listings:95 return9697 # titre complet : alt de l'image (le h4 est tronqué par le thème)98 img = card.select_one("img[alt]")99 title = _plain(img["alt"].strip()) if img and img.get("alt") else ""100 if not title:101 title = link.get_text(strip=True)102103 # statut (« Disponible pour visite », « Bientôt disponible », « Loué »)104 ribbon = card.select_one(".ribbon-inside")105 status = ribbon.get_text(strip=True) if ribbon else ""106 if re.search(r"lou[ée]", status, re.I):107 return # déjà loué108109 # disponibilité : « Disponible : … » ou « Livraison … » du titre ;110 # sinon le statut, seulement s'il parle de disponibilité (pas « Nouveau »)111 availability = ""112 m_av = re.search(r"(?:Disponible\s*:|Livraison\s*:?)\s*([^|]+)", title, re.I)113 if m_av:114 availability = m_av.group(1).strip()115 elif re.search(r"disponible", status, re.I):116 availability = status117118 # secteur affiché dans le titre (« … | Secteur : Duberger | … »)119 sector = ""120 m_sec = re.search(r"Secteur\s*:\s*([^|]+)", title, re.I)121 if m_sec:122 sector = m_sec.group(1).strip()123124 # type d'unité : « 4-1/2 » du titre, sinon la catégorie de la carte125 tag = card.select_one(".action_tag_wrapper")126 category = tag.get_text(strip=True) if tag else ""127 unit_type = normalize_unit_type(title.replace("-1/2", " 1/2"))128 if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo",129 unit_type or ""):130 unit_type = normalize_unit_type(category)131132 price_el = card.select_one('[class*="propery_price"]')133 price_label = price_el.get_text(" ", strip=True) if price_el else ""134135 # chambres / salles de bain structurées sur la carte136 amenities: list[str] = []137 rooms = card.select_one('[class^="inforoom"]')138 if rooms:139 amenities.append(rooms.get_text(" ", strip=True)) # « 2 Chambres »140 baths = card.select_one('[class^="infobath"]')141 if baths:142 txt = baths.get_text(" ", strip=True) # « 1.5 Baths »143 m_b = re.match(r"([\d.]+)", txt)144 if m_b:145 amenities.append(f"{m_b.group(1)} salle(s) de bain")146147 images = []148 for im in card.select(".carousel-item img[src]"):149 u = _clean_img(im["src"])150 if u.startswith("http") and u not in images:151 images.append(u)152153 details: dict = {}154 if status:155 details["status"] = status156 if category:157 details["category"] = category158159 listings[ext_id] = Listing(160 source=self.source_id,161 external_id=ext_id,162 url=url,163 title=title,164 sector=sector,165 city="Québec", # affiné par la fiche détail166 unit_type=unit_type,167 price=parse_price(price_label),168 price_label=price_label,169 availability=availability,170 amenities=amenities,171 details=details,172 images=images,173 )174175 # -- fiche détail (WpResidence) ----------------------------------------------176 def _fetch_detail(self, url: str) -> dict:177 """Adresse complète + taxonomies, description, caractéristiques,178 chambres/sdb (overview), galerie complète (#owl-demo)."""179 if self._fetched >= self.max_details:180 raise RuntimeError("budget de fiches détail atteint")181 self._fetched += 1182 html = self.get(url).text183 soup = BeautifulSoup(html, "html.parser")184 out: dict = {}185186 # « 3135 Rue Dubé, Québec, QC G1M 2N2, Duberger, Québec, Québec » :187 # le texte hors liens = adresse civique ; les liens = taxonomies188 # property_city (secteur) et property_area (ville)189 categs = soup.select_one(".property_categs")190 if categs:191 labels: list[str] = []192 for a in categs.select("a"):193 t = a.get_text(strip=True)194 if t and t not in labels:195 labels.append(t)196 a.extract() # ne garder que l'adresse en texte197 address = re.sub(r"\s+", " ", categs.get_text(" ", strip=True))198 out["address"] = address.strip(" ,")199 out["taxonomies"] = labels200201 # description riche (bullets ▪️ : inclusions, animaux, référence…)202 desc_el = soup.select_one(".wpestate_property_description")203 if desc_el:204 txt = _plain(desc_el.get_text("\n", strip=True))205 txt = re.sub(r"^Description\n", "", txt)206 txt = re.sub(r"[ \t]+", " ", txt)207 out["description"] = txt.strip()[:1500]208 m_ref = _REF_RE.search(out["description"])209 if m_ref:210 out["reference"] = m_ref.group(0)211212 # overview : « 2 Chambres », « 1.5 Salles de bain »213 out["overview"] = [214 re.sub(r"\s+", " ", el.get_text(" ", strip=True))215 for el in soup.select(".overview_element")216 if re.search(r"chambre|salle", el.get_text(), re.I)]217218 # caractéristiques (« Entrée indépendante », « Stationnement extérieur »)219 feats: list[str] = []220 for el in soup.select(".listing_detail"):221 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))222 if t and not t.lower().startswith("other features") and t not in feats:223 feats.append(t)224 out["features"] = feats[:20]225226 # galerie complète : carrousel #owl-demo (background-image 1170x660)227 images: list[str] = []228 owl = soup.select_one("#owl-demo")229 for div in owl.select("div.item[style]") if owl else []:230 m = _BG_URL_RE.search(div["style"])231 if m:232 u = _clean_img(m.group(1).strip("'\" "))233 if u.startswith("http") and u not in images:234 images.append(u)235 out["images"] = images[:30]236 return out237238 def _apply_detail(self, lst: Listing, d: dict) -> None:239 """Reporte le payload (frais ou en cache) sur l'annonce."""240 if not d:241 return242 if d.get("address"):243 lst.address = d["address"]244 # taxonomies : secteur (Duberger, Limoilou…) + ville (Québec/Lévis)245 labels = d.get("taxonomies") or []246 if any("lévis" in t.lower() or "levis" in t.lower() for t in labels):247 lst.city = "Lévis"248 sectors = [t for t in labels if t not in ("Québec", "Quebec", "Lévis", "Levis")]249 if sectors and not lst.sector:250 lst.sector = sectors[0]251 if d.get("description"):252 lst.description = d["description"]253 if d.get("reference"):254 lst.details["reference"] = d["reference"]255 extra = (d.get("overview") or []) + (d.get("features") or [])256 if extra:257 lst.amenities = list(dict.fromkeys(lst.amenities + extra))258 if d.get("images"):259 lst.images = d["images"]260