# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_jdm.py : connecteur JDM Gestion immobilière (gestionjdm.ca # — Rivière-du-Loup, Témiscouata-sur-le-Lac, Cacouna ; Bas-Saint-Laurent). # WordPress + Elementor (widget UAEL Posts) rendu serveur : # - page /logement/ : cartes `.uael-post-wrapper.logement` — badge # d'étiquette (« Disponible »), adresse complète en extrait # (« 36 rue plourde, Rivière-du-Loup (QC) G5R 1A1 »), photo, lien fiche ; # - fiche (/logement-1/…) : sections h4 « Description » (text-editor), # « Caractéristiques » et « À proximité » (icon-list), galerie. # Granularité IMMEUBLE (« 24 unités de logements sur 3 étages ») — précédent # girs/immeubles_guillot : aucun prix ni liste d'unités publiés -> price=None, # rien d'inventé. Les cartes étiquetées « loué / indisponible » (si elles # apparaissent un jour) sont écartées ; le badge brut alimente availability. # Le type n'est retenu que si UN seul est cité (« 3-1/2, 4-1/2 et 5-1/2 » -> # vide, la description fait foi). Les pages /chambre/ (maisons de chambres) # et /commercial/ ne sont pas visitées. # external_id stable : slug de la fiche (logement-1). # robots.txt ouvert (Disallow vide), sitemap Yoast public. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://gestionjdm.ca" LIST_URL = f"{BASE}/logement/" _TYPE_RE = re.compile(r"([1-9])\s*[-\s]?\s*(?:½|1/2)") _CITY_RE = re.compile(r",\s*([^,()]+?)\s*\(QC\)", re.I) _SKIP_STATUS = re.compile(r"(?i)lou[ée]|indisponible|non\s+disponible") class GestionJdmConnector(BaseConnector): source_id = "gestion_jdm" request_delay = 0.6 max_images = 10 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") listings: dict[str, Listing] = {} for card in soup.select("div.uael-post-wrapper"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte UAEL Posts ---------------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one("a[href]") if link is None: return url = link["href"].strip() m = re.search(r"gestionjdm\.ca/([^/?#]+)", url) if not m: return ext_id = m.group(1) if ext_id in listings: return # adresse complète en extrait (« 52 rue de l'Anse, Témiscouata… (QC) G0L 1E0 ») excerpt = card.select_one(".uael-post__excerpt") address = re.sub(r"\s+", " ", excerpt.get_text(" ", strip=True)) if excerpt else "" m_city = _CITY_RE.search(address) city = m_city.group(1).strip() if m_city else "" # badge d'étiquette (« Disponible ») — brut ; cartes louées écartées badge = card.select_one(".uael-post__terms") availability = re.sub(r"\s+", " ", badge.get_text(" ", strip=True)) if badge else "" if _SKIP_STATUS.search(availability): return images: list[str] = [] thumb = card.select_one(".uael-post__thumbnail img[src]") if thumb and thumb["src"].startswith("http"): images.append(thumb["src"]) # fiche (cache : re-fetch seulement si adresse/badge changent) payload = self.detail(ext_id, f"{address}|{availability}", lambda: self._fetch_detail(url)) description = payload.get("description", "") amenities = payload.get("amenities", []) for u in payload.get("images", []): if u not in images and len(images) < self.max_images: images.append(u) # type d'unités : seulement si UN seul type est cité en prose types = sorted(set(_TYPE_RE.findall(f"{description} " f"{' '.join(amenities)}"))) unit_type = f"{types[0]}½" if len(types) == 1 else "" listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=address or ext_id, address=address, city=city, unit_type=unit_type, price=None, # aucun loyer publié sur le site price_label="", availability=availability, description=description, amenities=amenities, images=images, ) # -- fiche (/logement-/) ------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {"description": "", "amenities": [], "images": []} paras: list[str] = [] nearby: list[str] = [] for h4 in soup.select("h4.elementor-heading-title"): section = h4.get_text(" ", strip=True).lower() widget = h4.find_parent("div", class_="elementor-widget-heading") nxt = widget.find_next_sibling("div") if widget else None if nxt is None: continue if section.startswith("description"): paras += [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) for p in nxt.select("p")] if not nxt.select("p"): # texte sans

paras.append(re.sub(r"\s+", " ", nxt.get_text(" ", strip=True))) elif section.startswith("caractéristiques"): out["amenities"] = [ re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in nxt.select("li") if li.get_text(strip=True)] elif section.startswith("à proximité"): nearby = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in nxt.select("li") if li.get_text(strip=True)] description = "\n".join(t for t in paras if t)[:1400] if nearby: description = (description + "\nÀ proximité : " + ", ".join(nearby)).strip() out["description"] = description for img in soup.select("img[src*='/wp-content/uploads/']"): u = img["src"] if (u.startswith("http") and "logo" not in u.lower() and u not in out["images"]): out["images"].append(u) if len(out["images"]) >= self.max_images: break return out