# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/multi_logis.py : connecteur Multi-Logis (multi-logis.com — 500+ # logements : Sept-Îles, Port-Cartier — plus gros parc de la Côte-Nord — # et aussi Lévis, toutes les villes conservées). WordPress + Elementor + # JetEngine + plugin maison « multilogis-listing », tout rendu serveur. # Liste /trouver-mon-appartement/ : une carte par bâtiment (nom, adresse, # typologies offertes, « À partir de »). Page /batiment// (via cache # BD) : unités disponibles individuelles (numéro, type, description, loyer, # superficie, disponibilité, galerie, plan), adresse structurée du champ ACF # (ville, code postal, lat/lng) et listes « Inclus » / « Caractéristiques » # de l'immeuble. Une annonce Lou-Ka = une unité disponible. Les champs des # unités sont identifiés par leur CONTENU (motifs « $/mois », « Studio/n½ », # « Superficie: », « Disponibilité: ») — robustes aux ids Elementor. # Immeubles commerciaux (« Le St-Georges ») sans unités : ignorés d'eux-mêmes. # robots.txt ouvert, sitemap Yoast. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://multi-logis.com" LIST_URL = f"{BASE}/trouver-mon-appartement/" # suffixe de redimensionnement WordPress (« -1024x1024.png » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _TYPE_RE = re.compile(r"^(studio|\d\s*½|\d\s*1/2)$", re.I) _ACF_RE = re.compile(r"'(address|city|post_code|lat|lng)'\s*=>\s*'?([^',\n]+)'?") def _clean_img(url: str) -> str: return _SIZE_SUFFIX.sub("", url.strip()) class MultiLogisConnector(BaseConnector): source_id = "multi_logis" request_delay = 0.6 max_details = 25 # garde-fou pages bâtiment (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") # cartes bâtiment (le sélecteur de carte duplique la grille : dédupliquer) buildings: dict[str, dict] = {} for item in soup.select(".jet-listing-grid__item"): link = item.select_one('a[href*="/batiment/"]') if not link: continue m = re.search(r"/batiment/([^/?#]+)", link["href"]) if not m: continue slug = m.group(1).strip("/") fields = [re.sub(r"\s+", " ", f.get_text(" ", strip=True)) for f in item.select(".jet-listing-dynamic-field__content")] fields = [f for f in fields if f] if slug in buildings and len(fields) <= len(buildings[slug]["fields"]): continue buildings[slug] = {"fields": fields} self._fetched = 0 listings: list[Listing] = [] for slug, info in buildings.items(): try: listings.extend(self._parse_building(slug, info["fields"])) except Exception: continue return listings # -- un bâtiment ------------------------------------------------------------------ def _parse_building(self, slug: str, fields: list[str]) -> list[Listing]: url = f"{BASE}/batiment/{slug}/" name = fields[0] if fields else slug card_addr = next((f for f in fields if re.search(r",\s*QC", f, re.I)), "") # page bâtiment (cache BD, invalidée quand la carte liste change) key = hashlib.sha1("|".join(fields).encode("utf-8")).hexdigest() def fetch_fn(): if self._fetched >= self.max_details: raise RuntimeError("budget de pages bâtiment atteint") self._fetched += 1 return self._fetch_building(url) payload = self.detail(slug, key, fetch_fn) if not payload: return [] address = payload.get("address") or card_addr city = payload.get("city") or "" if payload.get("post_code") and payload["post_code"] not in address: address = f"{address}, {payload['post_code']}" lat, lng = payload.get("lat"), payload.get("lng") amenities_building = payload.get("amenities", []) out: list[Listing] = [] for u in payload.get("units", []): numero = u.get("numero") or "" m = re.search(r"#\s*([\w-]+)", numero) unit_no = (m.group(1) if m else "").strip(" ,#").lower() if not unit_no: # unité affichée sans numéro (« Appartement # ») : identifiant # stable dérivé du contenu invariant (type, superficie, plan, # début de description) — pas du prix ni de la disponibilité sig = (f"{u.get('type', '')}|{u.get('superficie', '')}|" f"{u.get('plan', '')}|{u.get('description', '')[:40]}") unit_no = "sn-" + hashlib.sha1(sig.encode("utf-8")).hexdigest()[:8] amenities = list(amenities_building) details: dict = {"building": name} if u.get("plan"): details["floorplan"] = u["plan"] area = None if u.get("superficie"): m_a = re.search(r"([\d\s]{2,6})\s*pi", u["superficie"]) if m_a: val = float(re.sub(r"\s", "", m_a.group(1))) if 80 <= val <= 20000: area = val availability = "" if u.get("disponibilite"): availability = re.sub(r"^Disponibilit[ée]\s*:\s*", "", u["disponibilite"], flags=re.I).strip() out.append(Listing( source=self.source_id, external_id=f"{slug}--{unit_no}", url=url, title=f"{name} — {numero}" if numero else name, address=address, city=city, unit_type=normalize_unit_type(u.get("type", "")), price=parse_price(u.get("loyer", "")), price_label=u.get("loyer", ""), availability=availability, area_sqft=area, description=u.get("description", ""), amenities=amenities, details=details, images=(u.get("images") or [])[:20], lat=lat, lng=lng, )) return out # -- page bâtiment ------------------------------------------------------------------ def _fetch_building(self, url: str) -> dict: """Payload JSON-sérialisable : unités disponibles + infos du bâtiment.""" resp = self.get(url) html = resp.text soup = BeautifulSoup(html, "html.parser") payload: dict = {} # adresse structurée : champ ACF Google Maps rendu en clair dans la page # ('address' => '40, rue Saint-Étienne, à Lévis', 'city' => 'Lévis'…) acf = dict() for k, v in _ACF_RE.findall(html): acf.setdefault(k, v.strip()) if acf.get("address"): payload["address"] = re.sub(r",?\s*à\s+", ", ", acf["address"]) for k in ("city", "post_code"): if acf.get(k): payload[k] = acf[k] try: payload["lat"] = float(acf["lat"]) payload["lng"] = float(acf["lng"]) except (KeyError, ValueError): pass # listes « Inclus » et « Caractéristiques » de l'immeuble amenities: list[str] = [] for h in soup.find_all(["h2", "h3", "h4"]): titre = h.get_text(strip=True).lower() if titre not in ("inclus", "caractéristiques", "caracteristiques"): continue cont = h.find_parent(class_="elementor-widget") for sib in (cont.find_next_siblings() if cont else []): lis = sib.select("li") if lis: for li in lis: t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) break payload["amenities"] = amenities[:25] # unités disponibles : items JetEngine contenant un champ « numéro » units: list[dict] = [] for item in soup.select(".jet-listing-grid__item"): unit = self._parse_unit(item) if unit: units.append(unit) payload["units"] = units return payload # -- une unité (classification des champs par contenu) ------------------------------- def _parse_unit(self, item) -> dict | None: texts: list[str] = [] for f in item.select(".jet-listing-dynamic-field__content"): t = re.sub(r"\s+", " ", f.get_text(" ", strip=True)) if t: texts.append(t) unit: dict = {} for t in texts: if re.search(r"^Appartement\s*#", t, re.I) and "numero" not in unit: unit["numero"] = t.rstrip(" ,") elif _TYPE_RE.match(t) and "type" not in unit: unit["type"] = t elif re.search(r"\$\s*/\s*mois|\d\$/mois", t) and "loyer" not in unit: unit["loyer"] = t elif re.search(r"^Superficie\s*:", t, re.I): unit["superficie"] = t elif re.search(r"^Disponibilit[ée]\s*:", t, re.I): unit["disponibilite"] = t elif len(t) > 60 and "description" not in unit: unit["description"] = t[:1200] if "numero" not in unit: return None # carte bâtiment / bloc décoratif # galerie de l'unité (liens lightbox pleine taille) images: list[str] = [] gal = item.select_one(".single-unit-gallery") or item for a in gal.select('a[href*="/wp-content/uploads/"]'): u = _clean_img(a["href"]) if u.startswith("http") and u not in images: images.append(u) unit["images"] = images # plan d'étage (« Voir le plan », seulement si un lien réel est fourni) for a in item.select("a[href]"): if "plan" in a.get_text(strip=True).lower(): href = a["href"].strip() if href.startswith("http") and "/wp-content/" in href: unit["plan"] = href break return unit