Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/gestion_1139.py : connecteur Gestion 1139 (gestion1139.com)5# Drummondville et environs (Centre-du-Québec). WordPress + FacetWP rendu6# serveur : la page d'accueil liste les 40+ immeubles (cartes .fwpl-result :7# titre, adresse, typologies, badge .archive-tag « Disponible … » sur les8# immeubles qui louent). AUCUN prix ni inventaire par unité sur le site —9# seules les cartes portant un badge « Disponible » sont retenues.10# Granularité : une annonce par TYPOLOGIE d'un immeuble disponible11# (ex. projet-horace-3.5, projet-horace-4.5).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://gestion1139.com"2425DISPO_RE = re.compile(r"disponible", re.I)26# villes plausibles du parc (détection dans le texte de la page immeuble)27CITY_RE = re.compile(28 r"(Drummondville|Saint-Nic[ée]phore|Saint-Charles-de-Drummond|"29 r"Saint-Cyrille[\w-]*|Saint-Germain[\w-]*|Notre-Dame-du-Bon-Conseil|"30 r"Wickham|L'Avenir)", re.I)31IMG_RE = re.compile(r"https://gestion1139\.com/wp-content/uploads/"32 r'[^"\'\s\\)]+\.(?:jpe?g|png|webp)', re.I)333435class Gestion1139Connector(BaseConnector):36 source_id = "gestion_1139"37 request_delay = 0.73839 def fetch(self) -> list[Listing]:40 listings: list[Listing] = []41 try:42 html = self.get(BASE + "/").text43 except Exception:44 return listings45 soup = BeautifulSoup(html, "html.parser")4647 seen: set[str] = set()48 for result in soup.select("div.fwpl-result"):49 try:50 tag_el = result.select_one(".archive-tag")51 tag = tag_el.get_text(" ", strip=True) if tag_el else ""52 if not tag or not DISPO_RE.search(tag):53 continue # immeuble sans badge de disponibilité54 a = result.select_one('a[href*="/immeubles/"]')55 if a is None:56 continue57 url = a.get("href", "").split("?")[0]58 slug = url.rstrip("/").split("/")[-1]59 if not slug or slug in seen:60 continue61 seen.add(slug)6263 title_el = result.select_one(".listing_title a")64 name = (title_el.get_text(" ", strip=True)65 if title_el else slug)66 addr_el = result.select_one("p.adresse")67 address = (re.sub(r"\s+", " ",68 addr_el.get_text(" ", strip=True))69 if addr_el else "")7071 card_key = hashlib.sha1(72 f"{tag}|{name}|{address}".encode("utf-8")).hexdigest()73 d = self.detail(slug, card_key,74 lambda url=url: self._fetch_detail(url))75 if d.get("address"):76 address = d["address"]77 city = d.get("city") or "Drummondville"7879 units = d.get("units") or [{"unit_type": "", "extra": []}]80 for u in units:81 ut = u.get("unit_type", "")82 suffix = ut.replace("½", ".5").replace(" ", "") or "imm"83 listings.append(Listing(84 source=self.source_id,85 external_id=f"{slug}-{suffix}",86 url=url,87 title=f"{ut + ' — ' if ut else ''}{name}",88 address=address,89 city=city,90 unit_type=ut,91 availability=tag,92 description=d.get("description", ""),93 amenities=(u.get("extra") or [])94 + (d.get("amenities") or []),95 images=d.get("images") or [],96 ))97 except Exception:98 continue99 return listings100101 def _fetch_detail(self, url: str) -> dict:102 """Page immeuble : description, adresse civique, avantages,103 typologies (« Nos unités » : n ½ / chambres / salles de bain),104 ville (détectée dans le texte) et photos."""105 out: dict = {}106 try:107 html = self.get(url).text108 except Exception:109 return out110 soup = BeautifulSoup(html, "html.parser")111112 # typologies113 units: list[dict] = []114 for bloc in soup.select("div.liste_appartement"):115 ps = [p.get_text(" ", strip=True) for p in bloc.find_all("p")116 if p.get_text(strip=True)]117 if not ps:118 continue119 units.append({"unit_type": normalize_unit_type(ps[0]),120 "extra": ps[1:3]})121 out["units"] = units122123 # description : premiers paragraphes de la section d'intro124 paras = [p.get_text(" ", strip=True)125 for p in soup.select(".uncode_text_column p")126 if len(p.get_text(strip=True)) > 40]127 if paras:128 out["description"] = re.sub(r"\s+", " ", paras[0]).strip()[:900]129130 # adresse civique (ligne « 1645 et 1655 Boulevard Lemire »)131 text = soup.get_text("\n", strip=True)132 m = re.search(r"^\d{1,5}[^\n]{3,70}(?:rue|boulevard|boul\.|avenue|"133 r"chemin|route)[^\n]{0,50}$", text, re.I | re.M)134 if m:135 out["address"] = re.sub(r"\s+", " ", m.group(0)).strip()136 m = CITY_RE.search(text)137 if m:138 out["city"] = m.group(1)139 else: # souvent seulement dans les noms de fichiers photo140 m = CITY_RE.search(html)141 if m:142 out["city"] = m.group(1).capitalize()143144 # avantages (liste « Les avantages »)145 out["amenities"] = [146 li.get_text(" ", strip=True)147 for li in soup.select(".uncode_text_column li")148 if 0 < len(li.get_text(strip=True)) <= 80][:15]149150 out["images"] = [u for u in dict.fromkeys(IMG_RE.findall(html))151 if not re.search(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.",152 u, re.I)][:20]153 return out154