# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immeubles_guillot.py : connecteur Les Immeubles Guillot # (immeublesguillot.com) — 3 immeubles / 61 unités à Québec : # - Ader (Beauport, 7 unités, 7½) # - Boul. Ste-Anne (Beauport, 28 unités, 1½/3½/4½) # - des Cyprès (Charlesbourg, 26 unités, 3½/4½/5½) # Site WordPress rendu serveur : une page par immeuble avec l'adresse, # les types offerts (« Types d'appartements dans cet immeuble »), un # tableau de commodités (colonnes Logement / Immeuble / Quartier) et des # galeries « Appartement modèle » par type. Aucun prix ni disponibilité # affichés -> price=None, availability vide (rien d'inventé). # Granularité = immeuble × type d'appartement (comme utile.py). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://immeublesguillot.com" INDEX_URL = f"{BASE}/appartements-a-louer/" # images de contenu (uploads) ; icônes/logos exclus _SKIP_IMG = re.compile(r"/icon-|logo|favicon|-\d{2,4}x\d{2,4}\.", re.I) _TYPE_RE = re.compile(r"^\d\s*(?:1/2|½)$") _ADDR_RE = re.compile(r"G\d[A-Z]\s?\d[A-Z]\d", re.I) # code postal québécois def _type_slug(t: str) -> str: return re.sub(r"[^a-z0-9]+", "-", t.lower().replace("½", "1-2")).strip("-") class ImmeublesGuillotConnector(BaseConnector): source_id = "immeubles_guillot" request_delay = 0.6 max_buildings = 10 # garde-fou max_images = 12 def fetch(self) -> list[Listing]: index = self.get(INDEX_URL).text slugs = list(dict.fromkeys( re.findall(r'href="https?://immeublesguillot\.com' r'/appartements-a-louer/([a-z0-9\-]+)/"', index))) listings: list[Listing] = [] for slug in slugs[: self.max_buildings]: try: listings.extend(self._building(slug)) except Exception: continue return listings # -- une page d'immeuble ---------------------------------------------------- def _building(self, slug: str) -> list[Listing]: url = f"{BASE}/appartements-a-louer/{slug}/" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # nom affiché : dernier élément du fil d'Ariane (« Beauport / Ader ») name = slug crumb = soup.select_one(".breadcrumbs, #breadcrumbs, .nectar-breadcrumbs") if crumb: parts = [t.strip() for t in crumb.get_text("»").split("»") if t.strip()] if parts: name = parts[-1] sector = name.split("/")[0].strip() if "/" in name else "" # adresse : titre contenant un code postal (« 3608 Boul. Ste-Anne, # Québec, G1E 3M1 ») address = "" for h in soup.find_all(["h1", "h2", "h3", "h4"]): t = re.sub(r"\s+", " ", h.get_text(" ", strip=True)) if _ADDR_RE.search(t): address = t break # types offerts :

qui suit « Types d'appartements dans cet immeuble » types: list[str] = [] for h3 in soup.find_all(["h3", "h2"]): if "types d" not in h3.get_text(strip=True).lower(): continue p = h3.find_next("p") if p: for line in p.get_text("\n", strip=True).split("\n"): line = re.sub(r"\s+", " ", line).strip() if _TYPE_RE.match(line) and line not in types: types.append(line) break # commodités : tableau tablepress (colonnes Logement et Immeuble ; # la colonne Quartier décrit le voisinage, pas le logement) amenities: list[str] = [] for td in soup.select("table.tablepress td.column-2, " "table.tablepress td.column-4"): for line in td.get_text("\n", strip=True).split("\n"): t = re.sub(r"\s+", " ", line).strip() if 3 <= len(t) <= 90 and t not in amenities: amenities.append(t) # description : premiers paragraphes après l'adresse desc_parts: list[str] = [] for p in soup.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if len(t) >= 80 and not p.find_parent(("footer", "nav")): desc_parts.append(t) if len(desc_parts) >= 2: break description = " ".join(desc_parts)[:600] # galeries « Appartement modèle – X 1/2 » : photos par type ; # repli : toutes les photos de la page all_photos = [u for u in dict.fromkeys(re.findall( r'https://immeublesguillot\.com/wp-content/uploads/' r'[^"\s]+\.(?:jpe?g|webp|png)', html)) if not _SKIP_IMG.search(u)] by_type: dict[str, list[str]] = {} for h2 in soup.find_all("h2"): head = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) m = re.match(r"Appartement modèle(?:\s*[–-]\s*(\d\s*1/2))?", head, re.I) if not m: continue key = re.sub(r"\s+", " ", m.group(1)).strip() if m.group(1) else "" gallery = h2.find_parent("div", class_="wpb_wrapper") root = (gallery.find_parent("div", class_="vc_column-inner") or gallery) if gallery else h2.parent imgs = [] # la galerie lie chaque vignette (-600x375) à l'original plein format for a in root.select('a[href*="/wp-content/uploads/"]'): href = a.get("href") or "" if (href.startswith("http") and not _SKIP_IMG.search(href) and re.search(r"\.(?:jpe?g|webp|png)$", href, re.I) and href not in imgs): imgs.append(href) for img in root.find_all("img"): src = img.get("src") or img.get("data-src") or "" if (src.startswith("http") and not _SKIP_IMG.search(src) and src not in imgs): imgs.append(src) if imgs: by_type[key] = imgs[: self.max_images] out: list[Listing] = [] for t in types: images = by_type.get(t) or by_type.get("") or all_photos out.append(Listing( source=self.source_id, external_id=f"{slug}-{_type_slug(t)}", url=url, title=f"Immeubles Guillot — {name} ({t})", address=address, sector=sector, city="Québec", unit_type=t, price=None, # aucun prix affiché sur le site price_label="", availability="", # aucune disponibilité affichée description=description, amenities=list(amenities), images=list(images)[: self.max_images], )) return out