SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
8.1 KB · 198 lines python
Raw Blame History
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