# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/acceslogis_gb.py : connecteur Accès Logis GB (acceslogisgb.com) # Gestionnaire de Lanaudière/Mauricie (Joliette, Ste-Élisabeth, # St-Ambroise-de-Kildare, Shawinigan…). Site builder mono-page : les cartes # « LOGEMENTS DISPONIBLES » vivent dans des grilles .columnswithgap-02 # (titre , photo, description qui se termine par # « Disponible … » + « 1150$ PAR MOIS »). Aucune page détail ni URL par # annonce : external_id = slug du titre + repère d'étage tiré de la # description (les titres se répètent d'un étage à l'autre). 1 requête/sync. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import unicodedata from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://acceslogisgb.com" LIST_URL = f"{BASE}/" # villes desservies (clé sans accents, en minuscules -> nom canonique) _CITIES = [ ("ste-elisabeth", "Sainte-Élisabeth"), ("sainte-elisabeth", "Sainte-Élisabeth"), ("sainte-elizabeth", "Sainte-Élisabeth"), ("st-ambroise", "Saint-Ambroise-de-Kildare"), ("saint-ambroise", "Saint-Ambroise-de-Kildare"), ("shawinigan", "Shawinigan"), ("joliette", "Joliette"), ("crabtree", "Crabtree"), ("berthier", "Berthierville"), ("st-thomas", "Saint-Thomas"), ("saint-thomas", "Saint-Thomas"), ("st-come", "Saint-Côme"), ("saint-come", "Saint-Côme"), ] # repère d'étage dans la description (« en demi sous-sol », « au 2e étage »…) _FLOOR_RE = re.compile( r"(demi[- ]sous[- ]sol|sous[- ]sol|rez[- ]de[- ]chauss[ée]e|\d+\s*(?:er|e|ème|ieme)\s*étage)", re.I) def _strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def _slug(s: str) -> str: s = _strip_accents(s.lower()) s = re.sub(r"[^a-z0-9]+", "-", s) return s.strip("-") class AccesLogisGBConnector(BaseConnector): source_id = "acceslogis_gb" request_delay = 0.7 def fetch(self) -> list[Listing]: resp = self.get(LIST_URL) resp.encoding = "utf-8" # le serveur ne déclare pas le charset html = resp.text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for grid in soup.select("div.columnswithgap-02"): for col in grid.find_all("div", recursive=False): try: self._parse_card(col, listings) except Exception: continue return list(listings.values()) def _parse_card(self, col, listings: dict[str, Listing]) -> None: title_el = col.select_one("p.font-026 b") if not title_el: return title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)) # hors périmètre logement : mini-entrepôts, locaux if re.search(r"entrep[oô]t|commercial|local\b", title, re.I): return desc_el = col.select_one("p.font-014") availability, price_label = "", "" description = "" if desc_el: # les mentions « Disponible … » et « 1150$ » sont des en fin # de paragraphe : on les extrait puis on garde le reste en description for span in desc_el.find_all("span"): t = re.sub(r"\s+", " ", span.get_text(" ", strip=True)) if re.match(r"(?i)disponible|libre", t): availability = t elif re.search(r"\d\s*\$", t): price_label = t span.extract() description = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True)) description = re.sub(r"\bPAR MOIS\b\s*$", "", description).strip() # ville : depuis le titre, sinon la description low = _strip_accents(f"{title} {description}".lower()) city = "" for key, name in _CITIES: if key in low: city = name break # type d'unité : titre (« 3 1/2, … ») sinon description unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo", unit_type or ""): unit_type = normalize_unit_type(description) if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo", unit_type or ""): unit_type = "" # adresse civique si mentionnée (« situé au 2510 Rang du Ruisseau à … ») address = "" m = re.search(r"situ[ée]e?\s+au\s+([\d][^.,]*?)\s+à\s", description) if m: address = m.group(1).strip() elif re.match(r"\d+\s+\w", title) and not normalize_unit_type(title).endswith("½"): address = title.split(",")[0].strip() # le titre est une adresse civique # external_id stable : slug du titre + repère d'étage (les titres se # répètent entre étages d'un même immeuble) ext = _slug(title) m_fl = _FLOOR_RE.search(description) if m_fl: ext += "-" + _slug(m_fl.group(1)) if ext in listings: # ultime repli : hash du texte ext += "-" + hashlib.sha1(description.encode("utf-8")).hexdigest()[:6] images: list[str] = [] img = col.select_one("img[src]") if img: src = img["src"] if not src.startswith("http"): src = f"{BASE}/{src.lstrip('/')}" images.append(src) listings[ext] = Listing( source=self.source_id, external_id=ext, url=f"{LIST_URL}#logements", title=title, address=address, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, description=description[:900], images=images, )