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/location_saguenay.py : connecteur Location Saguenay5# (locationsaguenay.com — Saguenay : Chicoutimi près de l'UQAC/Cégep et6# Jonquière près du Cégep ; propriétaire-gestionnaire, formule de base ou7# tout inclus). WordPress classique (thème Davis), tout rendu serveur : le8# menu « Logements disponibles » liste TOUTES les fiches, groupées par9# « Appartements N chambres » (les pages villes /appartement-a-louer-10# chicoutimi|jonquiere/ sont des vitrines SEO sans cartes). Une fiche WP par11# immeuble/type d'unité (granularité immeuble, précédent immeubles_guillot) :12# description libre et galerie photoswipe ; prix « sur demande » (aucun prix13# publié -> price = None) et pas de dates de disponibilité.14# ⚠️ robots.txt : Crawl-delay: 10 -> request_delay = 10 s et budget de fiches15# réduit (max_details = 12, complété au fil des syncs par le cache BD).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import BaseConnector2627BASE = "https://locationsaguenay.com"28LIST_URL = f"{BASE}/"2930_GROUP_RE = re.compile(r"Appartements?\s+(\d+)\s+chambres?", re.I)31_ADDR_RE = re.compile(r"^\s*(\d[\w\s'’.,-]*?)\s*(?:à\s+\w[\w-]*)?\s*$")323334class LocationSaguenayConnector(BaseConnector):35 source_id = "location_saguenay"36 request_delay = 10.0 # Crawl-delay: 10 du robots.txt — respecté tel quel37 max_details = 12 # garde-fou fiches détail (vraies requêtes par sync)3839 def fetch(self) -> list[Listing]:40 html = self.get(LIST_URL).text41 soup = BeautifulSoup(html, "html.parser")4243 menu = soup.select_one("ul.blog-menu")44 if menu is None:45 return []4647 # entrée « Logements disponibles » du menu principal48 root = None49 for li in menu.find_all("li", recursive=False):50 a = li.find("a", recursive=False)51 if a and "logements disponibles" in a.get_text(strip=True).lower():52 root = li.find("ul", class_="sub-menu")53 break54 if root is None:55 return []5657 self._fetched = 058 listings: dict[str, Listing] = {}59 # groupes « Appartements N chambres », chacun avec ses fiches60 for group in root.find_all("li", recursive=False):61 head = group.find("a", recursive=False)62 label = head.get_text(strip=True) if head else ""63 m = _GROUP_RE.search(label)64 bedrooms = m.group(1) if m else ""65 sub = group.find("ul", class_="sub-menu")66 if sub is None:67 continue68 for a in sub.select("li > a[href]"):69 try:70 self._parse_entry(a, bedrooms, listings)71 except Exception:72 continue73 return list(listings.values())7475 # -- entrée du menu (une fiche WP par immeuble/type) -------------------------------76 def _parse_entry(self, a, bedrooms: str,77 listings: dict[str, Listing]) -> None:78 url = a["href"]79 if not url.startswith(BASE):80 return81 slug = url.rstrip("/").rsplit("/", 1)[-1]82 if not slug or slug in listings:83 return84 title = re.sub(r"\s+", " ", a.get_text(" ", strip=True))8586 # type : nombre de chambres du groupe du menu (Loft si le titre le dit)87 if re.search(r"\bLofts?\b", title, re.I):88 unit_type = "Loft"89 elif bedrooms:90 unit_type = normalize_unit_type(f"{bedrooms} chambres")91 else:92 unit_type = ""9394 # adresse civique en tête de titre (« 2603, rue St-Hubert à Jonquière : … »)95 address = ""96 head = title.split(":", 1)[0].strip()97 if re.match(r"^\d", head):98 address = re.sub(r"\s+à\s+\w[\w'’-]*$", "", head).strip(" ,")99100 # secteur : mots-clés factuels du titre (UQAC = Chicoutimi)101 key = strip_accents(title.lower())102 sector = ""103 if "jonquiere" in key:104 sector = "Jonquière"105 elif "chicoutimi" in key or "uqac" in key:106 sector = "Chicoutimi"107108 amenities = []109 if bedrooms:110 amenities.append(f"{bedrooms} chambre(s)")111112 lst = Listing(113 source=self.source_id,114 external_id=slug,115 url=url,116 title=title,117 address=address,118 sector=sector,119 city="Saguenay",120 unit_type=unit_type,121 amenities=amenities,122 images=[],123 )124125 key_hash = hashlib.sha1(f"{title}|{bedrooms}".encode("utf-8")).hexdigest()126 try:127 payload = self.detail(slug, key_hash,128 lambda u=url: self._fetch_detail(u))129 self._apply_detail(lst, payload)130 except Exception:131 pass132 listings[slug] = lst133134 # -- fiche détail (page WP de l'immeuble) ------------------------------------------135 def _fetch_detail(self, url: str) -> dict:136 """Description libre, mention de prix (« sur demande ») et galerie."""137 if self._fetched >= self.max_details:138 raise RuntimeError("budget de fiches détail atteint")139 self._fetched += 1140 html = self.get(url).text141 soup = BeautifulSoup(html, "html.parser")142 out: dict = {}143144 content = soup.select_one(".entry-content") or soup145 paras: list[str] = []146 for p in content.find_all("p"):147 txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))148 if not txt:149 continue150 # mention de prix du site (« Tarif/Prix sur demande ») : libellé brut151 m = re.search(r"(?:tarif|prix)[^.]*demande[^.]*\.?", txt, re.I)152 if m:153 out.setdefault("price_label", m.group(0).strip())154 continue155 # fin du contenu utile : bloc contact/formulaire156 if re.search(r"contactez|formulaire|courriel|581-|1-888-", txt, re.I):157 break158 paras.append(txt)159 if paras:160 out["description"] = "\n".join(paras)[:1500]161162 # secteur si la description est plus précise que le titre163 key = strip_accents(" ".join(paras).lower())164 if "jonquiere" in key:165 out["sector"] = "Jonquière"166 elif "chicoutimi" in key or "uqac" in key:167 out["sector"] = "Chicoutimi"168169 # galerie photoswipe (photos réelles ; logo du site exclu)170 images: list[str] = []171 for img in content.find_all("img", src=True):172 u = img["src"]173 if (u.startswith("http") and "/wp-content/uploads/" in u174 and "cropped-" not in u and u not in images):175 images.append(u)176 out["images"] = images[:25]177 return out178179 def _apply_detail(self, lst: Listing, d: dict) -> None:180 """Reporte le payload (frais ou en cache) sur l'annonce."""181 if not d:182 return183 if d.get("description"):184 lst.description = d["description"]185 if d.get("price_label"):186 lst.price_label = d["price_label"] # « sur demande » : prix None187 if d.get("sector") and not lst.sector:188 lst.sector = d["sector"]189 if d.get("images"):190 lst.images = d["images"]191