SPB Git

spb/lou-ka

Public

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

[ka2] fix connecteur c3r: réécriture pour la refonte du site (Wix → Astro, 2026-09-08) — l'API wix-data n'existe plus. Le sync de 12:32 a rapporté found=0 ok=1 (médiane 8, retraits suspendus) : reproduit en live, les collections Wix Import1/Import2 renvoient 404 WDE0025 « collection does not exist » et les anciennes URLs d'unités (/terrasses-st-maurice/…) répondent 302 → /logements. Gestion C3R a lancé aujourd'hui (sitemap lastmod 2026-09-08) un nouveau site statique Astro servi par Express : catalogue /logements?page=N paginé côté serveur (5 pages, 73 unités, 4 projets — Chemin du Lac, Terrasses St-Maurice, Rue des Tournesols, Maisons St-Bo), chaque unité en <article data-rental-unit> avec data-city/project/bedrooms/price/status + type, adresse, sdb, superficie, photo. Nouveau fetch() : pagination avec dédup par slug (au-delà de la dernière page le serveur re-sert la même page), décodage UTF-8 forcé (pas de charset dans les en-têtes → requests décodait « Loué » en « Loué »), filtre statut Disponible conservé, RuntimeError si 0 carte parsée (garde contre rendu partiel/structure changée, même pattern que brio/le_fjord). Testé : parsing 73/73 unités (prix 73, adresses 73, sdb 73, superficie 73, photos 65), found=0 en prod — VRAI zéro : les 73 unités du nouveau site sont toutes « Loué », y compris les 8 qui étaient « Disponible » ce matin sur l'ancien CMS. Le garde-fou lèvera la suspension des retraits après le 3e sync à 0 (DRIFT_HISTORY=5) et les 8 annonces périmées seront dépubliées naturellement. pm2 restart lou-ka-sync, site :8095 → 200. sources.json : listing_url → /logements, + Shawinigan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 8, 2026) parent e5379b0 Browse files

2 changed files +112 −124

