# -----------------------------------------------------------------------------
# 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,
)