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/immeubles_ouellet.py : connecteur Les Immeubles Vianney Ouellet5# & Fils (immeublesouellet.com — Rimouski, 300+ unités, 40+ ans). Site PHP6# artisanal (hébergeur PQM.net). La page /location/ n'affiche RIEN en GET ;7# le moteur de recherche (POST quartier/grandeur/prix « tous ») renvoie la8# grille complète : un bloc par immeuble (adresse en h4, photo PQM, lien9# detail.php?id=N) avec une rangée par unité « 4 1/2 : Complet » ou10# « 4 1/2 : 1 x libre à partir du 2025-12-15 ». Le connecteur ne visite que11# les immeubles ayant au moins une rangée non « Complet » et émet une12# annonce par bloc d'unité libre de la fiche detail.php?id=N : prix propre13# au bloc (« 995$ », « --- » quand loué), description, services inclus,14# ameublement, date de disponibilité, galerie. Parc quasi plein en région :15# 0-2 annonces est un état normal. Les textes saisis en base par l'agence16# sont en UTF-8 doublement encodé (« Meublé ») alors que le gabarit est17# sain : correction chaîne par chaîne. robots.txt : 404 (= tout permis).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type, parse_price, strip_accents26from .base import BaseConnector2728BASE = "https://immeublesouellet.com"29SEARCH_URL = f"{BASE}/location/index.php"30SEARCH_ALL = {"quartier": "0", "grandeur": "0", "prix": "0",31 "rechercher": "Recherche"}3233# rangée de la grille : « 4 1/2 : Complet », « 4 1/2 : 1 x libre à partir … »34_ROW_RE = re.compile(r"^\s*(.+?)\s*:\s*(.+?)\s*$")35_COMPLET_RE = re.compile(r"^complet\b", re.I)36# entête d'un bloc d'unité sur la fiche : type puis ligne prix (« 995$ »/« --- »)37_TYPE_RE = re.compile(r"^(\d\s*1/2|Studio|Loft|Maison|Commercial)$", re.I)38_PRICE_LINE_RE = re.compile(r"^(-{2,}|\d[\d\s]*\$(?:\s*/\s*mois)?)$")394041def _fix_mojibake(s: str) -> str:42 """Textes de la base servis en UTF-8 doublement encodé : corrige si besoin."""43 if "Ã" not in s and "â€" not in s:44 return s45 for enc in ("cp1252", "latin-1"):46 try:47 return s.encode(enc).decode("utf-8")48 except (UnicodeEncodeError, UnicodeDecodeError):49 continue50 return s515253def _slug(label: str) -> str:54 return re.sub(r"[^a-z0-9]+", "-", strip_accents(label.lower())).strip("-")555657class ImmeublesOuelletConnector(BaseConnector):58 source_id = "immeubles_ouellet"59 request_delay = 0.660 max_details = 15 # garde-fou fiches immeuble (vraies requêtes par sync)6162 def fetch(self) -> list[Listing]:63 # la grille complète n'est servie que par la recherche POST « tous »64 resp = self.session.post(SEARCH_URL, data=SEARCH_ALL,65 timeout=self.timeout)66 resp.raise_for_status()67 soup = BeautifulSoup(resp.text, "html.parser")6869 self._fetched = 070 listings: dict[str, Listing] = {}71 for item in soup.select("li.list__item"):72 try:73 self._parse_building(item, listings)74 except Exception:75 continue76 return list(listings.values())7778 # -- bloc immeuble (résultats de recherche) ------------------------------------79 def _parse_building(self, item, listings: dict[str, Listing]) -> None:80 link = item.select_one('a[href*="detail.php?id="]')81 if not link:82 return83 m = re.search(r"detail\.php\?id=(\d+)", link["href"])84 if not m:85 return86 bid = m.group(1)87 h4 = item.select_one("h4")88 address = _fix_mojibake(h4.get_text(" ", strip=True)) if h4 else ""89 if re.search(r"commercial", address, re.I):90 return9192 # rangées « type : statut » — l'immeuble n'est visité que si au moins93 # une unité n'est pas « Complet »94 free_rows = []95 for li in item.select("ul li"):96 rm = _ROW_RE.match(_fix_mojibake(li.get_text(" ", strip=True)))97 if rm and not _COMPLET_RE.match(rm.group(2)) \98 and not re.search(r"commercial", rm.group(1), re.I):99 free_rows.append((rm.group(1), rm.group(2)))100 if not free_rows:101 return102103 img = item.select_one("img[src]")104 thumb = img["src"] if img and img["src"].startswith("http") else ""105 detail_url = f"{BASE}/location/detail.php?id={bid}"106 try:107 payload = self._fetch_detail(detail_url)108 except Exception:109 payload = {}110111 # blocs d'unités LIBRES de la fiche (prix + date propres au bloc)112 blocks = [b for b in payload.get("units", [])113 if not _COMPLET_RE.match(b.get("availability", "Complet"))]114 if blocks:115 per_type: dict[str, int] = {}116 for b in blocks:117 slug = _slug(b["label"])118 per_type[slug] = per_type.get(slug, 0) + 1119 ext_id = f"{bid}-{slug}-{per_type[slug]}"120 if ext_id in listings:121 continue122 listings[ext_id] = Listing(123 source=self.source_id,124 external_id=ext_id,125 url=detail_url,126 title=f"{b['label']} — {address}".strip(" —"),127 address=address,128 city="Rimouski", # tout le parc est à Rimouski129 unit_type=normalize_unit_type(b["label"]),130 price=parse_price(b.get("price_label", "")),131 price_label=b.get("price_label", ""),132 availability=b.get("availability", ""),133 description=b.get("description", ""),134 amenities=b.get("amenities", []),135 furnished=b.get("furnished"),136 images=payload.get("images") or ([thumb] if thumb else []),137 )138 return139140 # repli : fiche indisponible -> annonces depuis les rangées de la grille141 per_type: dict[str, int] = {}142 for unit_label, status in free_rows:143 slug = _slug(unit_label)144 per_type[slug] = per_type.get(slug, 0) + 1145 ext_id = f"{bid}-{slug}-{per_type[slug]}"146 if ext_id in listings:147 continue148 listings[ext_id] = Listing(149 source=self.source_id,150 external_id=ext_id,151 url=detail_url,152 title=f"{unit_label} — {address}".strip(" —"),153 address=address,154 city="Rimouski",155 unit_type=normalize_unit_type(unit_label),156 price=parse_price(status),157 price_label=status if "$" in status else "",158 availability=status,159 images=[thumb] if thumb else [],160 )161162 # -- fiche immeuble (detail.php?id=N) --------------------------------------------163 def _fetch_detail(self, url: str) -> dict:164 """Blocs d'unités (type, prix, description, services, ameublement,165 disponibilité) parsés ligne à ligne + galerie de l'immeuble."""166 if self._fetched >= self.max_details:167 raise RuntimeError("budget de fiches immeuble atteint")168 self._fetched += 1169 soup = BeautifulSoup(self.get(url).text, "html.parser")170 out: dict = {"units": [], "images": []}171172 out["images"] = [im["src"] for im in soup.select("img[src]")173 if im["src"].startswith("http")][:20]174175 lines = [_fix_mojibake(ln) for ln in176 soup.get_text("\n", strip=True).split("\n")]177 i, n = 0, len(lines)178 while i < n:179 # entête de bloc : ligne type suivie de la ligne prix (« --- »/« 995$ »)180 if not (_TYPE_RE.match(lines[i]) and i + 1 < n181 and _PRICE_LINE_RE.match(lines[i + 1])):182 i += 1183 continue184 unit = {"label": lines[i]}185 if "$" in lines[i + 1]:186 unit["price_label"] = lines[i + 1]187 i += 2188 section = None189 while i < n and not (_TYPE_RE.match(lines[i]) and i + 1 < n190 and _PRICE_LINE_RE.match(lines[i + 1])):191 ln = lines[i]192 if ln == "Description":193 section = "description"194 elif ln == "Services inclus":195 section = "amenities"196 elif ln == "Ameublement":197 section = "furnished"198 elif ln.startswith("Disponibilit"):199 section = "availability"200 elif ln.startswith("418 724-9132"): # pied de page : fin201 break202 elif section == "description":203 unit["description"] = (unit.get("description", "") +204 "\n" + ln).strip()[:800]205 elif section == "amenities":206 a = re.sub(r"^-\s*", "", ln).strip()207 if a and not re.match(r"^aucun service", a, re.I):208 unit.setdefault("amenities", []).append(a)209 elif section == "furnished":210 raw = strip_accents(ln.strip().lower())211 if raw.startswith("non"):212 unit["furnished"] = False213 elif "meuble" in raw:214 unit["furnished"] = True215 section = None216 elif section == "availability":217 unit["availability"] = ln.strip()218 section = None219 i += 1220 if _TYPE_RE.match(unit["label"]) and "commercial" not in \221 unit["label"].lower():222 out["units"].append(unit)223 return out224