Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/construction_app.py : connecteur Construction APP / Les5# Habitations APP (constructionappinc.com) — constructeur de Portneuf qui6# loue ses immeubles à Pont-Rouge, Donnacona, Saint-Marc-des-Carrières,7# Portneuf, Saint-Basile, Val-Bélair et Sainte-Catherine-de-la-J.-Cartier.8# WordPress/Divi rendu serveur : /vente-et-location/ empile des blocs9# `.et_pb_row` — h1 « Ville (Québec) », libellé « LOGEMENT À LOUER »/10# « MAISONS DE VILLE EN LOCATION », adresse, caractéristiques (chambres,11# sdb, types n ½) et photos. Ni prix ni date (Tier B).12# Granularité : immeuble (un bloc = une annonce).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, strip_accents21from .base import BaseConnector2223BASE = "https://www.constructionappinc.com"24LIST_URL = f"{BASE}/vente-et-location/"2526LABEL_RE = re.compile(r"(LOGEMENTS?|MAISONS? DE VILLE)"27 r".{0,40}(À LOUER|EN LOCATION)", re.I | re.S)28CITY_RE = re.compile(r"^(.+?)\s*\(Qu[ée]bec\)$", re.I)29ADDR_RE = re.compile(r"^\d[\d\s\wàâéèêëîïôùûç.'-]*,\s*.+", re.I)30ADDR2_RE = re.compile(r"^(rue|avenue|av\.|boul)", re.I)31BED_RE = re.compile(r"Chambre\(?s?\)?\s*:\s*(\d+)", re.I)32BATH_RE = re.compile(r"Salle\(?s?\)?\s*de\s*bain\s*:\s*(\d+)", re.I)33TYPE_RE = re.compile(r"\b(\d)\s*1/2\b")3435CONTACT = {"email": "info@habitationsapp.com", "phone": "418-410-0715"}363738def _slug(text: str) -> str:39 return re.sub(r"-+", "-",40 re.sub(r"[^a-z0-9]+", "-", strip_accents(text.lower()))41 ).strip("-")424344class ConstructionAppConnector(BaseConnector):45 source_id = "construction_app"46 request_delay = 0.64748 def fetch(self) -> list[Listing]:49 listings: list[Listing] = []50 try:51 html = self.get(LIST_URL).text52 except Exception:53 return listings54 soup = BeautifulSoup(html, "html.parser")5556 seen: set[str] = set()57 for row in soup.select("div.et_pb_row"):58 try:59 lst = self._parse_row(row, seen)60 except Exception:61 continue62 if lst is not None:63 listings.append(lst)64 return listings6566 def _parse_row(self, row, seen: set[str]) -> Listing | None:67 txt = row.get_text("\n", strip=True)68 if not LABEL_RE.search(txt):69 return None70 lines = [ln.strip() for ln in txt.split("\n") if ln.strip()]7172 city = ""73 for ln in lines:74 m = CITY_RE.match(ln)75 if m:76 city = m.group(1).strip()77 break7879 address = next(80 (ln for ln in lines81 if ADDR_RE.match(ln) and not ln.lower().startswith(("t 4", "418"))82 and "1/2" not in ln), "")83 if not address:84 address = next((ln for ln in lines85 if ADDR2_RE.match(ln) and "," in ln), "")86 if not address:87 return None # bloc générique sans adresse88 if not city: # ville = dernier segment de l'adresse89 tail = address.split(",")[-1].strip()90 if tail and not re.match(r"^suite", tail, re.I):91 city = tail9293 ext_id = _slug(address)94 if not ext_id or ext_id in seen:95 return None96 seen.add(ext_id)9798 blob = " | ".join(lines)99 bedrooms = bathrooms = None100 m = BED_RE.search(blob)101 if m:102 bedrooms = float(m.group(1))103 m = BATH_RE.search(blob)104 if m:105 bathrooms = float(m.group(1))106 types = list(dict.fromkeys(f"{n}½" for n in TYPE_RE.findall(blob)))107 unit_type = types[0] if len(types) == 1 else ""108109 # commodités : lignes courtes ni adresse/ville/label/contacts110 amenities = [ln for ln in lines111 if 3 < len(ln) <= 60112 and ln not in (address, city)113 and not CITY_RE.match(ln)114 and not LABEL_RE.search(ln)115 and not re.search(r"(chambre|salle|1/2|@|\d{3}[ -]\d{4}"116 r"|nouveau projet|contactez|photos?\b"117 r"|plans? des|en savoir)",118 ln, re.I)][:12]119120 images = [img.get("src", "") for img in row.find_all("img")121 if img.get("src", "").startswith("http")122 and "icon" not in img.get("src", "").lower()123 and "logo" not in img.get("src", "").lower()][:10]124125 label = "MAISONS DE VILLE" if re.search(r"maisons? de ville",126 blob, re.I) else "Logement"127 return Listing(128 source=self.source_id,129 external_id=ext_id,130 url=LIST_URL,131 title=f"{address}",132 address=f"{address}" + (f", {city}" if city133 and city.lower() not in address.lower()134 else ""),135 city=city,136 unit_type=unit_type,137 bedrooms=bedrooms,138 bathrooms=bathrooms,139 price_label="Prix sur demande",140 availability="Disponible",141 description=f"{label} à louer au {address} ({city}) — "142 "Les Habitations APP (Construction APP). "143 + (f"Types offerts : {', '.join(types)}. "144 if types else "") + " ".join(amenities[:6]),145 amenities=amenities,146 details={"contact": dict(CONTACT),147 **({"types": types} if len(types) > 1 else {})},148 images=images,149 )150