# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immeubles_ouellet.py : connecteur Les Immeubles Vianney Ouellet # & Fils (immeublesouellet.com — Rimouski, 300+ unités, 40+ ans). Site PHP # artisanal (hébergeur PQM.net). La page /location/ n'affiche RIEN en GET ; # le moteur de recherche (POST quartier/grandeur/prix « tous ») renvoie la # grille complète : un bloc par immeuble (adresse en h4, photo PQM, lien # detail.php?id=N) avec une rangée par unité « 4 1/2 : Complet » ou # « 4 1/2 : 1 x libre à partir du 2025-12-15 ». Le connecteur ne visite que # les immeubles ayant au moins une rangée non « Complet » et émet une # annonce par bloc d'unité libre de la fiche detail.php?id=N : prix propre # au bloc (« 995$ », « --- » quand loué), description, services inclus, # ameublement, date de disponibilité, galerie. Parc quasi plein en région : # 0-2 annonces est un état normal. Les textes saisis en base par l'agence # sont en UTF-8 doublement encodé (« Meublé ») alors que le gabarit est # sain : correction chaîne par chaîne. robots.txt : 404 (= tout permis). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://immeublesouellet.com" SEARCH_URL = f"{BASE}/location/index.php" SEARCH_ALL = {"quartier": "0", "grandeur": "0", "prix": "0", "rechercher": "Recherche"} # rangée de la grille : « 4 1/2 : Complet », « 4 1/2 : 1 x libre à partir … » _ROW_RE = re.compile(r"^\s*(.+?)\s*:\s*(.+?)\s*$") _COMPLET_RE = re.compile(r"^complet\b", re.I) # entête d'un bloc d'unité sur la fiche : type puis ligne prix (« 995$ »/« --- ») _TYPE_RE = re.compile(r"^(\d\s*1/2|Studio|Loft|Maison|Commercial)$", re.I) _PRICE_LINE_RE = re.compile(r"^(-{2,}|\d[\d\s]*\$(?:\s*/\s*mois)?)$") def _fix_mojibake(s: str) -> str: """Textes de la base servis en UTF-8 doublement encodé : corrige si besoin.""" if "Ã" not in s and "â€" not in s: return s for enc in ("cp1252", "latin-1"): try: return s.encode(enc).decode("utf-8") except (UnicodeEncodeError, UnicodeDecodeError): continue return s def _slug(label: str) -> str: return re.sub(r"[^a-z0-9]+", "-", strip_accents(label.lower())).strip("-") class ImmeublesOuelletConnector(BaseConnector): source_id = "immeubles_ouellet" request_delay = 0.6 max_details = 15 # garde-fou fiches immeuble (vraies requêtes par sync) def fetch(self) -> list[Listing]: # la grille complète n'est servie que par la recherche POST « tous » resp = self.session.post(SEARCH_URL, data=SEARCH_ALL, timeout=self.timeout) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} for item in soup.select("li.list__item"): try: self._parse_building(item, listings) except Exception: continue return list(listings.values()) # -- bloc immeuble (résultats de recherche) ------------------------------------ def _parse_building(self, item, listings: dict[str, Listing]) -> None: link = item.select_one('a[href*="detail.php?id="]') if not link: return m = re.search(r"detail\.php\?id=(\d+)", link["href"]) if not m: return bid = m.group(1) h4 = item.select_one("h4") address = _fix_mojibake(h4.get_text(" ", strip=True)) if h4 else "" if re.search(r"commercial", address, re.I): return # rangées « type : statut » — l'immeuble n'est visité que si au moins # une unité n'est pas « Complet » free_rows = [] for li in item.select("ul li"): rm = _ROW_RE.match(_fix_mojibake(li.get_text(" ", strip=True))) if rm and not _COMPLET_RE.match(rm.group(2)) \ and not re.search(r"commercial", rm.group(1), re.I): free_rows.append((rm.group(1), rm.group(2))) if not free_rows: return img = item.select_one("img[src]") thumb = img["src"] if img and img["src"].startswith("http") else "" detail_url = f"{BASE}/location/detail.php?id={bid}" try: payload = self._fetch_detail(detail_url) except Exception: payload = {} # blocs d'unités LIBRES de la fiche (prix + date propres au bloc) blocks = [b for b in payload.get("units", []) if not _COMPLET_RE.match(b.get("availability", "Complet"))] if blocks: per_type: dict[str, int] = {} for b in blocks: slug = _slug(b["label"]) per_type[slug] = per_type.get(slug, 0) + 1 ext_id = f"{bid}-{slug}-{per_type[slug]}" if ext_id in listings: continue listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=detail_url, title=f"{b['label']} — {address}".strip(" —"), address=address, city="Rimouski", # tout le parc est à Rimouski unit_type=normalize_unit_type(b["label"]), price=parse_price(b.get("price_label", "")), price_label=b.get("price_label", ""), availability=b.get("availability", ""), description=b.get("description", ""), amenities=b.get("amenities", []), furnished=b.get("furnished"), images=payload.get("images") or ([thumb] if thumb else []), ) return # repli : fiche indisponible -> annonces depuis les rangées de la grille per_type: dict[str, int] = {} for unit_label, status in free_rows: slug = _slug(unit_label) per_type[slug] = per_type.get(slug, 0) + 1 ext_id = f"{bid}-{slug}-{per_type[slug]}" if ext_id in listings: continue listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=detail_url, title=f"{unit_label} — {address}".strip(" —"), address=address, city="Rimouski", unit_type=normalize_unit_type(unit_label), price=parse_price(status), price_label=status if "$" in status else "", availability=status, images=[thumb] if thumb else [], ) # -- fiche immeuble (detail.php?id=N) -------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Blocs d'unités (type, prix, description, services, ameublement, disponibilité) parsés ligne à ligne + galerie de l'immeuble.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches immeuble atteint") self._fetched += 1 soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {"units": [], "images": []} out["images"] = [im["src"] for im in soup.select("img[src]") if im["src"].startswith("http")][:20] lines = [_fix_mojibake(ln) for ln in soup.get_text("\n", strip=True).split("\n")] i, n = 0, len(lines) while i < n: # entête de bloc : ligne type suivie de la ligne prix (« --- »/« 995$ ») if not (_TYPE_RE.match(lines[i]) and i + 1 < n and _PRICE_LINE_RE.match(lines[i + 1])): i += 1 continue unit = {"label": lines[i]} if "$" in lines[i + 1]: unit["price_label"] = lines[i + 1] i += 2 section = None while i < n and not (_TYPE_RE.match(lines[i]) and i + 1 < n and _PRICE_LINE_RE.match(lines[i + 1])): ln = lines[i] if ln == "Description": section = "description" elif ln == "Services inclus": section = "amenities" elif ln == "Ameublement": section = "furnished" elif ln.startswith("Disponibilit"): section = "availability" elif ln.startswith("418 724-9132"): # pied de page : fin break elif section == "description": unit["description"] = (unit.get("description", "") + "\n" + ln).strip()[:800] elif section == "amenities": a = re.sub(r"^-\s*", "", ln).strip() if a and not re.match(r"^aucun service", a, re.I): unit.setdefault("amenities", []).append(a) elif section == "furnished": raw = strip_accents(ln.strip().lower()) if raw.startswith("non"): unit["furnished"] = False elif "meuble" in raw: unit["furnished"] = True section = None elif section == "availability": unit["availability"] = ln.strip() section = None i += 1 if _TYPE_RE.match(unit["label"]) and "commercial" not in \ unit["label"].lower(): out["units"].append(unit) return out