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/garic.py : connecteur Garic Gestion Immobilière (garic.ca)5# Gestionnaire de Gatineau (Hull, Vieux-Gatineau, secteur est). WordPress6# (Oxygen) : la grille filtrable de /a-louer/ se nourrit d'un endpoint7# admin-ajax MAISON `garic_get_properties` qui renvoie TOUT l'inventaire8# en JSON structuré : ID, adresse (post_title), prix/mois, type (« 4 ½ »),9# chambres, salles de bain, superficie, disponibilité, image, URL de la10# fiche et géocodage complet (lat/lng + quartier OpenStreetMap). Le nonce11# de sécurité est lu sur la page /a-louer/ à chaque sync.12# L'unique propriété d'OTTAWA est exclue (périmètre Québec). La fiche13# /a-louer/<slug> (cache BD) ajoute description, inclusions, « À14# proximité » et scores de mobilité.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://garic.ca"27LIST_URL = f"{BASE}/a-louer/"28AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"2930_NONCE_RE = re.compile(r'nonce"\s*:\s*"([0-9a-f]+)"')31# « 74, Rue Saint-Paul, Vieux-Gatineau, Gatineau, Outaouais, … » -> secteur32_GENERIC_SEG = re.compile(33 r"(?i)^(gatineau|outaouais|quebec|québec|canada|j\d[a-z]\s?\d[a-z]\d|"34 r"\(secteur\).*|urban agglomeration.*|papineau|les collines-de-l'outaouais)$"35 r"|^(rue|avenue|av\.?|boul\.?|boulevard|chemin|mont[ée]e|impasse|place)\b")36# municipalités distinctes de la couronne parfois présentes dans le géocodage37_MUNICIPALITIES = {"thurso": "Thurso", "chelsea": "Chelsea",38 "cantley": "Cantley", "val-des-monts": "Val-des-Monts"}394041class GaricConnector(BaseConnector):42 source_id = "garic"43 request_delay = 1.044 max_details = 2045 max_images = 104647 # -- fiche détail ------------------------------------------------------------48 def _fetch_detail(self, url: str) -> dict:49 self._fetched += 150 soup = BeautifulSoup(self.get(url).text, "html.parser")51 out: dict = {"amenities": [], "nearby": [], "scores": {}}5253 def _section(title: str) -> list[str]:54 """Bloc `div.meta-info` : « <h3>Inclusions</h3> 🍳 Cuisinière<br/>… »55 — une entrée par segment séparé par <br/>."""56 h = soup.find("h3", string=re.compile(rf"^\s*{title}\s*$"))57 if not (h and h.parent):58 return []59 items = [re.sub(r"\s+", " ", t).strip()60 for t in h.parent.get_text("\n", strip=True).split("\n")]61 return [t for t in items62 if t and t != title and 2 < len(t) < 90][:15]6364 out["amenities"] = _section("Inclusions")65 out["nearby"] = _section("À proximité")6667 h = soup.find("h3", string=re.compile(r"^\s*Description\s*$"))68 if h and h.parent:69 txt = h.parent.get_text("\n", strip=True)70 txt = re.sub(r"^Description\s*\n?", "", txt)71 txt = re.sub(r"\n{2,}", "\n", txt)72 out["description"] = txt.strip()[:2000]7374 text = soup.get_text(" ", strip=True)75 for label, key in (("Walk Score", "walk"), ("Transit Score", "transit"),76 ("Bike Score", "bike")):77 m = re.search(rf"{label}\s*®?\s*(\d{{1,3}})", text)78 if m:79 out["scores"][key] = int(m.group(1))80 return out8182 # -- fetch -----------------------------------------------------------------83 def fetch(self) -> list[Listing]:84 # 1) nonce de la grille (rafraîchi à chaque sync)85 page = self.get(LIST_URL).text86 m = _NONCE_RE.search(page)87 if not m:88 return []89 # 2) inventaire JSON complet (POST via la session -> rejouable en test)90 r = self.session.post(91 AJAX_URL,92 data={"action": "garic_get_properties", "security": m.group(1)},93 timeout=self.timeout)94 r.raise_for_status()95 data = (r.json() or {}).get("data") or []9697 self._fetched = 098 listings: dict[str, Listing] = {}99 for rec in data:100 try:101 self._parse_record(rec, listings)102 except Exception:103 continue104 return list(listings.values())105106 def _parse_record(self, rec: dict, listings: dict[str, Listing]) -> None:107 ville = (rec.get("ville") or "").strip()108 if ville.lower() != "gatineau":109 return # Ottawa (Ontario) : hors périmètre110 ext = str(rec.get("ID") or "")111 url = (rec.get("url") or "").split("?")[0]112 if not ext or not url or ext in listings:113 return114115 title = re.sub(r"\s+", " ", rec.get("post_title") or "").strip()116 price_amt = (rec.get("prix_montant") or "").strip()117 per = (rec.get("prix_par") or "mois").strip()118 price_label = f"{price_amt}$ par {per}" if price_amt else ""119120 # superficie déclarée (souvent vide) — champ « superficie » brut121 area = None122 sup = (rec.get("superficie") or "").strip()123 if sup:124 try:125 v = float(re.sub(r"[^\d.]", "", sup))126 if 80 <= v <= 20000:127 area = v128 except ValueError:129 area = None130131 # géocodage publié : lat/lng + quartier OSM (« Vieux-Gatineau »)132 lat = lng = None133 sector = ""134 emp = rec.get("emplacement") or {}135 markers = emp.get("markers") or []136 if markers:137 lat, lng = markers[0].get("lat"), markers[0].get("lng")138 geos = markers[0].get("geocode") or []139 disp = ""140 if geos:141 disp = (geos[0].get("display_name")142 or (geos[0].get("properties") or {})143 .get("display_name") or "")144 segs = [s.strip() for s in disp.split(",")]145 for seg in segs[2:5]:146 if seg and not _GENERIC_SEG.match(seg) \147 and not re.match(r"^\d", seg):148 sector = seg149 break150 # le géocodage révèle parfois une municipalité distincte (Thurso…) :151 # elle devient la ville, sans secteur152 city = "Gatineau"153 if sector.lower() in _MUNICIPALITIES:154 city, sector = _MUNICIPALITIES[sector.lower()], ""155 if lat is None:156 lat, lng = emp.get("lat"), emp.get("lng")157158 images: list[str] = []159 img = rec.get("image") or {}160 for k in ("full", "large", "thumbnail"):161 u = img.get(k) or ""162 if u.startswith("http"):163 images.append(re.sub(r"-\d{2,4}x\d{2,4}(?=\.\w+$)", "", u))164 break165166 details: dict = {}167 sdb = (rec.get("pieces_salles_de_bain") or "").strip()168 if sdb.isdigit():169 details["bathrooms"] = int(sdb)170171 # fiche détail (description, inclusions, proximité, scores)172 payload: dict = {}173 key = hashlib.sha1(f"{title}|{price_label}|{rec.get('availability')}"174 .encode("utf-8")).hexdigest()[:20]175 if self._fetched < self.max_details:176 try:177 payload = self.detail(ext, key,178 lambda u=url: self._fetch_detail(u))179 except Exception:180 payload = {}181 desc_bits = []182 if payload.get("description"):183 desc_bits.append(payload["description"])184 if payload.get("nearby"):185 desc_bits.append("À proximité : " + ", ".join(payload["nearby"]))186 for k, v in (payload.get("scores") or {}).items():187 details[f"{k}_score"] = v188189 listings[ext] = Listing(190 source=self.source_id,191 external_id=ext,192 url=url,193 title=title,194 address=title, # le titre EST l'adresse civique195 sector=sector,196 city=city,197 unit_type=normalize_unit_type(rec.get("type") or ""),198 price=parse_price(price_label),199 price_label=price_label,200 availability=(rec.get("availability") or "").strip(),201 area_sqft=area,202 description="\n".join(desc_bits)[:2200],203 amenities=list(payload.get("amenities") or []),204 details=details,205 lat=float(lat) if lat is not None else None,206 lng=float(lng) if lng is not None else None,207 images=images[: self.max_images],208 )209