| 3 |
3 |
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 |
4 |
# connectors/c3r.py : connecteur Gestion C3R (gestionc3r.com) |
| 5 |
5 |
# Gestionnaire de la Mauricie (Trois-Rivières, Shawinigan, Saint-Boniface, |
| 6 |
|
−# Saint-Maurice — 450+ appartements, projets neufs affichés sur le site). |
| 7 |
|
−# Site Wix : les unités vivent dans des collections CMS interrogeables via |
| 8 |
|
−# l'API wix-data publique (jeton d'app wix-code obtenu via |
| 9 |
|
−# /_api/v1/access-tokens, puis POST /_api/cloud-data/v1/wix-data/collections/ |
| 10 |
|
−# query). Collections réelles : Import1 (Terrasses St-Maurice) et Import2 |
| 11 |
|
−# (St-Boniface) — champs availabilityStatus (Disponible/Réservé/En attente/ |
| 12 |
|
−# Loué), price, unitType, addressString, bedrooms, sizeSqFt… |
| 13 |
|
−# (LeDomaineDesTournesols = vitrine de typologies sans dispo ni prix réels, |
| 14 |
|
−# descriptions anglaises génériques : ignorée.) |
| 15 |
|
−# Granularité : une annonce par unité. |
|
6 |
+# Saint-Maurice). Refonte du site le 2026-09-08 : l'ancien site Wix (et son |
|
7 |
+# API wix-data, collections Import1/Import2) a été remplacé par un site |
|
8 |
+# statique Astro servi par Express — les collections n'existent plus |
|
9 |
+# (WDE0025) et les anciennes URLs redirigent vers /logements. |
|
10 |
+# Nouveau modèle : catalogue /logements?page=N paginé côté serveur |
|
11 |
+# (~18 cartes/page, 73 unités, 4 projets), chaque carte <article |
|
12 |
+# data-rental-unit> porte des attributs data-* propres (city, project, |
|
13 |
+# bedrooms, price, status) + type, adresse, salle de bain, superficie et |
|
14 |
+# photo dans le corps de la carte. Seul le statut « Disponible » est gardé |
|
15 |
+# (Loué / Réservé : non). Granularité : une annonce par unité. |
| 16 |
16 |
# ----------------------------------------------------------------------------- |
| 17 |
17 |
from __future__ import annotations |
| 18 |
18 |
|
| 19 |
|
−import json |
| 20 |
19 |
import re |
| 21 |
20 |
|
|
21 |
+from bs4 import BeautifulSoup |
|
22 |
+ |
| 22 |
23 |
from ..schema import Listing, normalize_unit_type |
| 23 |
24 |
from .base import BaseConnector |
| 24 |
25 |
|
| 25 |
26 |
BASE = "https://www.gestionc3r.com" |
| 26 |
|
−TOKENS_URL = f"{BASE}/_api/v1/access-tokens" |
| 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 |
|
−] |
|
27 |
+LIST_URL = f"{BASE}/logements" |
| 38 |
28 |
|
| 39 |
|
−# seul « Disponible » = louable maintenant (Réservé / En attente / Loué : non) |
|
29 |
+# seul « Disponible » = louable maintenant (Réservé / Loué : non) |
| 40 |
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: |
| 46 |
|
− """Normalise une image Wix (URL https ou URI wix:image://) en URL CDN.""" |
| 47 |
|
− if not value: |
| 48 |
|
− return "" |
| 49 |
|
− if value.startswith("http"): |
| 50 |
|
− return value.split("#")[0] |
| 51 |
|
− m = WIX_IMG_RE.match(value) |
| 52 |
|
− if m: |
| 53 |
|
− return f"https://static.wixstatic.com/media/{m.group(1)}" |
| 54 |
|
− return "" |
|
35 |
+def _num(raw: str | None) -> float | None: |
|
36 |
+ """Premier nombre d'un texte (« 1 575 $/mois », « 1100 pi² ») → float.""" |
|
37 |
+ if raw is None: |
|
38 |
+ return None |
|
39 |
+ digits = re.sub(r"[^\d,.]", "", str(raw)).replace(",", ".") |
|
40 |
+ m = re.match(r"\d+(?:\.\d+)?", digits) |
|
41 |
+ return float(m.group(0)) if m else None |
| 55 |
42 |
|
| 56 |
43 |
|
| 57 |
44 |
class C3RConnector(BaseConnector): |
| 58 |
45 |
source_id = "c3r" |
| 59 |
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 |
48 |
def fetch(self) -> list[Listing]: |
| 67 |
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 |
50 |
seen: set[str] = set() |
| 76 |
|
− for coll, project, city, link_field in COLLECTIONS: |
| 77 |
|
− try: |
| 78 |
|
− resp = self.post( |
| 79 |
|
− QUERY_URL, |
| 80 |
|
− headers={"Authorization": token, |
| 81 |
|
− "Content-Type": "application/json"}, |
| 82 |
|
− data=json.dumps({ |
| 83 |
|
− "collectionName": coll, |
| 84 |
|
− "dataQuery": {"paging": {"offset": 0, "limit": 200}}, |
| 85 |
|
− "segment": "LIVE", |
| 86 |
|
− })) |
| 87 |
|
− items = resp.json().get("items") or [] |
| 88 |
|
− except Exception: |
| 89 |
|
− continue |
| 90 |
|
− for it in items: |
| 91 |
|
− try: |
| 92 |
|
− status = (it.get("availabilityStatus") or "").strip() |
| 93 |
|
− if status.lower() not in KEEP_STATUS: |
| 94 |
|
− continue |
| 95 |
|
− link = (it.get(link_field) or "").strip() |
| 96 |
|
− slug = link.rstrip("/").split("/")[-1] if link else "" |
| 97 |
|
− ext_id = slug or str(it.get("_id") or "") |
| 98 |
|
− if not ext_id: |
| 99 |
|
− continue |
| 100 |
|
− if ext_id in seen: # doublons de saisie dans le CMS |
| 101 |
|
− ext_id = f"{ext_id}-{str(it.get('_id', ''))[:8]}" |
| 102 |
|
− if ext_id in seen: |
| 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: |
|
51 |
+ cards_total = 0 |
|
52 |
+ |
|
53 |
+ for page in range(1, MAX_PAGES + 1): |
|
54 |
+ params = {"page": page} if page > 1 else None |
|
55 |
+ # le serveur n'annonce pas de charset → requests décoderait en |
|
56 |
+ # latin-1 (« Loué ») ; la page déclare <meta charset="utf-8"> |
|
57 |
+ html = self.get(LIST_URL, params=params).content.decode( |
|
58 |
+ "utf-8", errors="replace") |
|
59 |
+ soup = BeautifulSoup(html, "html.parser") |
|
60 |
+ cards = soup.select("article[data-rental-unit]") |
|
61 |
+ if not cards: |
|
62 |
+ break |
|
63 |
+ |
|
64 |
+ new_on_page = 0 |
|
65 |
+ for card in cards: |
|
66 |
+ link = card.select_one("a[href]") |
|
67 |
+ href = (link.get("href") or "").strip() if link else "" |
|
68 |
+ slug = href.rstrip("/").split("/")[-1] if href else "" |
|
69 |
+ ext_id = slug or "" |
|
70 |
+ if not ext_id or ext_id in seen: |
|
71 |
+ continue # au-delà de la dernière page, le site re-sert |
|
72 |
+ seen.add(ext_id) # la même page → dédup par slug |
|
73 |
+ new_on_page += 1 |
|
74 |
+ cards_total += 1 |
|
75 |
+ |
|
76 |
+ status = (card.get("data-status") or "").strip() |
|
77 |
+ if status.lower() not in KEEP_STATUS: |
| 151 |
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 |
140 |
return listings |
| 153 |
141 |
|