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/gestion_jdm.py : connecteur JDM Gestion immobilière (gestionjdm.ca5# — Rivière-du-Loup, Témiscouata-sur-le-Lac, Cacouna ; Bas-Saint-Laurent).6# WordPress + Elementor (widget UAEL Posts) rendu serveur :7# - page /logement/ : cartes `.uael-post-wrapper.logement` — badge8# d'étiquette (« Disponible »), adresse complète en extrait9# (« 36 rue plourde, Rivière-du-Loup (QC) G5R 1A1 »), photo, lien fiche ;10# - fiche (/logement-1/…) : sections h4 « Description » (text-editor),11# « Caractéristiques » et « À proximité » (icon-list), galerie.12# Granularité IMMEUBLE (« 24 unités de logements sur 3 étages ») — précédent13# girs/immeubles_guillot : aucun prix ni liste d'unités publiés -> price=None,14# rien d'inventé. Les cartes étiquetées « loué / indisponible » (si elles15# apparaissent un jour) sont écartées ; le badge brut alimente availability.16# Le type n'est retenu que si UN seul est cité (« 3-1/2, 4-1/2 et 5-1/2 » ->17# vide, la description fait foi). Les pages /chambre/ (maisons de chambres)18# et /commercial/ ne sont pas visitées.19# external_id stable : slug de la fiche (logement-1).20# robots.txt ouvert (Disallow vide), sitemap Yoast public.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import re2526from bs4 import BeautifulSoup2728from ..schema import Listing29from .base import BaseConnector3031BASE = "https://gestionjdm.ca"32LIST_URL = f"{BASE}/logement/"3334_TYPE_RE = re.compile(r"([1-9])\s*[-\s]?\s*(?:½|1/2)")35_CITY_RE = re.compile(r",\s*([^,()]+?)\s*\(QC\)", re.I)36_SKIP_STATUS = re.compile(r"(?i)lou[ée]|indisponible|non\s+disponible")373839class GestionJdmConnector(BaseConnector):40 source_id = "gestion_jdm"41 request_delay = 0.642 max_images = 104344 def fetch(self) -> list[Listing]:45 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")46 listings: dict[str, Listing] = {}47 for card in soup.select("div.uael-post-wrapper"):48 try:49 self._parse_card(card, listings)50 except Exception:51 continue52 return list(listings.values())5354 # -- carte UAEL Posts ----------------------------------------------------------55 def _parse_card(self, card, listings: dict[str, Listing]) -> None:56 link = card.select_one("a[href]")57 if link is None:58 return59 url = link["href"].strip()60 m = re.search(r"gestionjdm\.ca/([^/?#]+)", url)61 if not m:62 return63 ext_id = m.group(1)64 if ext_id in listings:65 return6667 # adresse complète en extrait (« 52 rue de l'Anse, Témiscouata… (QC) G0L 1E0 »)68 excerpt = card.select_one(".uael-post__excerpt")69 address = re.sub(r"\s+", " ",70 excerpt.get_text(" ", strip=True)) if excerpt else ""71 m_city = _CITY_RE.search(address)72 city = m_city.group(1).strip() if m_city else ""7374 # badge d'étiquette (« Disponible ») — brut ; cartes louées écartées75 badge = card.select_one(".uael-post__terms")76 availability = re.sub(r"\s+", " ",77 badge.get_text(" ", strip=True)) if badge else ""78 if _SKIP_STATUS.search(availability):79 return8081 images: list[str] = []82 thumb = card.select_one(".uael-post__thumbnail img[src]")83 if thumb and thumb["src"].startswith("http"):84 images.append(thumb["src"])8586 # fiche (cache : re-fetch seulement si adresse/badge changent)87 payload = self.detail(ext_id, f"{address}|{availability}",88 lambda: self._fetch_detail(url))89 description = payload.get("description", "")90 amenities = payload.get("amenities", [])91 for u in payload.get("images", []):92 if u not in images and len(images) < self.max_images:93 images.append(u)9495 # type d'unités : seulement si UN seul type est cité en prose96 types = sorted(set(_TYPE_RE.findall(f"{description} "97 f"{' '.join(amenities)}")))98 unit_type = f"{types[0]}½" if len(types) == 1 else ""99100 listings[ext_id] = Listing(101 source=self.source_id,102 external_id=ext_id,103 url=url,104 title=address or ext_id,105 address=address,106 city=city,107 unit_type=unit_type,108 price=None, # aucun loyer publié sur le site109 price_label="",110 availability=availability,111 description=description,112 amenities=amenities,113 images=images,114 )115116 # -- fiche (/logement-<n>/) ------------------------------------------------------117 def _fetch_detail(self, url: str) -> dict:118 soup = BeautifulSoup(self.get(url).text, "html.parser")119 out: dict = {"description": "", "amenities": [], "images": []}120121 paras: list[str] = []122 nearby: list[str] = []123 for h4 in soup.select("h4.elementor-heading-title"):124 section = h4.get_text(" ", strip=True).lower()125 widget = h4.find_parent("div", class_="elementor-widget-heading")126 nxt = widget.find_next_sibling("div") if widget else None127 if nxt is None:128 continue129 if section.startswith("description"):130 paras += [re.sub(r"\s+", " ", p.get_text(" ", strip=True))131 for p in nxt.select("p")]132 if not nxt.select("p"): # texte sans <p>133 paras.append(re.sub(r"\s+", " ",134 nxt.get_text(" ", strip=True)))135 elif section.startswith("caractéristiques"):136 out["amenities"] = [137 re.sub(r"\s+", " ", li.get_text(" ", strip=True))138 for li in nxt.select("li") if li.get_text(strip=True)]139 elif section.startswith("à proximité"):140 nearby = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))141 for li in nxt.select("li") if li.get_text(strip=True)]142143 description = "\n".join(t for t in paras if t)[:1400]144 if nearby:145 description = (description + "\nÀ proximité : "146 + ", ".join(nearby)).strip()147 out["description"] = description148149 for img in soup.select("img[src*='/wp-content/uploads/']"):150 u = img["src"]151 if (u.startswith("http") and "logo" not in u.lower()152 and u not in out["images"]):153 out["images"].append(u)154 if len(out["images"]) >= self.max_images:155 break156 return out157