# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/picard.py : connecteur Picard Immobilier (picardimmobilier.net) # La page /recherche/ liste tous les logements disponibles (rendu serveur), # cartes .property-box-2 avec lien /logement//ref=/. # Fiches détaillées : photos S3, disponibilité, description, commodités. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://picardimmobilier.net" SEARCH_URL = f"{BASE}/recherche/" SECTOR_LABELS = { "charny": "Charny", "les-saules": "Les Saules", "sainte-foy": "Sainte-Foy", "beauport": "Beauport", "limoilou": "Limoilou", "loretteville": "Loretteville", "vieux-quebec": "Vieux-Québec", "montcalm": "Montcalm", } IMG_RE = re.compile(r"https://picardimmobilier\.s3\.amazonaws\.com/media/" r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I) LATLNG_RE = re.compile(r'data-lat="(-?[\d.]+)"\s+data-lng="(-?[\d.]+)"') class _DetailBudget(Exception): """Plafond de requêtes de fiches atteint pour cette synchronisation.""" class PicardConnector(BaseConnector): source_id = "picard" request_delay = 0.6 max_details = 80 # garde-fou de fetch des fiches def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} try: html = self.get(SEARCH_URL).text except Exception: return [] soup = BeautifulSoup(html, "html.parser") for card in soup.select("div.property-box-2"): try: a = card.select_one('a[href*="ref="]') if not a: continue m = re.search(r"/logement/([a-z\-]+)/ref=(\d+)/", a.get("href", "")) if not m: continue sector_slug, ext_id = m.group(1), m.group(2) if ext_id in listings: continue sector = SECTOR_LABELS.get(sector_slug, sector_slug.replace("-", " ").title()) title_el = card.select_one("h3.title a") title = title_el.get_text(" ", strip=True) if title_el else "" # Exclure commercial / stationnement / rangement if re.search(r"Commercial|Stationnement|Rangement|Parking", title, re.I): continue loc_el = card.select_one("h5.location a") address = loc_el.get_text(" ", strip=True) if loc_el else "" price_el = card.select_one(".price-box") price_label = (re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) if price_el else "") # ex. "$1055.00 Par mois" -> normaliser pour parse_price pm = re.search(r"\$\s*([\d\s,\.]+)", price_label) price = None if pm: try: price = float(pm.group(1).replace(",", "").replace(" ", "")) if not (100 <= price <= 20000): price = None except ValueError: price = None unit_type = "" for li in card.select("ul.facilities-list li"): t = li.get_text(" ", strip=True) if "Chambre" in t: unit_type = t.replace("Chambres", "").strip() break img = card.select_one(".property-photo img") images = [img["src"]] if img and img.get("src") else [] listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/logement/{sector_slug}/ref={ext_id}/", title=title or (address or f"Logement {ext_id}"), address=address, sector=sector, city=infer_city(sector), unit_type=normalize_unit_type(unit_type), price=price, price_label=price_label, images=images, ) except Exception: continue # Fiches détaillées (via cache self.detail : une vraie requête # seulement si la carte liste a changé) : photos, disponibilité, # adresse complète, description, commodités, lat/lng. budget = {"n": 0} for lst in listings.values(): key = hashlib.sha1("|".join( (lst.title, lst.price_label, lst.address, lst.unit_type, lst.images[0] if lst.images else "")).encode()).hexdigest() def _fetch(url=lst.url): if budget["n"] >= self.max_details: raise _DetailBudget(url) budget["n"] += 1 return self._parse_detail(self.get(url).text) try: payload = self.detail(lst.external_id, key, _fetch) except Exception: continue if payload.get("images"): lst.images = payload["images"][:25] if payload.get("availability"): lst.availability = payload["availability"] if payload.get("address"): lst.address = payload["address"] if payload.get("description"): lst.description = payload["description"] if payload.get("amenities"): lst.amenities = payload["amenities"] if payload.get("lat") is not None: lst.lat, lst.lng = payload["lat"], payload["lng"] return list(listings.values()) # -- fiche /logement//ref=/ ---------------------------------- @staticmethod def _parse_detail(detail: str) -> dict: payload: dict = {} imgs = [u for u in dict.fromkeys(IMG_RE.findall(detail)) if not re.search(r"logo|icon|favicon|static", u, re.I)] if imgs: payload["images"] = imgs dsoup = BeautifulSoup(detail, "html.parser") text = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True)) # Adresse complète + disponibilité en tête de fiche am = re.search(r"(Disponible[^#]{0,40}?)\s*#?\s*Référence", text) if am: payload["availability"] = am.group(1).strip() adm = re.search( r"par mois\s+(.{5,90}?)\s+(?:Disponible|#\s*Référence)", text) if adm: payload["address"] = adm.group(1).strip() dm = re.search(r"Description\s+(.{10,600}?)\s+Caractéristiques", text) if dm: payload["description"] = dm.group(1).strip() amenities = [re.sub(r"\s+", " ", d.get_text(" ", strip=True)) for d in dsoup.select("div.col-6.text-dark") if d.get_text(strip=True)][:15] if amenities: payload["amenities"] = amenities # Coordonnées : carte Google `#map-custom` (attributs data-lat/lng) mm = LATLNG_RE.search(detail) if mm: try: payload["lat"] = float(mm.group(1)) payload["lng"] = float(mm.group(2)) except ValueError: pass return payload