# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/firma.py : connecteur Groupe Firma (groupefirma.ca) # Condos locatifs en Montérégie / banlieue sud-ouest de Montréal # (Salaberry-de-Valleyfield, Saint-Zotique, Sainte-Barbe, L'Île-Perrot...). # Découverte des immeubles via l'API REST WordPress (post type "housing"), # puis parsing des fiches /logement// : unités
  • # avec type, disponibilité, étage, superficie, commodités (icônes bjm-active) # et images (plan + galerie de l'unité). Le prix est présent dans un # commentaire HTML "" (souvent vide). # Lachute (Laurentides, hors Grand Montréal) est exclue. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://groupefirma.ca" API_URL = f"{BASE}/wp-json/wp/v2/housing?per_page=100" # Municipalités admissibles (Montérégie sud-ouest / Grand Montréal). _ALLOWED_CITY_KEYS = ( "valleyfield", "saint-zotique", "st-zotique", "sainte-barbe", "ste-barbe", "saint-louis-de-gonzague", "st-louis-de-gonzague", "ile-perrot", "saint-timothee", "st-timothee", "les-coteaux", "coteau-du-lac", "vaudreuil", "pincourt", "beauharnois", "chateauguay", ) # Normalisation des noms de villes rencontrés dans les adresses. _CITY_CANON = { "valleyfield": "Salaberry-de-Valleyfield", "salaberry-de-valleyfield": "Salaberry-de-Valleyfield", "saint-zotique": "Saint-Zotique", "st-zotique": "Saint-Zotique", "ste-barbe": "Sainte-Barbe", "sainte-barbe": "Sainte-Barbe", "st-louis-de-gonzague": "Saint-Louis-de-Gonzague", "saint-louis-de-gonzague": "Saint-Louis-de-Gonzague", "st-timothee": "Saint-Timothée", "saint-timothee": "Saint-Timothée", "l'ile-perrot": "L'Île-Perrot", "lile-perrot": "L'Île-Perrot", "ile-perrot": "L'Île-Perrot", } _PRICE_COMMENT_RE = re.compile(r"") _IMG_RE = re.compile(r"\.(?:jpg|jpeg|png|webp)(?:$|\?)", re.I) _ICON_RE = re.compile(r"-ico\.|icon|logo|favicon", re.I) def _canon_city(raw: str) -> str: key = strip_accents(raw.strip().lower()).replace("’", "'") key = re.sub(r"\s+qc\.?$", "", key).strip(" ,") return _CITY_CANON.get(key, raw.strip(" ,")) def _city_allowed(raw: str) -> bool: key = strip_accents(raw.strip().lower()).replace(" ", "-").replace("’", "'") return any(tok in key for tok in _ALLOWED_CITY_KEYS) class FirmaConnector(BaseConnector): source_id = "firma" request_delay = 0.6 max_buildings = 40 # garde-fou de crawl def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: buildings = self.get(API_URL).json() except Exception: return listings if not isinstance(buildings, list): return listings for i, b in enumerate(buildings): if i >= self.max_buildings: break try: listings.extend(self._parse_building(b)) except Exception: continue return listings # -- une fiche d'immeuble -------------------------------------------------- def _parse_building(self, b: dict) -> list[Listing]: slug = b.get("slug") or "" url = b.get("link") or f"{BASE}/logement/{slug}/" name = htmllib.unescape((b.get("title") or {}).get("rendered") or slug) name = re.sub(r"\s+", " ", name).strip() html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # Bloc d'en-tête : nom + adresse civique de l'immeuble address = "" addr_el = soup.select_one(".bjm-singlehousing-details-bluebox-address") if addr_el: address = addr_el.get_text(" ", strip=True) city_raw = address.split(",")[-1].strip() if "," in address else "" if not city_raw or not _city_allowed(city_raw): return [] # hors Grand Montréal (ex. Lachute) city = _canon_city(city_raw) desc_el = soup.select_one(".bjm-singlehousing-details-description") description = (desc_el.get_text(" ", strip=True)[:600] if desc_el else "") # Commodités de l'immeuble (icônes actives du bloc de détails) building_amenities: list[str] = [] for item in soup.select(".bjm-singlehousing-details-icons-item"): img = item.select_one("img.bjm-active") if img: label = item.get_text(" ", strip=True) if label: building_amenities.append(label) # Images de secours : galerie « photos du projet » fallback_imgs = self._section_images(soup.find(id="bjm-singlehousing-photos")) results: list[Listing] = [] for li in soup.select('li[id^="unit-"]'): lst = self._parse_unit(li, slug, url, name, address, city, description, building_amenities, fallback_imgs) if lst: results.append(lst) return results # -- une unité (li id="unit-NNN") ----------------------------------------- def _parse_unit(self, li, slug: str, url: str, name: str, address: str, city: str, description: str, building_amenities: list[str], fallback_imgs: list[str]) -> Listing | None: unit_no = (li.get("id") or "").replace("unit-", "").strip() if not unit_no: return None num_el = li.select_one(".bjm-unit-number") label = num_el.get_text(strip=True).lstrip("#") if num_el else unit_no h3 = li.find("h3") type_raw = h3.get_text(" ", strip=True) if h3 else "" unit_type = normalize_unit_type(type_raw) # Prix : masqué dans un commentaire HTML "" price = None price_label = "" m = _PRICE_COMMENT_RE.search(str(li)) if m and re.search(r"\d", m.group(1)): price_label = f"{m.group(1).strip()}$ / mois" price = parse_price(price_label) # Disponibilité : badge + date du bloc de texte avail_el = li.select_one(".bjm-det-label") availability = avail_el.get_text(" ", strip=True) if avail_el else "" details_txt = li.get_text(" ", strip=True) dm = re.search(r"Disponibilit[ée]\s*:\s*([\d/]+)", details_txt) if dm: availability = (f"{availability} ({dm.group(1)})" if availability else dm.group(1)) # Champs structurés de la fiche d'unité : étage, superficies (pi²) details: dict = {} area_sqft = None extras = [] fm = re.search(r"[ÉE]tage\s*:\s*([^\s].{0,30}?)(?:\s{2,}|Superficie)", details_txt) if fm: floor_txt = fm.group(1).strip() extras.append(f"Étage : {floor_txt}") if floor_txt.isdigit() and 0 < int(floor_txt) <= 60: details["floor"] = int(floor_txt) sm = re.search(r"Superficie\s*:\s*(\d[\d\s.,]*)", details_txt) if sm: extras.append(f"Superficie : {sm.group(1).strip()} pi²") try: v = float(sm.group(1).strip().replace(" ", "") .replace(",", ".")) if 80 <= v <= 20000: # explicite : sinon le parsing générique prendrait le min # avec la superficie du balcon ci-dessous area_sqft = v except ValueError: pass bm = re.search(r"Superficie du balcon\s*:\s*(\d[\d\s.,]*)", details_txt) if bm: extras.append(f"Superficie du balcon : {bm.group(1).strip()} pi²") unit_desc = " | ".join(extras) full_desc = " — ".join(p for p in (unit_desc, description) if p)[:600] # Commodités de l'unité : icônes actives seulement amenities = [img.get("alt", "").strip() for img in li.select("img.bjm-active[alt]") if img.get("alt", "").strip()] amenities = list(dict.fromkeys(amenities + building_amenities)) # Images : plan + galerie propres à l'unité, sinon photos du projet images = self._section_images(li) if not images: images = fallback_imgs return Listing( source=self.source_id, external_id=f"{slug}-{unit_no}", url=url, title=f"{name} — {unit_type or 'unité'} #{label}".strip(), address=address, sector="", city=city, unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area_sqft, description=full_desc, amenities=amenities, details=details, images=images, ) # -- images d'une section (liens lightbox + ) ------------------------- @staticmethod def _section_images(node) -> list[str]: if node is None: return [] urls: list[str] = [] for a in node.select("a[href]"): href = a.get("href") or "" if _IMG_RE.search(href): urls.append(href) for img in node.select("img[src]"): src = img.get("src") or "" if _IMG_RE.search(src) and "uploads" in src: urls.append(src) out: list[str] = [] for u in urls: if not u.startswith("http"): u = BASE + u if not _ICON_RE.search(u): out.append(u) return list(dict.fromkeys(out))[:25]