# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/location_saguenay.py : connecteur Location Saguenay # (locationsaguenay.com — Saguenay : Chicoutimi près de l'UQAC/Cégep et # Jonquière près du Cégep ; propriétaire-gestionnaire, formule de base ou # tout inclus). WordPress classique (thème Davis), tout rendu serveur : le # menu « Logements disponibles » liste TOUTES les fiches, groupées par # « Appartements N chambres » (les pages villes /appartement-a-louer- # chicoutimi|jonquiere/ sont des vitrines SEO sans cartes). Une fiche WP par # immeuble/type d'unité (granularité immeuble, précédent immeubles_guillot) : # description libre et galerie photoswipe ; prix « sur demande » (aucun prix # publié -> price = None) et pas de dates de disponibilité. # ⚠️ robots.txt : Crawl-delay: 10 -> request_delay = 10 s et budget de fiches # réduit (max_details = 12, complété au fil des syncs par le cache BD). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://locationsaguenay.com" LIST_URL = f"{BASE}/" _GROUP_RE = re.compile(r"Appartements?\s+(\d+)\s+chambres?", re.I) _ADDR_RE = re.compile(r"^\s*(\d[\w\s'’.,-]*?)\s*(?:à\s+\w[\w-]*)?\s*$") class LocationSaguenayConnector(BaseConnector): source_id = "location_saguenay" request_delay = 10.0 # Crawl-delay: 10 du robots.txt — respecté tel quel max_details = 12 # 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") menu = soup.select_one("ul.blog-menu") if menu is None: return [] # entrée « Logements disponibles » du menu principal root = None for li in menu.find_all("li", recursive=False): a = li.find("a", recursive=False) if a and "logements disponibles" in a.get_text(strip=True).lower(): root = li.find("ul", class_="sub-menu") break if root is None: return [] self._fetched = 0 listings: dict[str, Listing] = {} # groupes « Appartements N chambres », chacun avec ses fiches for group in root.find_all("li", recursive=False): head = group.find("a", recursive=False) label = head.get_text(strip=True) if head else "" m = _GROUP_RE.search(label) bedrooms = m.group(1) if m else "" sub = group.find("ul", class_="sub-menu") if sub is None: continue for a in sub.select("li > a[href]"): try: self._parse_entry(a, bedrooms, listings) except Exception: continue return list(listings.values()) # -- entrée du menu (une fiche WP par immeuble/type) ------------------------------- def _parse_entry(self, a, bedrooms: str, listings: dict[str, Listing]) -> None: url = a["href"] if not url.startswith(BASE): return slug = url.rstrip("/").rsplit("/", 1)[-1] if not slug or slug in listings: return title = re.sub(r"\s+", " ", a.get_text(" ", strip=True)) # type : nombre de chambres du groupe du menu (Loft si le titre le dit) if re.search(r"\bLofts?\b", title, re.I): unit_type = "Loft" elif bedrooms: unit_type = normalize_unit_type(f"{bedrooms} chambres") else: unit_type = "" # adresse civique en tête de titre (« 2603, rue St-Hubert à Jonquière : … ») address = "" head = title.split(":", 1)[0].strip() if re.match(r"^\d", head): address = re.sub(r"\s+à\s+\w[\w'’-]*$", "", head).strip(" ,") # secteur : mots-clés factuels du titre (UQAC = Chicoutimi) key = strip_accents(title.lower()) sector = "" if "jonquiere" in key: sector = "Jonquière" elif "chicoutimi" in key or "uqac" in key: sector = "Chicoutimi" amenities = [] if bedrooms: amenities.append(f"{bedrooms} chambre(s)") lst = Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, sector=sector, city="Saguenay", unit_type=unit_type, amenities=amenities, images=[], ) key_hash = hashlib.sha1(f"{title}|{bedrooms}".encode("utf-8")).hexdigest() try: payload = self.detail(slug, key_hash, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[slug] = lst # -- fiche détail (page WP de l'immeuble) ------------------------------------------ def _fetch_detail(self, url: str) -> dict: """Description libre, mention de prix (« sur demande ») et galerie.""" 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 = {} content = soup.select_one(".entry-content") or soup paras: list[str] = [] for p in content.find_all("p"): txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if not txt: continue # mention de prix du site (« Tarif/Prix sur demande ») : libellé brut m = re.search(r"(?:tarif|prix)[^.]*demande[^.]*\.?", txt, re.I) if m: out.setdefault("price_label", m.group(0).strip()) continue # fin du contenu utile : bloc contact/formulaire if re.search(r"contactez|formulaire|courriel|581-|1-888-", txt, re.I): break paras.append(txt) if paras: out["description"] = "\n".join(paras)[:1500] # secteur si la description est plus précise que le titre key = strip_accents(" ".join(paras).lower()) if "jonquiere" in key: out["sector"] = "Jonquière" elif "chicoutimi" in key or "uqac" in key: out["sector"] = "Chicoutimi" # galerie photoswipe (photos réelles ; logo du site exclu) images: list[str] = [] for img in content.find_all("img", src=True): u = img["src"] if (u.startswith("http") and "/wp-content/uploads/" in u and "cropped-" not in u and u not in images): images.append(u) out["images"] = images[:25] 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 if d.get("description"): lst.description = d["description"] if d.get("price_label"): lst.price_label = d["price_label"] # « sur demande » : prix None if d.get("sector") and not lst.sector: lst.sector = d["sector"] if d.get("images"): lst.images = d["images"]