SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
9.9 KB · 224 lines python
Raw Blame History
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