# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/elk.py : connecteur ELK Property Management (elkproperty.com) # Gestionnaire du Plateau/Hull à Gatineau. Très vieux site PHP (PinchCMS), # HTTP SEULEMENT (pas de HTTPS) : la page residential_new.php?typeID=1 # (Ottawa/Gatineau) rend côté serveur un bloc par complexe : # - div.results-in : adresse (h2 + code postal), galerie lightbox, # description à puces (secteur « Hull District »), contact, note de # loyer « Starting from $1050.00/month | Hydro/Gas not included », # listes « Building Amenities » / « Apartment Features » ; # - div.record-bttm#units_ : colonnes parallèles BEDROOMS / FLOORPLAN # (PDF) / RENT alignées par index -> une annonce par TYPOLOGIE affichée # sous « NOW RENTING / AVAILABLE APARTMENTS ». # Les complexes hors Québec (Halifax sous typeID=2, adresses ON) sont # exclus : seules les adresses « QC » passent. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "http://www.elkproperty.com" LIST_URL = f"{BASE}/residential_new.php?typeID=1" _SECTORS = ["Hull", "Aylmer", "Buckingham", "Plateau"] class ElkConnector(BaseConnector): source_id = "elk" request_delay = 1.0 max_images = 12 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") # tables d'unités par complexe : record-bttm id="units_" units_by_id: dict[str, list[dict]] = {} for block in soup.select("div.record-bttm[id^='units_']"): pid = block["id"].split("_", 1)[1] cols: dict[str, list] = {} for ul in block.find_all("ul"): h2 = ul.find("h2") if not h2: continue head = h2.get_text(" ", strip=True).upper() cells = ul.find_all("li")[1:] # après l'en-tête cols[head] = cells rows: list[dict] = [] beds = cols.get("BEDROOMS", []) rents = cols.get("RENT", []) plans = cols.get("FLOORPLAN", []) for i, bcell in enumerate(beds): b = re.sub(r"\s+", " ", bcell.get_text(" ", strip=True)) if not b: continue row: dict = {"beds": b} if i < len(rents): row["rent"] = re.sub(r"\s+", " ", rents[i].get_text(" ", strip=True)) if i < len(plans): a = plans[i].find("a", href=True) if a: row["plan"] = a["href"] rows.append(row) units_by_id[pid] = rows listings: dict[str, Listing] = {} for res in soup.select("div.results-in"): try: self._parse_complex(res, units_by_id, listings) except Exception: continue return list(listings.values()) def _parse_complex(self, res, units_by_id: dict, listings: dict[str, Listing]) -> None: right = res.select_one(".gallary-right") h2 = right.find("h2") if right else None if not h2: return span = h2.find("span") locality = span.get_text(" ", strip=True) if span else "" if span: span.extract() street = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)).strip() # « Gatineau QC, J9A 3J2 » : Québec seulement (Halifax/Ottawa exclus) if not re.search(r"\bQC\b", locality): return m = re.match(r"^([A-Za-zÀ-ÿ' .-]+?)\s+QC", locality) city = (m.group(1).strip() if m else "Gatineau") # id du complexe via la galerie lightbox « apt_6 » -> table units_6 pid = "" gal = res.select_one("[data-lightbox]") if gal: mm = re.search(r"(\d+)$", gal.get("data-lightbox", "")) if mm: pid = mm.group(1) images = [] for a in res.select("a[data-lightbox][href]"): u = a["href"] if not u.startswith("http"): u = BASE + (u if u.startswith("/") else "/" + u) if u not in images: images.append(u) text = right.get_text("\n", strip=True) # description à puces + note de loyer, texte fidèle de l'agence desc_lines = [re.sub(r"\s+", " ", l).strip() for l in text.split("\n")] desc_lines = [l for l in desc_lines if l and not re.match(r"(?i)^(contact us today|rent:$)", l) and "@" not in l and not re.match(r"^\d{3}-\d{3}-\d{4}$", l)] rent_note = "" for l in desc_lines: if re.search(r"(?i)starting from \$", l): rent_note = l break sector = "" for s in _SECTORS: if re.search(rf"(?i)\b{s}\b", text): sector = s break amenities: list[str] = [] for h4 in right.find_all("h4"): sec = h4.get_text(" ", strip=True) if not re.search(r"(?i)amenities|features", sec): continue ul = h4.find_next("ul") for li in (ul.find_all("li") if ul else []): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) contact = {} mm = re.search(r"([\w.+-]+@elkproperty\.com)", text) if mm: contact["email"] = mm.group(1) mm = re.search(r"\b(\d{3})[-. ](\d{3})[-. ](\d{4})\b", text) if mm: contact["phone"] = f"{mm.group(1)}-{mm.group(2)}-{mm.group(3)}" address = f"{street}, {city}" rows = units_by_id.get(pid, []) for row in rows: beds = row["beds"] mm = re.match(r"^(\d+)", beds) unit_type = (normalize_unit_type(f"{mm.group(1)} chambres") if mm else "") rent = row.get("rent", "") price = None pm = re.search(r"\$\s*([\d,]+)", rent) if pm: price = float(pm.group(1).replace(",", "")) details: dict = {} if contact: details["contact"] = dict(contact) plan = row.get("plan", "") if plan: if not plan.startswith("http"): plan = BASE + (plan if plan.startswith("/") else "/" + plan) details["floorplan_pdf"] = plan ext = f"{pid}-{mm.group(1) if mm else beds}" if ext in listings: continue desc = " — ".join(x for x in [ " ".join(desc_lines[:6]), rent_note] if x) listings[ext] = Listing( source=self.source_id, external_id=ext, url=f"{LIST_URL}#units_{pid}", title=f"{street} — {beds} bedroom(s)", address=address, sector=sector, city=city, unit_type=unit_type, price=price, price_label=rent, availability="Now renting", # bandeau de la section source description=desc[:900], amenities=amenities, details=details, images=images[: self.max_images], )