# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gia_t.py : connecteur GIA-T — Gestion Immobilière # Abitibi-Témiscamingue (gia-t.com — Rouyn-Noranda ; gère aussi pour des # tiers, inventaire variable). WordPress + Elementor + WP Grid Builder, tout # rendu serveur. Liste /location/ : cartes `article.wpgb-card` — prix # (« 1500$ »), adresse/titre, secteur (« Vieux Noranda », « Près du Cégep et # de l'Université »), catégorie (Résidentiel — le commercial est exclu), # « Grandeur : 5½ », extrait et photo pleine taille (lien lightbox). Fiche # détail /logements// (via cache BD) : description complète # (inclusions/exclusions, animaux, fumeur), chambres et salles de bain # (icon-box), étage (icon-list), inclusions résumées et carrousel d'images. # robots.txt ouvert (Disallow: vide). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gia-t.com" LIST_URL = f"{BASE}/location/" # suffixe de redimensionnement WordPress (« -768x1620.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _POST_ID_RE = re.compile(r"^wpgb-post-(\d+)$") _PRICE_RE = re.compile(r"^\d[\d\s,]*\$") _GRANDEUR_RE = re.compile(r"Grandeur\s*:\s*(.+)$", re.I) class GiaTConnector(BaseConnector): source_id = "gia_t" request_delay = 0.6 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} for card in soup.select("article.wpgb-card"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte (WP Grid Builder) -------------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('h3 a[href*="/logements/"]') \ or card.select_one('a[href*="/logements/"]') if not link: return url = link["href"] m = re.search(r"/logements/([^/?#]+)", url) slug = m.group(1).strip("/") if m else "" # external_id : ID du post WordPress (classe wpgb-post-), sinon slug ext_id = slug for cls in card.get("class", []): m_id = _POST_ID_RE.match(cls) if m_id: ext_id = m_id.group(1) break if not ext_id or ext_id in listings: return title = link.get_text(strip=True) # blocs texte de la carte : prix, secteur, catégorie, « Grandeur : n½ » price_label = sector = category = unit_label = "" for blk in card.select(".wpgb-card-body div"): txt = re.sub(r"\s+", " ", blk.get_text(" ", strip=True)) if not txt or blk.find("div"): continue m_g = _GRANDEUR_RE.search(txt) if m_g: unit_label = m_g.group(1).strip() elif _PRICE_RE.match(txt): price_label = txt elif txt in ("Résidentiel", "Commercial"): category = txt elif txt != title and len(txt) < 80: sector = txt # exclusion : locaux commerciaux (taxonomie du site) if category and category != "Résidentiel": return # extrait affiché sur la photo (sert de description de repli) excerpt_el = card.select_one(".wpgb-card-media-content p") excerpt = excerpt_el.get_text(" ", strip=True) if excerpt_el else "" # photo : lien lightbox pleine taille, sinon miniature lazy-load images: list[str] = [] lb = card.select_one("a.wpgb-lightbox[href]") if lb and lb["href"].startswith("http"): images.append(lb["href"]) else: lazy = card.select_one("[data-wpgb-src]") if lazy and lazy["data-wpgb-src"].startswith("http"): images.append(_SIZE_SUFFIX.sub("", lazy["data-wpgb-src"])) lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=title if re.match(r"^\d+", title) else "", sector=sector, city="Rouyn-Noranda", # tout le parc est à Rouyn-Noranda unit_type=normalize_unit_type(unit_label), price=parse_price(price_label), price_label=price_label, description=excerpt, images=images, ) key = hashlib.sha1( f"{title}|{price_label}|{unit_label}|{excerpt}" .encode("utf-8")).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche détail (/logements//) ---------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description complète, chambres/sdb, étage, inclusions, carrousel.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} desc_el = soup.select_one(".elementor-widget-theme-post-content") if desc_el: txt = desc_el.get_text("\n", strip=True) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] # icon-box : « Nombre de chambre » / « Nombre de salle de bain » / « Dimension » amenities: list[str] = [] for ib in soup.select(".elementor-widget-icon-box"): t_el = ib.select_one(".elementor-icon-box-title") v_el = ib.select_one(".elementor-icon-box-description") t = t_el.get_text(strip=True) if t_el else "" v = v_el.get_text(" ", strip=True) if v_el else "" if not v: continue if re.search(r"chambre", t, re.I): amenities.append(f"{v} chambre(s)") elif re.search(r"salle de bain", t, re.I): amenities.append(f"{v} salle(s) de bain") elif re.search(r"dimension", t, re.I): amenities.append(f"Dimension : {v}") # inclusions résumées : bloc texte suivant le titre « Inclusions » for h in soup.select(".elementor-widget-heading .elementor-heading-title"): if h.get_text(strip=True).lower() == "inclusions": widget = h.find_parent(class_="elementor-widget-heading") nxt = widget.find_next(class_="elementor-widget-text-editor") \ if widget else None if nxt: val = nxt.get_text(" ", strip=True) if val: amenities.append(f"Inclus : {val}") break out["amenities"] = amenities[:15] images = [] for img in soup.select(".elementor-widget-image-carousel img[src]"): u = _SIZE_SUFFIX.sub("", img["src"]) if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:25] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("description"): lst.description = d["description"] if d.get("amenities"): lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"]