| 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 | |