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/gia_t.py : connecteur GIA-T — Gestion Immobilière5# Abitibi-Témiscamingue (gia-t.com — Rouyn-Noranda ; gère aussi pour des6# tiers, inventaire variable). WordPress + Elementor + WP Grid Builder, tout7# rendu serveur. Liste /location/ : cartes `article.wpgb-card` — prix8# (« 1500$ »), adresse/titre, secteur (« Vieux Noranda », « Près du Cégep et9# de l'Université »), catégorie (Résidentiel — le commercial est exclu),10# « Grandeur : 5½ », extrait et photo pleine taille (lien lightbox). Fiche11# détail /logements/<slug>/ (via cache BD) : description complète12# (inclusions/exclusions, animaux, fumeur), chambres et salles de bain13# (icon-box), étage (icon-list), inclusions résumées et carrousel d'images.14# robots.txt ouvert (Disallow: vide).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://gia-t.com"27LIST_URL = f"{BASE}/location/"2829# suffixe de redimensionnement WordPress (« -768x1620.jpg » -> pleine taille)30_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)31_POST_ID_RE = re.compile(r"^wpgb-post-(\d+)$")32_PRICE_RE = re.compile(r"^\d[\d\s,]*\$")33_GRANDEUR_RE = re.compile(r"Grandeur\s*:\s*(.+)$", re.I)343536class GiaTConnector(BaseConnector):37 source_id = "gia_t"38 request_delay = 0.639 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync)4041 def fetch(self) -> list[Listing]:42 html = self.get(LIST_URL).text43 soup = BeautifulSoup(html, "html.parser")4445 self._fetched = 046 listings: dict[str, Listing] = {}47 for card in soup.select("article.wpgb-card"):48 try:49 self._parse_card(card, listings)50 except Exception:51 continue52 return list(listings.values())5354 # -- carte (WP Grid Builder) --------------------------------------------------------55 def _parse_card(self, card, listings: dict[str, Listing]) -> None:56 link = card.select_one('h3 a[href*="/logements/"]') \57 or card.select_one('a[href*="/logements/"]')58 if not link:59 return60 url = link["href"]61 m = re.search(r"/logements/([^/?#]+)", url)62 slug = m.group(1).strip("/") if m else ""6364 # external_id : ID du post WordPress (classe wpgb-post-<id>), sinon slug65 ext_id = slug66 for cls in card.get("class", []):67 m_id = _POST_ID_RE.match(cls)68 if m_id:69 ext_id = m_id.group(1)70 break71 if not ext_id or ext_id in listings:72 return7374 title = link.get_text(strip=True)7576 # blocs texte de la carte : prix, secteur, catégorie, « Grandeur : n½ »77 price_label = sector = category = unit_label = ""78 for blk in card.select(".wpgb-card-body div"):79 txt = re.sub(r"\s+", " ", blk.get_text(" ", strip=True))80 if not txt or blk.find("div"):81 continue82 m_g = _GRANDEUR_RE.search(txt)83 if m_g:84 unit_label = m_g.group(1).strip()85 elif _PRICE_RE.match(txt):86 price_label = txt87 elif txt in ("Résidentiel", "Commercial"):88 category = txt89 elif txt != title and len(txt) < 80:90 sector = txt9192 # exclusion : locaux commerciaux (taxonomie du site)93 if category and category != "Résidentiel":94 return9596 # extrait affiché sur la photo (sert de description de repli)97 excerpt_el = card.select_one(".wpgb-card-media-content p")98 excerpt = excerpt_el.get_text(" ", strip=True) if excerpt_el else ""99100 # photo : lien lightbox pleine taille, sinon miniature lazy-load101 images: list[str] = []102 lb = card.select_one("a.wpgb-lightbox[href]")103 if lb and lb["href"].startswith("http"):104 images.append(lb["href"])105 else:106 lazy = card.select_one("[data-wpgb-src]")107 if lazy and lazy["data-wpgb-src"].startswith("http"):108 images.append(_SIZE_SUFFIX.sub("", lazy["data-wpgb-src"]))109110 lst = Listing(111 source=self.source_id,112 external_id=ext_id,113 url=url,114 title=title,115 address=title if re.match(r"^\d+", title) else "",116 sector=sector,117 city="Rouyn-Noranda", # tout le parc est à Rouyn-Noranda118 unit_type=normalize_unit_type(unit_label),119 price=parse_price(price_label),120 price_label=price_label,121 description=excerpt,122 images=images,123 )124125 key = hashlib.sha1(126 f"{title}|{price_label}|{unit_label}|{excerpt}"127 .encode("utf-8")).hexdigest()128 try:129 payload = self.detail(ext_id, key,130 lambda u=url: self._fetch_detail(u))131 self._apply_detail(lst, payload)132 except Exception:133 pass134 listings[ext_id] = lst135136 # -- fiche détail (/logements/<slug>/) ----------------------------------------------137 def _fetch_detail(self, url: str) -> dict:138 """Description complète, chambres/sdb, étage, inclusions, carrousel."""139 if self._fetched >= self.max_details:140 raise RuntimeError("budget de fiches détail atteint")141 self._fetched += 1142 html = self.get(url).text143 soup = BeautifulSoup(html, "html.parser")144 out: dict = {}145146 desc_el = soup.select_one(".elementor-widget-theme-post-content")147 if desc_el:148 txt = desc_el.get_text("\n", strip=True)149 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]150151 # icon-box : « Nombre de chambre » / « Nombre de salle de bain » / « Dimension »152 amenities: list[str] = []153 for ib in soup.select(".elementor-widget-icon-box"):154 t_el = ib.select_one(".elementor-icon-box-title")155 v_el = ib.select_one(".elementor-icon-box-description")156 t = t_el.get_text(strip=True) if t_el else ""157 v = v_el.get_text(" ", strip=True) if v_el else ""158 if not v:159 continue160 if re.search(r"chambre", t, re.I):161 amenities.append(f"{v} chambre(s)")162 elif re.search(r"salle de bain", t, re.I):163 amenities.append(f"{v} salle(s) de bain")164 elif re.search(r"dimension", t, re.I):165 amenities.append(f"Dimension : {v}")166167 # inclusions résumées : bloc texte suivant le titre « Inclusions »168 for h in soup.select(".elementor-widget-heading .elementor-heading-title"):169 if h.get_text(strip=True).lower() == "inclusions":170 widget = h.find_parent(class_="elementor-widget-heading")171 nxt = widget.find_next(class_="elementor-widget-text-editor") \172 if widget else None173 if nxt:174 val = nxt.get_text(" ", strip=True)175 if val:176 amenities.append(f"Inclus : {val}")177 break178 out["amenities"] = amenities[:15]179180 images = []181 for img in soup.select(".elementor-widget-image-carousel img[src]"):182 u = _SIZE_SUFFIX.sub("", img["src"])183 if u.startswith("http") and u not in images:184 images.append(u)185 out["images"] = images[:25]186 return out187188 def _apply_detail(self, lst: Listing, d: dict) -> None:189 """Reporte le payload (frais ou en cache) sur l'annonce."""190 if not d:191 return192 if d.get("description"):193 lst.description = d["description"]194 if d.get("amenities"):195 lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))196 if d.get("images") and len(d["images"]) > len(lst.images):197 lst.images = d["images"]198