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/louer_saguenay.py : connecteur Louer Saguenay (louersaguenay.com5# — centre-ville de Jonquière, ville de Saguenay ; 9128-0768 Québec inc.).6# Petit parc de lofts et d'appartements au-dessus de locaux commerciaux de la7# rue Saint-Dominique. WordPress + Elementor rendu serveur :8# - page /location-loft-appartement-jonquiere/ : boucle Elementor — un9# item `.e-loop-item.category-location-loft-appartement-jonquiere` par10# annonce (titre h2 + lien, extrait « DISPONIBLE AU 1-6-2026 », photo) ;11# la section « NOS ESPACES COMMERCIALES » (articles `ee-post`, catégorie12# location-espaces-commercials-saguenay) est ignorée ;13# - fiche : description (« Informations »), « Prix mensuel : » suivi des14# tarifs « 1395.00$ (Non meublé) / 1495.00$ (Meublé) » -> price = le plus15# bas, price_label = texte original complet, galerie de photos.16# Fiches visitées via self.detail (cache BD, re-fetch seulement si l'extrait17# ou le titre change) — parc ~5 unités résidentielles.18# external_id stable : slug de la fiche. city=Saguenay, sector=Jonquière19# (convention des connecteurs SLSJ existants).20# robots.txt ouvert (Disallow /imunify-bot-check seulement), sitemap Yoast.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import re2526from bs4 import BeautifulSoup2728from ..schema import Listing, normalize_unit_type29from .base import BaseConnector3031BASE = "https://louersaguenay.com"32LIST_URL = f"{BASE}/location-loft-appartement-jonquiere/"3334_TYPE_RE = re.compile(r"([1-6])\s*(?:½|1/2)")35# « … à louer 2345 rue Saint-Dominique Suite 201 » -> tout ce qui suit « à louer »36_ADDR_RE = re.compile(r"à louer\s+(\d.+)$", re.I)37_PRICE_RE = re.compile(r"(\d[\d\s]*(?:[.,]\d{1,2})?)\s*\$")383940class LouerSaguenayConnector(BaseConnector):41 source_id = "louer_saguenay"42 request_delay = 0.643 max_images = 104445 def fetch(self) -> list[Listing]:46 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")4748 listings: dict[str, Listing] = {}49 items = soup.select(50 ".e-loop-item.category-location-loft-appartement-jonquiere")51 for item in items:52 try:53 self._parse_item(item, listings)54 except Exception:55 continue56 return list(listings.values())5758 # -- item de la boucle Elementor ----------------------------------------------59 def _parse_item(self, item, listings: dict[str, Listing]) -> None:60 h2 = item.select_one("h2.elementor-heading-title a[href]")61 if h2 is None:62 return63 url = h2["href"].strip()64 title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))65 m = re.search(r"louersaguenay\.com/([^/?#]+)", url)66 if not m:67 return68 ext_id = m.group(1)69 if ext_id in listings:70 return7172 # extrait = disponibilité (« DISPONIBLE AU 1-6-2026 ») quand publiée73 availability = ""74 excerpt = item.select_one(".elementor-widget-theme-post-excerpt "75 ".elementor-widget-container")76 if excerpt is not None:77 t = re.sub(r"\s+", " ", excerpt.get_text(" ", strip=True))78 if t:79 availability = t8081 images: list[str] = []82 thumb = item.select_one("img[src]")83 if thumb and thumb["src"].startswith("http"):84 images.append(thumb["src"])8586 m_type = _TYPE_RE.search(title)87 if m_type:88 unit_type = f"{m_type.group(1)}½"89 elif re.search(r"(?i)\bloft\b", title):90 unit_type = "Loft"91 else:92 unit_type = ""9394 m_addr = _ADDR_RE.search(title)95 address = f"{m_addr.group(1)}, Jonquière" if m_addr else ""9697 # fiche (cache : re-fetch seulement si le titre/extrait change)98 payload = self.detail(ext_id, f"{title}|{availability}",99 lambda: self._fetch_detail(url))100101 price_label = payload.get("price_label", "")102 prices = [float(p.replace(" ", "").replace(" ", "").replace(",", "."))103 for p in _PRICE_RE.findall(price_label)]104 prices = [p for p in prices if 100 <= p <= 20000]105106 for u in payload.get("images", []):107 if u not in images and len(images) < self.max_images:108 images.append(u)109110 listings[ext_id] = Listing(111 source=self.source_id,112 external_id=ext_id,113 url=url,114 title=title,115 address=address,116 sector="Jonquière",117 city="Saguenay",118 unit_type=normalize_unit_type(unit_type),119 price=min(prices) if prices else None, # le plus bas (non meublé)120 price_label=price_label,121 availability=availability or payload.get("availability", ""),122 description=payload.get("description", ""),123 images=images,124 )125126 # -- fiche -------------------------------------------------------------------127 def _fetch_detail(self, url: str) -> dict:128 soup = BeautifulSoup(self.get(url).text, "html.parser")129 out: dict = {"price_label": "", "availability": "",130 "description": "", "images": []}131132 # « Prix mensuel : » -> le heading suivant porte les tarifs133 headings = soup.select("h1, h2.elementor-heading-title")134 for i, h in enumerate(headings):135 t = re.sub(r"\s+", " ", h.get_text(" ", strip=True))136 if re.match(r"(?i)^prix\s+mensuel", t) and i + 1 < len(headings):137 out["price_label"] = re.sub(138 r"\s+", " ", headings[i + 1].get_text(" ", strip=True))139 elif re.match(r"(?i)^disponible", t):140 # « Disponible : » suivi d'un heading date (« 1er juillet 2022 »)141 if t.rstrip(":").strip().lower() == "disponible" \142 and i + 1 < len(headings):143 nxt = re.sub(r"\s+", " ",144 headings[i + 1].get_text(" ", strip=True))145 t = f"Disponible : {nxt}"146 out["availability"] = t147148 # paragraphes de la section « Informations »149 paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))150 for p in soup.select(".elementor-widget-text-editor p")]151 out["description"] = "\n".join(t for t in paras if len(t) >= 25)[:1600]152153 for img in soup.select("img[src*='/wp-content/uploads/']"):154 u = img["src"]155 if (u.startswith("http") and "logo" not in u.lower()156 and not re.search(r"-\d+x\d+\.(?:jpe?g|png|webp)$", u)157 and u not in out["images"]):158 out["images"].append(u)159 if len(out["images"]) >= self.max_images:160 break161 return out162