# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/construction_app.py : connecteur Construction APP / Les # Habitations APP (constructionappinc.com) — constructeur de Portneuf qui # loue ses immeubles à Pont-Rouge, Donnacona, Saint-Marc-des-Carrières, # Portneuf, Saint-Basile, Val-Bélair et Sainte-Catherine-de-la-J.-Cartier. # WordPress/Divi rendu serveur : /vente-et-location/ empile des blocs # `.et_pb_row` — h1 « Ville (Québec) », libellé « LOGEMENT À LOUER »/ # « MAISONS DE VILLE EN LOCATION », adresse, caractéristiques (chambres, # sdb, types n ½) et photos. Ni prix ni date (Tier B). # Granularité : immeuble (un bloc = une annonce). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://www.constructionappinc.com" LIST_URL = f"{BASE}/vente-et-location/" LABEL_RE = re.compile(r"(LOGEMENTS?|MAISONS? DE VILLE)" r".{0,40}(À LOUER|EN LOCATION)", re.I | re.S) CITY_RE = re.compile(r"^(.+?)\s*\(Qu[ée]bec\)$", re.I) ADDR_RE = re.compile(r"^\d[\d\s\wàâéèêëîïôùûç.'-]*,\s*.+", re.I) ADDR2_RE = re.compile(r"^(rue|avenue|av\.|boul)", re.I) BED_RE = re.compile(r"Chambre\(?s?\)?\s*:\s*(\d+)", re.I) BATH_RE = re.compile(r"Salle\(?s?\)?\s*de\s*bain\s*:\s*(\d+)", re.I) TYPE_RE = re.compile(r"\b(\d)\s*1/2\b") CONTACT = {"email": "info@habitationsapp.com", "phone": "418-410-0715"} def _slug(text: str) -> str: return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", strip_accents(text.lower())) ).strip("-") class ConstructionAppConnector(BaseConnector): source_id = "construction_app" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") seen: set[str] = set() for row in soup.select("div.et_pb_row"): try: lst = self._parse_row(row, seen) except Exception: continue if lst is not None: listings.append(lst) return listings def _parse_row(self, row, seen: set[str]) -> Listing | None: txt = row.get_text("\n", strip=True) if not LABEL_RE.search(txt): return None lines = [ln.strip() for ln in txt.split("\n") if ln.strip()] city = "" for ln in lines: m = CITY_RE.match(ln) if m: city = m.group(1).strip() break address = next( (ln for ln in lines if ADDR_RE.match(ln) and not ln.lower().startswith(("t 4", "418")) and "1/2" not in ln), "") if not address: address = next((ln for ln in lines if ADDR2_RE.match(ln) and "," in ln), "") if not address: return None # bloc générique sans adresse if not city: # ville = dernier segment de l'adresse tail = address.split(",")[-1].strip() if tail and not re.match(r"^suite", tail, re.I): city = tail ext_id = _slug(address) if not ext_id or ext_id in seen: return None seen.add(ext_id) blob = " | ".join(lines) bedrooms = bathrooms = None m = BED_RE.search(blob) if m: bedrooms = float(m.group(1)) m = BATH_RE.search(blob) if m: bathrooms = float(m.group(1)) types = list(dict.fromkeys(f"{n}½" for n in TYPE_RE.findall(blob))) unit_type = types[0] if len(types) == 1 else "" # commodités : lignes courtes ni adresse/ville/label/contacts amenities = [ln for ln in lines if 3 < len(ln) <= 60 and ln not in (address, city) and not CITY_RE.match(ln) and not LABEL_RE.search(ln) and not re.search(r"(chambre|salle|1/2|@|\d{3}[ -]\d{4}" r"|nouveau projet|contactez|photos?\b" r"|plans? des|en savoir)", ln, re.I)][:12] images = [img.get("src", "") for img in row.find_all("img") if img.get("src", "").startswith("http") and "icon" not in img.get("src", "").lower() and "logo" not in img.get("src", "").lower()][:10] label = "MAISONS DE VILLE" if re.search(r"maisons? de ville", blob, re.I) else "Logement" return Listing( source=self.source_id, external_id=ext_id, url=LIST_URL, title=f"{address}", address=f"{address}" + (f", {city}" if city and city.lower() not in address.lower() else ""), city=city, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price_label="Prix sur demande", availability="Disponible", description=f"{label} à louer au {address} ({city}) — " "Les Habitations APP (Construction APP). " + (f"Types offerts : {', '.join(types)}. " if types else "") + " ".join(amenities[:6]), amenities=amenities, details={"contact": dict(CONTACT), **({"types": types} if len(types) > 1 else {})}, images=images, )