# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/garic.py : connecteur Garic Gestion Immobilière (garic.ca) # Gestionnaire de Gatineau (Hull, Vieux-Gatineau, secteur est). WordPress # (Oxygen) : la grille filtrable de /a-louer/ se nourrit d'un endpoint # admin-ajax MAISON `garic_get_properties` qui renvoie TOUT l'inventaire # en JSON structuré : ID, adresse (post_title), prix/mois, type (« 4 ½ »), # chambres, salles de bain, superficie, disponibilité, image, URL de la # fiche et géocodage complet (lat/lng + quartier OpenStreetMap). Le nonce # de sécurité est lu sur la page /a-louer/ à chaque sync. # L'unique propriété d'OTTAWA est exclue (périmètre Québec). La fiche # /a-louer/ (cache BD) ajoute description, inclusions, « À # proximité » et scores de mobilité. # ----------------------------------------------------------------------------- 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://garic.ca" LIST_URL = f"{BASE}/a-louer/" AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" _NONCE_RE = re.compile(r'nonce"\s*:\s*"([0-9a-f]+)"') # « 74, Rue Saint-Paul, Vieux-Gatineau, Gatineau, Outaouais, … » -> secteur _GENERIC_SEG = re.compile( r"(?i)^(gatineau|outaouais|quebec|québec|canada|j\d[a-z]\s?\d[a-z]\d|" r"\(secteur\).*|urban agglomeration.*|papineau|les collines-de-l'outaouais)$" r"|^(rue|avenue|av\.?|boul\.?|boulevard|chemin|mont[ée]e|impasse|place)\b") # municipalités distinctes de la couronne parfois présentes dans le géocodage _MUNICIPALITIES = {"thurso": "Thurso", "chelsea": "Chelsea", "cantley": "Cantley", "val-des-monts": "Val-des-Monts"} class GaricConnector(BaseConnector): source_id = "garic" request_delay = 1.0 max_details = 20 max_images = 10 # -- fiche détail ------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: self._fetched += 1 soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {"amenities": [], "nearby": [], "scores": {}} def _section(title: str) -> list[str]: """Bloc `div.meta-info` : «

Inclusions

🍳 Cuisinière
… » — une entrée par segment séparé par
.""" h = soup.find("h3", string=re.compile(rf"^\s*{title}\s*$")) if not (h and h.parent): return [] items = [re.sub(r"\s+", " ", t).strip() for t in h.parent.get_text("\n", strip=True).split("\n")] return [t for t in items if t and t != title and 2 < len(t) < 90][:15] out["amenities"] = _section("Inclusions") out["nearby"] = _section("À proximité") h = soup.find("h3", string=re.compile(r"^\s*Description\s*$")) if h and h.parent: txt = h.parent.get_text("\n", strip=True) txt = re.sub(r"^Description\s*\n?", "", txt) txt = re.sub(r"\n{2,}", "\n", txt) out["description"] = txt.strip()[:2000] text = soup.get_text(" ", strip=True) for label, key in (("Walk Score", "walk"), ("Transit Score", "transit"), ("Bike Score", "bike")): m = re.search(rf"{label}\s*®?\s*(\d{{1,3}})", text) if m: out["scores"][key] = int(m.group(1)) return out # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: # 1) nonce de la grille (rafraîchi à chaque sync) page = self.get(LIST_URL).text m = _NONCE_RE.search(page) if not m: return [] # 2) inventaire JSON complet (POST via la session -> rejouable en test) r = self.session.post( AJAX_URL, data={"action": "garic_get_properties", "security": m.group(1)}, timeout=self.timeout) r.raise_for_status() data = (r.json() or {}).get("data") or [] self._fetched = 0 listings: dict[str, Listing] = {} for rec in data: try: self._parse_record(rec, listings) except Exception: continue return list(listings.values()) def _parse_record(self, rec: dict, listings: dict[str, Listing]) -> None: ville = (rec.get("ville") or "").strip() if ville.lower() != "gatineau": return # Ottawa (Ontario) : hors périmètre ext = str(rec.get("ID") or "") url = (rec.get("url") or "").split("?")[0] if not ext or not url or ext in listings: return title = re.sub(r"\s+", " ", rec.get("post_title") or "").strip() price_amt = (rec.get("prix_montant") or "").strip() per = (rec.get("prix_par") or "mois").strip() price_label = f"{price_amt}$ par {per}" if price_amt else "" # superficie déclarée (souvent vide) — champ « superficie » brut area = None sup = (rec.get("superficie") or "").strip() if sup: try: v = float(re.sub(r"[^\d.]", "", sup)) if 80 <= v <= 20000: area = v except ValueError: area = None # géocodage publié : lat/lng + quartier OSM (« Vieux-Gatineau ») lat = lng = None sector = "" emp = rec.get("emplacement") or {} markers = emp.get("markers") or [] if markers: lat, lng = markers[0].get("lat"), markers[0].get("lng") geos = markers[0].get("geocode") or [] disp = "" if geos: disp = (geos[0].get("display_name") or (geos[0].get("properties") or {}) .get("display_name") or "") segs = [s.strip() for s in disp.split(",")] for seg in segs[2:5]: if seg and not _GENERIC_SEG.match(seg) \ and not re.match(r"^\d", seg): sector = seg break # le géocodage révèle parfois une municipalité distincte (Thurso…) : # elle devient la ville, sans secteur city = "Gatineau" if sector.lower() in _MUNICIPALITIES: city, sector = _MUNICIPALITIES[sector.lower()], "" if lat is None: lat, lng = emp.get("lat"), emp.get("lng") images: list[str] = [] img = rec.get("image") or {} for k in ("full", "large", "thumbnail"): u = img.get(k) or "" if u.startswith("http"): images.append(re.sub(r"-\d{2,4}x\d{2,4}(?=\.\w+$)", "", u)) break details: dict = {} sdb = (rec.get("pieces_salles_de_bain") or "").strip() if sdb.isdigit(): details["bathrooms"] = int(sdb) # fiche détail (description, inclusions, proximité, scores) payload: dict = {} key = hashlib.sha1(f"{title}|{price_label}|{rec.get('availability')}" .encode("utf-8")).hexdigest()[:20] if self._fetched < self.max_details: try: payload = self.detail(ext, key, lambda u=url: self._fetch_detail(u)) except Exception: payload = {} desc_bits = [] if payload.get("description"): desc_bits.append(payload["description"]) if payload.get("nearby"): desc_bits.append("À proximité : " + ", ".join(payload["nearby"])) for k, v in (payload.get("scores") or {}).items(): details[f"{k}_score"] = v listings[ext] = Listing( source=self.source_id, external_id=ext, url=url, title=title, address=title, # le titre EST l'adresse civique sector=sector, city=city, unit_type=normalize_unit_type(rec.get("type") or ""), price=parse_price(price_label), price_label=price_label, availability=(rec.get("availability") or "").strip(), area_sqft=area, description="\n".join(desc_bits)[:2200], amenities=list(payload.get("amenities") or []), details=details, lat=float(lat) if lat is not None else None, lng=float(lng) if lng is not None else None, images=images[: self.max_images], )