spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/immeubles_guillot.py : connecteur Les Immeubles Guillot5# (immeublesguillot.com) — 3 immeubles / 61 unités à Québec :6# - Ader (Beauport, 7 unités, 7½)7# - Boul. Ste-Anne (Beauport, 28 unités, 1½/3½/4½)8# - des Cyprès (Charlesbourg, 26 unités, 3½/4½/5½)9# Site WordPress rendu serveur : une page par immeuble avec l'adresse,10# les types offerts (« Types d'appartements dans cet immeuble »), un11# tableau de commodités (colonnes Logement / Immeuble / Quartier) et des12# galeries « Appartement modèle » par type. Aucun prix ni disponibilité13# affichés -> price=None, availability vide (rien d'inventé).14# Granularité = immeuble × type d'appartement (comme utile.py).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing23from .base import BaseConnector2425BASE = "https://immeublesguillot.com"26INDEX_URL = f"{BASE}/appartements-a-louer/"2728# images de contenu (uploads) ; icônes/logos exclus29_SKIP_IMG = re.compile(r"/icon-|logo|favicon|-\d{2,4}x\d{2,4}\.", re.I)30_TYPE_RE = re.compile(r"^\d\s*(?:1/2|½)$")31_ADDR_RE = re.compile(r"G\d[A-Z]\s?\d[A-Z]\d", re.I) # code postal québécois323334def _type_slug(t: str) -> str:35 return re.sub(r"[^a-z0-9]+", "-", t.lower().replace("½", "1-2")).strip("-")363738class ImmeublesGuillotConnector(BaseConnector):39 source_id = "immeubles_guillot"40 request_delay = 0.641 max_buildings = 10 # garde-fou42 max_images = 124344 def fetch(self) -> list[Listing]:45 index = self.get(INDEX_URL).text46 slugs = list(dict.fromkeys(47 re.findall(r'href="https?://immeublesguillot\.com'48 r'/appartements-a-louer/([a-z0-9\-]+)/"', index)))49 listings: list[Listing] = []50 for slug in slugs[: self.max_buildings]:51 try:52 listings.extend(self._building(slug))53 except Exception:54 continue55 return listings5657 # -- une page d'immeuble ----------------------------------------------------58 def _building(self, slug: str) -> list[Listing]:59 url = f"{BASE}/appartements-a-louer/{slug}/"60 html = self.get(url).text61 soup = BeautifulSoup(html, "html.parser")6263 # nom affiché : dernier élément du fil d'Ariane (« Beauport / Ader »)64 name = slug65 crumb = soup.select_one(".breadcrumbs, #breadcrumbs, .nectar-breadcrumbs")66 if crumb:67 parts = [t.strip() for t in crumb.get_text("»").split("»")68 if t.strip()]69 if parts:70 name = parts[-1]71 sector = name.split("/")[0].strip() if "/" in name else ""7273 # adresse : titre contenant un code postal (« 3608 Boul. Ste-Anne,74 # Québec, G1E 3M1 »)75 address = ""76 for h in soup.find_all(["h1", "h2", "h3", "h4"]):77 t = re.sub(r"\s+", " ", h.get_text(" ", strip=True))78 if _ADDR_RE.search(t):79 address = t80 break8182 # types offerts : <p> qui suit « Types d'appartements dans cet immeuble »83 types: list[str] = []84 for h3 in soup.find_all(["h3", "h2"]):85 if "types d" not in h3.get_text(strip=True).lower():86 continue87 p = h3.find_next("p")88 if p:89 for line in p.get_text("\n", strip=True).split("\n"):90 line = re.sub(r"\s+", " ", line).strip()91 if _TYPE_RE.match(line) and line not in types:92 types.append(line)93 break9495 # commodités : tableau tablepress (colonnes Logement et Immeuble ;96 # la colonne Quartier décrit le voisinage, pas le logement)97 amenities: list[str] = []98 for td in soup.select("table.tablepress td.column-2, "99 "table.tablepress td.column-4"):100 for line in td.get_text("\n", strip=True).split("\n"):101 t = re.sub(r"\s+", " ", line).strip()102 if 3 <= len(t) <= 90 and t not in amenities:103 amenities.append(t)104105 # description : premiers paragraphes après l'adresse106 desc_parts: list[str] = []107 for p in soup.find_all("p"):108 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))109 if len(t) >= 80 and not p.find_parent(("footer", "nav")):110 desc_parts.append(t)111 if len(desc_parts) >= 2:112 break113 description = " ".join(desc_parts)[:600]114115 # galeries « Appartement modèle – X 1/2 » : photos par type ;116 # repli : toutes les photos de la page117 all_photos = [u for u in dict.fromkeys(re.findall(118 r'https://immeublesguillot\.com/wp-content/uploads/'119 r'[^"\s]+\.(?:jpe?g|webp|png)', html))120 if not _SKIP_IMG.search(u)]121 by_type: dict[str, list[str]] = {}122 for h2 in soup.find_all("h2"):123 head = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))124 m = re.match(r"Appartement modèle(?:\s*[–-]\s*(\d\s*1/2))?",125 head, re.I)126 if not m:127 continue128 key = re.sub(r"\s+", " ", m.group(1)).strip() if m.group(1) else ""129 gallery = h2.find_parent("div", class_="wpb_wrapper")130 root = (gallery.find_parent("div", class_="vc_column-inner")131 or gallery) if gallery else h2.parent132 imgs = []133 # la galerie lie chaque vignette (-600x375) à l'original plein format134 for a in root.select('a[href*="/wp-content/uploads/"]'):135 href = a.get("href") or ""136 if (href.startswith("http") and not _SKIP_IMG.search(href)137 and re.search(r"\.(?:jpe?g|webp|png)$", href, re.I)138 and href not in imgs):139 imgs.append(href)140 for img in root.find_all("img"):141 src = img.get("src") or img.get("data-src") or ""142 if (src.startswith("http") and not _SKIP_IMG.search(src)143 and src not in imgs):144 imgs.append(src)145 if imgs:146 by_type[key] = imgs[: self.max_images]147148 out: list[Listing] = []149 for t in types:150 images = by_type.get(t) or by_type.get("") or all_photos151 out.append(Listing(152 source=self.source_id,153 external_id=f"{slug}-{_type_slug(t)}",154 url=url,155 title=f"Immeubles Guillot — {name} ({t})",156 address=address,157 sector=sector,158 city="Québec",159 unit_type=t,160 price=None, # aucun prix affiché sur le site161 price_label="",162 availability="", # aucune disponibilité affichée163 description=description,164 amenities=list(amenities),165 images=list(images)[: self.max_images],166 ))167 return out168