# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/simard.py : connecteur Immeubles Simard (immeublessimard.com) # Section « À louer » — catégorie appartement uniquement (les bureaux, # commerces et laboratoires sont exclus d'office). Pages détail (cache BD # self.detail) : bloc specs étiqueté (#infos : pièces, prix, superficie, # disponibilité), « Caractéristiques et inclusions », description, images. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import (Listing, infer_city, merge_details, normalize_unit_type, strip_accents) from .base import BaseConnector BASE = "https://immeublessimard.com" LIST_URL = f"{BASE}/a-louer/categorie/appartement/" DETAIL_RE = re.compile(r"/a-louer/appartement/([a-z0-9\-]+)/([a-z0-9\-]+)/?$") IMG_RE = re.compile( r"https://immeublessimard\.com/wp-content/uploads/[^\"'\\\s\)]+" r"\.(?:jpg|jpeg|png|webp)", re.I) IMG_NOISE_RE = re.compile(r"logo|favicon|icon", re.I) # Prix affiché « $1975.00 » ou « 1 975$ » PRICE_RE = re.compile(r"(?:\$\s*([\d\s,]+(?:\.\d{2})?)|([\d][\d\s]*(?:,\d{2})?)\s*\$)") def _parse_price(text: str) -> float | None: """Gère les deux formats : « $1975.00 » et « 1 975$ ».""" if not text: return None s = text.replace(" ", " ").replace(" ", " ") s = re.sub(r"(\d),(\d{3})", r"\1\2", s) # 1,975 -> 1975 m = PRICE_RE.search(s) if not m: return None num = (m.group(1) or m.group(2)).replace(" ", "").replace(",", ".") try: val = float(num) except ValueError: return None return val if 100 <= val <= 20000 else None class SimardConnector(BaseConnector): source_id = "simard" request_delay = 0.6 max_details = 60 # garde-fou 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 a[href*='/a-louer/appartement/']"): try: lst = self._parse_card(card) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # Pages détail (cache BD) : specs, prix, dispo, commodités, images fetched = 0 for i, lst in enumerate(listings.values()): if i >= self.max_details: break # « v3 » : version du format de payload (invalide les caches anciens) key = hashlib.sha1( f"v3|{lst.title}|{lst.description}".encode("utf-8")).hexdigest() def _fetch(url=lst.url): nonlocal fetched if fetched >= self.max_details: raise RuntimeError("plafond de requêtes détail atteint") fetched += 1 return self._fetch_detail(url) try: payload = self.detail(lst.external_id, key, _fetch) except Exception: payload = {} if payload: self._apply_detail(lst, payload) return list(listings.values()) def _parse_card(self, card) -> Listing | None: href = card.get("href", "").split("?")[0] m = DETAIL_RE.search(href) if not m: return None sector_slug, slug = m.groups() title_el = card.select_one("h2") sector_el = card.select_one(".secteur") bullets = [li.get_text(" ", strip=True) for li in card.select("li")] title = title_el.get_text(" ", strip=True) if title_el else slug sector = sector_el.get_text(strip=True) if sector_el else \ sector_slug.replace("-", " ").title() unit_type = "" for b in bullets + [title]: m2 = re.search(r"\d\s*(?:½|1/2)|studio|loft", b, re.I) if m2: unit_type = normalize_unit_type(m2.group(0)) break # certains titres SONT l'adresse civique (« 1276-24 Chanoine-Morel ») address = title if re.match(r"^\d{2,5}(?:-\d+[A-Za-z]?)?\s+\D", title) else "" return Listing( source=self.source_id, external_id=slug, url=href if href.startswith("http") else BASE + href, title=title, address=address, sector=sector, city=infer_city(sector), unit_type=unit_type, description=" • ".join(b for b in bullets if b)[:400], ) def _fetch_detail(self, url: str) -> dict: """Télécharge une fiche et en extrait le payload brut (cacheable).""" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # Bloc specs étiqueté (#infos) : « Nombre de pièces : 4½ », # « Prix : $1975 », « Superficie : 902 pi2 », « Disponibilité : … » specs: dict[str, str] = {} for li in soup.select("#infos ul li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) m = re.match(r"(Nombre de pièces|Prix|Superficie|Disponibilité)" r"\s*:\s*(.+)", t, re.I) if m: specs[strip_accents(m.group(1).lower())] = m.group(2).strip() # statut affiché dans l'entête (« Disponible », « Loué ») status_el = soup.select_one("#infos p.details .text-right") status = status_el.get_text(" ", strip=True) if status_el else "" # bloc « Location : » — contact structuré (tel:/mailto:) contact: dict = {} tel = soup.select_one("#infos a[href^='tel:']") if tel: digits = re.sub(r"\D", "", tel.get("href", "")) if len(digits) == 10: contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" mail = soup.select_one("#infos a[href^='mailto:']") if mail: m = re.match(r"mailto:([^?]+)", mail.get("href", "")) if m: contact["email"] = m.group(1).strip() # « Caractéristiques et inclusions » + « Description » amenities: list[str] = [] desc_parts: list[str] = [] contenu = soup.select_one("section .contenu") if contenu: for h2 in contenu.select("h2"): title = h2.get_text(" ", strip=True) if re.search(r"Caractéristiques", title, re.I): ul = h2.find_next_sibling("ul") for li in (ul.select("li") if ul else []): t = li.get_text(" ", strip=True) if t and len(t) < 80 and t not in amenities: amenities.append(t) elif re.search(r"Description", title, re.I): for p in h2.find_next_siblings("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if t: desc_parts.append(t) desc = " ".join(desc_parts) if not desc: # repli : meta description og = soup.find("meta", attrs={"property": "og:description"}) or \ soup.find("meta", attrs={"name": "description"}) desc = (og.get("content") or "").strip() if og else "" imgs = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not IMG_NOISE_RE.search(u)] # éliminer les variantes redimensionnées quand l'originale est là originals = {re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) for u in imgs} images = [u for u in imgs if re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) == u or re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) not in originals][:25] return {"specs": specs, "status": status, "amenities": amenities, "contact": contact, "description": desc[:1200], "images": images} def _apply_detail(self, lst: Listing, payload: dict) -> None: """Applique le payload d'une fiche (frais ou depuis le cache BD).""" specs = payload.get("specs") or {} if specs.get("prix"): price = _parse_price(specs["prix"]) if price: lst.price = price lst.price_label = specs["prix"] if lst.price is None: # repli : prix dans la description (« Prix: $1975.00 … ») en # ignorant les mentions de stationnement (« Stationnement; $90.00 ») cleaned = re.sub(r"[Ss]tationnement[^.]{0,40}\$\s*[\d.,]+", " ", payload.get("description") or "") price = _parse_price(cleaned) if price: lst.price = price m = PRICE_RE.search(re.sub(r"(\d),(\d{3})", r"\1\2", cleaned)) lst.price_label = m.group(0).strip() if m else "" if specs.get("disponibilite"): lst.availability = f"Disponibilité : {specs['disponibilite']}" elif payload.get("status") and re.search(r"disponible", payload["status"], re.I): lst.availability = payload["status"] if specs.get("nombre de pieces"): lst.unit_type = normalize_unit_type(specs["nombre de pieces"]) if not lst.unit_type: m = re.search(r"\d\s*(?:½|1/2)|studio|loft", payload.get("description") or "", re.I) if m: lst.unit_type = normalize_unit_type(m.group(0)) amenities = list(payload.get("amenities") or []) # superficie : texte source (« 902 pi2 ») ajouté aux commodités pour # affichage + parsing par finalize(). Quirk du site : le champ # contient parfois un type d'unité (« Superficie : 4 ½ »). surf = specs.get("superficie") or "" if re.search(r"\d\s*(?:pi|m)\b|pi[²2]|m[²2]", surf): amenities.append(f"Superficie : {surf}") elif surf and not lst.unit_type: lst.unit_type = normalize_unit_type(surf) if amenities: lst.amenities = amenities if payload.get("contact"): lst.details = merge_details(lst.details, {"contact": payload["contact"]}) if payload.get("description"): lst.description = payload["description"] if payload.get("images"): lst.images = payload["images"]