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/acceslogis_gb.py : connecteur Accès Logis GB (acceslogisgb.com)5# Gestionnaire de Lanaudière/Mauricie (Joliette, Ste-Élisabeth,6# St-Ambroise-de-Kildare, Shawinigan…). Site builder mono-page : les cartes7# « LOGEMENTS DISPONIBLES » vivent dans des grilles .columnswithgap-028# (titre <p.font-026><b>, photo, description <p.font-014> qui se termine par9# « Disponible … » + « 1150$ PAR MOIS »). Aucune page détail ni URL par10# annonce : external_id = slug du titre + repère d'étage tiré de la11# description (les titres se répètent d'un étage à l'autre). 1 requête/sync.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re17import unicodedata1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://acceslogisgb.com"25LIST_URL = f"{BASE}/"2627# villes desservies (clé sans accents, en minuscules -> nom canonique)28_CITIES = [29 ("ste-elisabeth", "Sainte-Élisabeth"),30 ("sainte-elisabeth", "Sainte-Élisabeth"),31 ("sainte-elizabeth", "Sainte-Élisabeth"),32 ("st-ambroise", "Saint-Ambroise-de-Kildare"),33 ("saint-ambroise", "Saint-Ambroise-de-Kildare"),34 ("shawinigan", "Shawinigan"),35 ("joliette", "Joliette"),36 ("crabtree", "Crabtree"),37 ("berthier", "Berthierville"),38 ("st-thomas", "Saint-Thomas"),39 ("saint-thomas", "Saint-Thomas"),40 ("st-come", "Saint-Côme"),41 ("saint-come", "Saint-Côme"),42]4344# repère d'étage dans la description (« en demi sous-sol », « au 2e étage »…)45_FLOOR_RE = re.compile(46 r"(demi[- ]sous[- ]sol|sous[- ]sol|rez[- ]de[- ]chauss[ée]e|\d+\s*(?:er|e|ème|ieme)\s*étage)",47 re.I)484950def _strip_accents(s: str) -> str:51 return "".join(c for c in unicodedata.normalize("NFD", s)52 if unicodedata.category(c) != "Mn")535455def _slug(s: str) -> str:56 s = _strip_accents(s.lower())57 s = re.sub(r"[^a-z0-9]+", "-", s)58 return s.strip("-")596061class AccesLogisGBConnector(BaseConnector):62 source_id = "acceslogis_gb"63 request_delay = 0.76465 def fetch(self) -> list[Listing]:66 resp = self.get(LIST_URL)67 resp.encoding = "utf-8" # le serveur ne déclare pas le charset68 html = resp.text69 soup = BeautifulSoup(html, "html.parser")70 listings: dict[str, Listing] = {}71 for grid in soup.select("div.columnswithgap-02"):72 for col in grid.find_all("div", recursive=False):73 try:74 self._parse_card(col, listings)75 except Exception:76 continue77 return list(listings.values())7879 def _parse_card(self, col, listings: dict[str, Listing]) -> None:80 title_el = col.select_one("p.font-026 b")81 if not title_el:82 return83 title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True))84 # hors périmètre logement : mini-entrepôts, locaux85 if re.search(r"entrep[oô]t|commercial|local\b", title, re.I):86 return8788 desc_el = col.select_one("p.font-014")89 availability, price_label = "", ""90 description = ""91 if desc_el:92 # les mentions « Disponible … » et « 1150$ » sont des <span> en fin93 # de paragraphe : on les extrait puis on garde le reste en description94 for span in desc_el.find_all("span"):95 t = re.sub(r"\s+", " ", span.get_text(" ", strip=True))96 if re.match(r"(?i)disponible|libre", t):97 availability = t98 elif re.search(r"\d\s*\$", t):99 price_label = t100 span.extract()101 description = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True))102 description = re.sub(r"\bPAR MOIS\b\s*$", "", description).strip()103104 # ville : depuis le titre, sinon la description105 low = _strip_accents(f"{title} {description}".lower())106 city = ""107 for key, name in _CITIES:108 if key in low:109 city = name110 break111112 # type d'unité : titre (« 3 1/2, … ») sinon description113 unit_type = normalize_unit_type(title)114 if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo",115 unit_type or ""):116 unit_type = normalize_unit_type(description)117 if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo",118 unit_type or ""):119 unit_type = ""120121 # adresse civique si mentionnée (« situé au 2510 Rang du Ruisseau à … »)122 address = ""123 m = re.search(r"situ[ée]e?\s+au\s+([\d][^.,]*?)\s+à\s", description)124 if m:125 address = m.group(1).strip()126 elif re.match(r"\d+\s+\w", title) and not normalize_unit_type(title).endswith("½"):127 address = title.split(",")[0].strip() # le titre est une adresse civique128129 # external_id stable : slug du titre + repère d'étage (les titres se130 # répètent entre étages d'un même immeuble)131 ext = _slug(title)132 m_fl = _FLOOR_RE.search(description)133 if m_fl:134 ext += "-" + _slug(m_fl.group(1))135 if ext in listings: # ultime repli : hash du texte136 ext += "-" + hashlib.sha1(description.encode("utf-8")).hexdigest()[:6]137138 images: list[str] = []139 img = col.select_one("img[src]")140 if img:141 src = img["src"]142 if not src.startswith("http"):143 src = f"{BASE}/{src.lstrip('/')}"144 images.append(src)145146 listings[ext] = Listing(147 source=self.source_id,148 external_id=ext,149 url=f"{LIST_URL}#logements",150 title=title,151 address=address,152 city=city,153 unit_type=unit_type,154 price=parse_price(price_label),155 price_label=price_label,156 availability=availability,157 description=description[:900],158 images=images,159 )160