# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/desmarais.py : connecteur Immeubles Desmarais (immeublesdesmarais.ca) # Gestionnaire historique de Gatineau (Hull, Aylmer, Buckingham). Site PHP # custom encodé ISO-8859-1, rendu CÔTÉ SERVEUR : la page /logements liste # les cartes (titre, secteur, chambres, superficie, type, prix « À partir # de ») avec pagination ?entity=logements&page=N. Le script de la carte # Google (showAddress) publie en plus l'adresse civique + code postal et le # lien canonique /logements// de chaque annonce. # La fiche détail ajoute la date de disponibilité, la description complète # et la galerie (/upload/logements//NN.jpg) — via self.detail() (cache # BD) avec plafond par sync. Les locaux commerciaux vivent sous /locaux # (entité distincte) et ne sont donc jamais ramassés. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price from .base import BaseConnector BASE = "https://www.immeublesdesmarais.ca" LIST_URL = f"{BASE}/logements" # secteurs de l'agglomération de Gatineau (le site filtre par ces 4 secteurs) _SECTEURS = {"hull": "Hull", "aylmer": "Aylmer", "buckingham": "Buckingham", "gatineau": ""} # showAddress(map, '', '...href=\"/logements//\"...') _MAP_RE = re.compile( r"showAddress\(map,\s*'([^']*)',\s*'.*?href=\\\"/logements/(\d+)/([^/\\\"]+)" ) def _clean_txt(s: str) -> str: return re.sub(r"\s+", " ", (s or "").strip()) class DesmaraisConnector(BaseConnector): source_id = "desmarais" request_delay = 1.0 max_pages = 6 # garde-fou de pagination (12 annonces = 2 pages) max_details = 20 # fiches détail réellement visitées par sync max_images = 20 def __init__(self) -> None: super().__init__() self._detail_calls = 0 # -- helpers --------------------------------------------------------------- def _html(self, url: str) -> str: """Le site est encodé ISO-8859-1 (meta charset) : forcer le décodage, sinon les accents deviennent mojibake selon l'en-tête du serveur.""" resp = self.get(url) resp.encoding = "iso-8859-1" return resp.text @staticmethod def _city_sector(raw: str) -> tuple[str, str]: """« Hull (Québec) J8X 4G9 » -> ('Gatineau', 'Hull') ; « Gatineau (Québec) » -> ('Gatineau', '').""" base = re.split(r"[(]", _clean_txt(raw))[0].strip() key = base.lower() if key in _SECTEURS: return "Gatineau", _SECTEURS[key] return base, "" # imprévu : on garde le libellé source tel quel @staticmethod def _unit_type(beds: str) -> str: """Nombre de chambres de la carte : « bach » (garçonnière) -> Studio, sinon « N chambres » normalisé par la couche commune.""" b = _clean_txt(beds).lower() if b.startswith("bach") or "garconni" in b or "garçonni" in b: return "Studio" m = re.match(r"^(\d+)", b) return normalize_unit_type(f"{m.group(1)} chambres") if m else "" # -- fiche détail ------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: """Date de disponibilité, description complète et galerie photos.""" self._detail_calls += 1 html = self._html(url) soup = BeautifulSoup(html, "html.parser") out: dict = {} td = soup.find("td", string=re.compile(r"Date de disponibilit")) if td: nxt = td.find_next_sibling("td") if nxt: out["availability"] = _clean_txt(nxt.get_text(" ", strip=True)) desc = soup.select_one("#detailRight p.greyText") if desc: txt = desc.get_text("\n", strip=True) out["description"] = re.sub(r"\s*\n\s*", " ", txt).strip()[:2000] images: list[str] = [] for a in soup.select("a.glightbox[href^='/upload/']"): u = BASE + a["href"] if u not in images: images.append(u) out["images"] = images return out # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} canon: dict[str, tuple[str, str]] = {} # id -> (adresse civique, slug) for page in range(1, self.max_pages + 1): url = LIST_URL if page == 1 else f"{LIST_URL}?entity=logements&page={page}" try: html = self._html(url) except Exception: break # adresses civiques + liens canoniques publiés par la carte Google for addr, num, slug in _MAP_RE.findall(html): canon.setdefault(num, (_clean_txt(addr), slug)) soup = BeautifulSoup(html, "html.parser") cards = soup.select("div.infoLogement") if not cards: break before = len(listings) for card in cards: try: self._parse_card(card, listings) except Exception: continue if len(listings) == before: # page sans nouveauté = fin break # adresse civique (carte Google) + fiche détail (cache BD) for ext, lst in listings.items(): addr, slug = canon.get(ext, ("", "")) if addr: # « 215 Rue de Canadel Gatineau, Québec J8T 8C3, J8T 8C3 » -> # partie civique seulement (la ville est déjà normalisée) civic = addr.split(",")[0].strip() civic = re.sub(r"\s+(Gatineau|Hull|Aylmer|Buckingham)$", "", civic, flags=re.I) lst.address = f"{civic}, {lst.city}" if civic else "" if slug: lst.url = f"{BASE}/logements/{ext}/{slug}" key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.description}" .encode("utf-8")).hexdigest()[:20] if self._detail_calls >= self.max_details: continue try: payload = self.detail(ext, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue if payload.get("availability"): lst.availability = payload["availability"] if payload.get("description"): lst.description = payload["description"] for img in payload.get("images") or []: if img not in lst.images and len(lst.images) < self.max_images: lst.images.append(img) return list(listings.values()) # -- carte de la liste ------------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one("a[href*='details.php']") if not link: return m = re.search(r"[?&]id=(\d+)", link.get("href", "")) if not m: return ext = m.group(1) if ext in listings: return top = card.select_one(".logementImageTop") spans = top.select("span") if top else [] title = _clean_txt(spans[0].get_text(" ", strip=True)) if spans else "" loc_raw = _clean_txt(spans[1].get_text(" ", strip=True)) if len(spans) > 1 else "" city, sector = self._city_sector(loc_raw) # type d'immeuble (Appartement / Condo / Maison) + prix « À partir de » prix_sec = card.select_one(".logementPrixSection") btype, price_label = "", "" if prix_sec: st = prix_sec.select_one("span strong") btype = _clean_txt(st.get_text(strip=True)) if st else "" pr = prix_sec.select_one("span.pull-right") price_label = _clean_txt(pr.get_text(" ", strip=True)) if pr else "" if re.search(r"commercial|local", btype, re.I): return # résidentiel seulement # chambres + superficie (blocs à icônes de la carte) beds = sqft = "" for div in card.select(".logementImageBot strong, .logementImageBotInfoBox strong"): t = _clean_txt(div.get_text(strip=True)) if "pi²" in t or "pi2" in t.lower(): sqft = sqft or t elif t: beds = beds or t if not beds: # repli : blocs « 2 chambres » m2 = re.search(r"\s*(bach|\d+)\s*\s*chambre", str(card), re.I) if m2: beds = m2.group(1) m3 = re.search(r"\s*([\d\s]+pi²)\s*", str(card)) if m3 and not sqft: sqft = _clean_txt(m3.group(1)) # extrait de description de la carte (remplacé par la fiche détail) snippet_el = card.select_one(".contentLogementInt") snippet = _clean_txt(snippet_el.get_text(" ", strip=True)) if snippet_el else "" snippet = re.sub(r"\(\.\.\.\)$", "…", snippet) img_el = card.select_one(".logementImage[style]") images: list[str] = [] if img_el: m4 = re.search(r"url\(([^)]+)\)", img_el.get("style", "")) if m4: images.append(BASE + m4.group(1).strip("'\" ")) details: dict = {} if btype: details["building_type"] = btype listings[ext] = Listing( source=self.source_id, external_id=ext, url=f"{BASE}/logements/{ext}/", title=title, sector=sector, city=city, unit_type=self._unit_type(beds), price=parse_price(price_label), price_label=price_label, area_sqft=parse_area_sqft(sqft), description=snippet, details=details, images=images, )