# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/louer_saguenay.py : connecteur Louer Saguenay (louersaguenay.com # — centre-ville de Jonquière, ville de Saguenay ; 9128-0768 Québec inc.). # Petit parc de lofts et d'appartements au-dessus de locaux commerciaux de la # rue Saint-Dominique. WordPress + Elementor rendu serveur : # - page /location-loft-appartement-jonquiere/ : boucle Elementor — un # item `.e-loop-item.category-location-loft-appartement-jonquiere` par # annonce (titre h2 + lien, extrait « DISPONIBLE AU 1-6-2026 », photo) ; # la section « NOS ESPACES COMMERCIALES » (articles `ee-post`, catégorie # location-espaces-commercials-saguenay) est ignorée ; # - fiche : description (« Informations »), « Prix mensuel : » suivi des # tarifs « 1395.00$ (Non meublé) / 1495.00$ (Meublé) » -> price = le plus # bas, price_label = texte original complet, galerie de photos. # Fiches visitées via self.detail (cache BD, re-fetch seulement si l'extrait # ou le titre change) — parc ~5 unités résidentielles. # external_id stable : slug de la fiche. city=Saguenay, sector=Jonquière # (convention des connecteurs SLSJ existants). # robots.txt ouvert (Disallow /imunify-bot-check seulement), sitemap Yoast. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://louersaguenay.com" LIST_URL = f"{BASE}/location-loft-appartement-jonquiere/" _TYPE_RE = re.compile(r"([1-6])\s*(?:½|1/2)") # « … à louer 2345 rue Saint-Dominique Suite 201 » -> tout ce qui suit « à louer » _ADDR_RE = re.compile(r"à louer\s+(\d.+)$", re.I) _PRICE_RE = re.compile(r"(\d[\d\s]*(?:[.,]\d{1,2})?)\s*\$") class LouerSaguenayConnector(BaseConnector): source_id = "louer_saguenay" request_delay = 0.6 max_images = 10 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") listings: dict[str, Listing] = {} items = soup.select( ".e-loop-item.category-location-loft-appartement-jonquiere") for item in items: try: self._parse_item(item, listings) except Exception: continue return list(listings.values()) # -- item de la boucle Elementor ---------------------------------------------- def _parse_item(self, item, listings: dict[str, Listing]) -> None: h2 = item.select_one("h2.elementor-heading-title a[href]") if h2 is None: return url = h2["href"].strip() title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) m = re.search(r"louersaguenay\.com/([^/?#]+)", url) if not m: return ext_id = m.group(1) if ext_id in listings: return # extrait = disponibilité (« DISPONIBLE AU 1-6-2026 ») quand publiée availability = "" excerpt = item.select_one(".elementor-widget-theme-post-excerpt " ".elementor-widget-container") if excerpt is not None: t = re.sub(r"\s+", " ", excerpt.get_text(" ", strip=True)) if t: availability = t images: list[str] = [] thumb = item.select_one("img[src]") if thumb and thumb["src"].startswith("http"): images.append(thumb["src"]) m_type = _TYPE_RE.search(title) if m_type: unit_type = f"{m_type.group(1)}½" elif re.search(r"(?i)\bloft\b", title): unit_type = "Loft" else: unit_type = "" m_addr = _ADDR_RE.search(title) address = f"{m_addr.group(1)}, Jonquière" if m_addr else "" # fiche (cache : re-fetch seulement si le titre/extrait change) payload = self.detail(ext_id, f"{title}|{availability}", lambda: self._fetch_detail(url)) price_label = payload.get("price_label", "") prices = [float(p.replace(" ", "").replace(" ", "").replace(",", ".")) for p in _PRICE_RE.findall(price_label)] prices = [p for p in prices if 100 <= p <= 20000] for u in payload.get("images", []): if u not in images and len(images) < self.max_images: images.append(u) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=address, sector="Jonquière", city="Saguenay", unit_type=normalize_unit_type(unit_type), price=min(prices) if prices else None, # le plus bas (non meublé) price_label=price_label, availability=availability or payload.get("availability", ""), description=payload.get("description", ""), images=images, ) # -- fiche ------------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {"price_label": "", "availability": "", "description": "", "images": []} # « Prix mensuel : » -> le heading suivant porte les tarifs headings = soup.select("h1, h2.elementor-heading-title") for i, h in enumerate(headings): t = re.sub(r"\s+", " ", h.get_text(" ", strip=True)) if re.match(r"(?i)^prix\s+mensuel", t) and i + 1 < len(headings): out["price_label"] = re.sub( r"\s+", " ", headings[i + 1].get_text(" ", strip=True)) elif re.match(r"(?i)^disponible", t): # « Disponible : » suivi d'un heading date (« 1er juillet 2022 ») if t.rstrip(":").strip().lower() == "disponible" \ and i + 1 < len(headings): nxt = re.sub(r"\s+", " ", headings[i + 1].get_text(" ", strip=True)) t = f"Disponible : {nxt}" out["availability"] = t # paragraphes de la section « Informations » paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) for p in soup.select(".elementor-widget-text-editor p")] out["description"] = "\n".join(t for t in paras if len(t) >= 25)[:1600] for img in soup.select("img[src*='/wp-content/uploads/']"): u = img["src"] if (u.startswith("http") and "logo" not in u.lower() and not re.search(r"-\d+x\d+\.(?:jpe?g|png|webp)$", u) and u not in out["images"]): out["images"].append(u) if len(out["images"]) >= self.max_images: break return out