modified data/sources.json +2 −2
@@ -2903,8 +2903,8 @@
2903 "id": "c3r", 2903 "id": "c3r",
2904 "name": "Gestion C3R", 2904 "name": "Gestion C3R",
2905 "url": "https://www.gestionc3r.com", 2905 "url": "https://www.gestionc3r.com",
2906− "listing_url": "https://www.gestionc3r.com/projets", 2906+ "listing_url": "https://www.gestionc3r.com/logements",
2907− "sectors": "Saint-Maurice (Terrasses St-Maurice), Saint-Boniface (Chemin du Lac)", 2907+ "sectors": "Saint-Maurice (Terrasses St-Maurice), Saint-Boniface (Chemin du Lac), Shawinigan (Rue des Tournesols)",
2908 "connector": "c3r", 2908 "connector": "c3r",
2909 "status": "actif", 2909 "status": "actif",
2910 "region": "Mauricie" 2910 "region": "Mauricie"
modified louka/connectors/c3r.py +110 −122
@@ -3,150 +3,138 @@
3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 # connectors/c3r.py : connecteur Gestion C3R (gestionc3r.com) 4 # connectors/c3r.py : connecteur Gestion C3R (gestionc3r.com)
5 # Gestionnaire de la Mauricie (Trois-Rivières, Shawinigan, Saint-Boniface, 5 # Gestionnaire de la Mauricie (Trois-Rivières, Shawinigan, Saint-Boniface,
6−# Saint-Maurice — 450+ appartements, projets neufs affichés sur le site). 6+# Saint-Maurice). Refonte du site le 2026-09-08 : l'ancien site Wix (et son
7−# Site Wix : les unités vivent dans des collections CMS interrogeables via 7+# API wix-data, collections Import1/Import2) a été remplacé par un site
8−# l'API wix-data publique (jeton d'app wix-code obtenu via 8+# statique Astro servi par Express — les collections n'existent plus
9−# /_api/v1/access-tokens, puis POST /_api/cloud-data/v1/wix-data/collections/ 9+# (WDE0025) et les anciennes URLs redirigent vers /logements.
10−# query). Collections réelles : Import1 (Terrasses St-Maurice) et Import2 10+# Nouveau modèle : catalogue /logements?page=N paginé côté serveur
11−# (St-Boniface) — champs availabilityStatus (Disponible/Réservé/En attente/ 11+# (~18 cartes/page, 73 unités, 4 projets), chaque carte <article
12−# Loué), price, unitType, addressString, bedrooms, sizeSqFt… 12+# data-rental-unit> porte des attributs data-* propres (city, project,
13−# (LeDomaineDesTournesols = vitrine de typologies sans dispo ni prix réels, 13+# bedrooms, price, status) + type, adresse, salle de bain, superficie et
14−# descriptions anglaises génériques : ignorée.) 14+# photo dans le corps de la carte. Seul le statut « Disponible » est gardé
15−# Granularité : une annonce par unité. 15+# (Loué / Réservé : non). Granularité : une annonce par unité.
16 # ----------------------------------------------------------------------------- 16 # -----------------------------------------------------------------------------
17 from __future__ import annotations 17 from __future__ import annotations
18 18
19−import json
20 import re 19 import re
21 20
21+from bs4 import BeautifulSoup
22+
22 from ..schema import Listing, normalize_unit_type 23 from ..schema import Listing, normalize_unit_type
23 from .base import BaseConnector 24 from .base import BaseConnector
24 25
25 BASE = "https://www.gestionc3r.com" 26 BASE = "https://www.gestionc3r.com"
26−TOKENS_URL = f"{BASE}/_api/v1/access-tokens" 27+LIST_URL = f"{BASE}/logements"
27−QUERY_URL = f"{BASE}/_api/cloud-data/v1/wix-data/collections/query"
28−
29−# appDefinitionId de wix-code (constant chez Wix, pas propre au site)
30−WIX_CODE_APP = "675bbcef-18d8-41f5-800e-131ec9e08762"
31−
32−# collection -> (nom du projet, ville, champ de lien vers la page dynamique)
33−COLLECTIONS = [
34− ("Import1", "Terrasses St-Maurice", "Saint-Maurice",
35− "link-terrasses-st-maurice-title"),
36− ("Import2", "St-Boniface", "Saint-Boniface", "link-st-boniface-title"),
37−]
38 28
39−# seul « Disponible » = louable maintenant (Réservé / En attente / Loué : non) 29+# seul « Disponible » = louable maintenant (Réservé / Loué : non)
40 KEEP_STATUS = {"disponible"} 30 KEEP_STATUS = {"disponible"}
41 31
42−WIX_IMG_RE = re.compile(r"^wix:image://v1/([^/]+)/") 32+MAX_PAGES = 20 # 5 pages au lancement (2026-09-08) — marge de croissance
43 33
44 34
45−def _wix_image(value: str | None) -> str: 35+def _num(raw: str | None) -> float | None:
46− """Normalise une image Wix (URL https ou URI wix:image://) en URL CDN.""" 36+ """Premier nombre d'un texte (« 1 575 $/mois », « 1100 pi² ») → float."""
47− if not value: 37+ if raw is None:
48− return "" 38+ return None
49− if value.startswith("http"): 39+ digits = re.sub(r"[^\d,.]", "", str(raw)).replace(",", ".")
50− return value.split("#")[0] 40+ m = re.match(r"\d+(?:\.\d+)?", digits)
51− m = WIX_IMG_RE.match(value) 41+ return float(m.group(0)) if m else None
52− if m:
53− return f"https://static.wixstatic.com/media/{m.group(1)}"
54− return ""
55 42
56 43
57 class C3RConnector(BaseConnector): 44 class C3RConnector(BaseConnector):
58 source_id = "c3r" 45 source_id = "c3r"
59 request_delay = 0.7 46 request_delay = 0.7
60 47
61− def _instance_token(self) -> str:
62− data = self.get(TOKENS_URL).json()
63− return ((data.get("apps") or {}).get(WIX_CODE_APP) or {}) \
64− .get("instance") or ""
65−
66 def fetch(self) -> list[Listing]: 48 def fetch(self) -> list[Listing]:
67 listings: list[Listing] = [] 49 listings: list[Listing] = []
68− try:
69− token = self._instance_token()
70− except Exception:
71− return listings
72− if not token:
73− return listings
74−
75 seen: set[str] = set() 50 seen: set[str] = set()
76− for coll, project, city, link_field in COLLECTIONS: 51+ cards_total = 0
77− try: 52+
78− resp = self.post( 53+ for page in range(1, MAX_PAGES + 1):
79− QUERY_URL, 54+ params = {"page": page} if page > 1 else None
80− headers={"Authorization": token, 55+ # le serveur n'annonce pas de charset → requests décoderait en
81− "Content-Type": "application/json"}, 56+ # latin-1 (« Loué ») ; la page déclare <meta charset="utf-8">
82− data=json.dumps({ 57+ html = self.get(LIST_URL, params=params).content.decode(
83− "collectionName": coll, 58+ "utf-8", errors="replace")
84− "dataQuery": {"paging": {"offset": 0, "limit": 200}}, 59+ soup = BeautifulSoup(html, "html.parser")
85− "segment": "LIVE", 60+ cards = soup.select("article[data-rental-unit]")
86− })) 61+ if not cards:
87− items = resp.json().get("items") or [] 62+ break
88− except Exception: 63+
89− continue 64+ new_on_page = 0
90− for it in items: 65+ for card in cards:
91− try: 66+ link = card.select_one("a[href]")
92− status = (it.get("availabilityStatus") or "").strip() 67+ href = (link.get("href") or "").strip() if link else ""
93− if status.lower() not in KEEP_STATUS: 68+ slug = href.rstrip("/").split("/")[-1] if href else ""
94− continue 69+ ext_id = slug or ""
95− link = (it.get(link_field) or "").strip() 70+ if not ext_id or ext_id in seen:
96− slug = link.rstrip("/").split("/")[-1] if link else "" 71+ continue # au-delà de la dernière page, le site re-sert
97− ext_id = slug or str(it.get("_id") or "") 72+ seen.add(ext_id) # la même page → dédup par slug
98− if not ext_id: 73+ new_on_page += 1
99− continue 74+ cards_total += 1
100− if ext_id in seen: # doublons de saisie dans le CMS 75+
101− ext_id = f"{ext_id}-{str(it.get('_id', ''))[:8]}" 76+ status = (card.get("data-status") or "").strip()
102− if ext_id in seen: 77+ if status.lower() not in KEEP_STATUS:
103− continue
104− seen.add(ext_id)
105−
106− price = it.get("price")
107− try:
108− price = float(price) if price is not None else None
109− except (TypeError, ValueError):
110− price = None
111− try:
112− area = float(it.get("sizeSqFt"))
113− except (TypeError, ValueError):
114− area = None
115− try:
116− beds = float(it.get("bedrooms"))
117− except (TypeError, ValueError):
118− beds = None
119−
120− details: dict = {}
121− if it.get("floorPosition"):
122− details["floor"] = str(it["floorPosition"]).strip()
123− images = [u for u in
124− (_wix_image(it.get("mainImageUrl")),
125− _wix_image(it.get("floorPlanUrl")
126− or it.get("floorplan")))
127− if u]
128−
129− title = (it.get("title") or "").strip() or ext_id
130− unit_type = normalize_unit_type(it.get("unitType") or "")
131− availability = (it.get("availabilityDate") or "").strip()
132− listings.append(Listing(
133− source=self.source_id,
134− external_id=ext_id,
135− url=f"{BASE}{link}" if link else BASE,
136− title=f"{unit_type or 'Appartement'} — {project}"
137− f" ({title})",
138− address=(it.get("addressString") or "").strip(),
139− city=city,
140− unit_type=unit_type,
141− bedrooms=beds,
142− price=price,
143− availability=availability,
144− area_sqft=area,
145− description=(it.get("shortDescription") or "")
146− .strip()[:600],
147− details=details,
148− images=images,
149− ))
150− except Exception:
151 continue 78 continue
79+
80+ city = (card.get("data-city") or "").strip()
81+ price = _num(card.get("data-price"))
82+ beds = _num(card.get("data-bedrooms"))
83+
84+ el = card.select_one(".rental-card__type")
85+ unit_type = normalize_unit_type(
86+ el.get_text(strip=True) if el else "")
87+ el = card.select_one("h3")
88+ title = el.get_text(" ", strip=True) if el else ext_id
89+ el = card.select_one(".rental-card__location")
90+ address = el.get_text(" ", strip=True) if el else ""
91+ el = card.select_one(".rental-card__eyebrow span")
92+ project = el.get_text(" ", strip=True) if el else ""
93+
94+ baths = area = None
95+ for fact in card.select(".rental-card__facts > div"):
96+ dt = fact.select_one("dt span")
97+ dd = fact.select_one("dd")
98+ if not dt or not dd:
99+ continue
100+ label = dt.get_text(strip=True).lower()
101+ if "salle" in label:
102+ baths = _num(dd.get_text())
103+ elif "superficie" in label:
104+ area = _num(dd.get_text())
105+
106+ img = card.select_one(".rental-card__media img[src]")
107+ images = [img["src"].split("#")[0]] if img else []
108+
109+ details: dict = {}
110+ if project:
111+ details["project"] = project
112+
113+ listings.append(Listing(
114+ source=self.source_id,
115+ external_id=ext_id,
116+ url=f"{BASE}{href}" if href.startswith("/") else href,
117+ title=f"{unit_type or 'Appartement'} — {title}",
118+ address=address,
119+ city=city,
120+ unit_type=unit_type,
121+ bedrooms=beds,
122+ bathrooms=baths,
123+ price=price,
124+ availability=status,
125+ area_sqft=area,
126+ details=details,
127+ images=images,
128+ ))
129+
130+ if new_on_page == 0:
131+ break
132+
133+ if cards_total == 0:
134+ # une page catalogue saine contient toujours des cartes (tous
135+ # statuts confondus) — 0 carte = rendu partiel ou structure
136+ # changée : échec franc plutôt qu'un faux « 0 trouvé ok »
137+ raise RuntimeError(
138+ "c3r : aucune carte data-rental-unit parsée sur "
139+ f"{LIST_URL} — structure changée ?")
152 return listings 140 return listings
153 141
Menu

Theme