SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

[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 16 days ago (Sep 8, 2026) parent e5379b0

2 changed files +112 −124

modified data/sources.json +2 −2
@@ -2903,8 +2903,8 @@
2903 2903 "id": "c3r",
2904 2904 "name": "Gestion C3R",
2905 2905 "url": "https://www.gestionc3r.com",
2906 − "listing_url": "https://www.gestionc3r.com/projets",
2907 − "sectors": "Saint-Maurice (Terrasses St-Maurice), Saint-Boniface (Chemin du Lac)",
2906 + "listing_url": "https://www.gestionc3r.com/logements",
2907 + "sectors": "Saint-Maurice (Terrasses St-Maurice), Saint-Boniface (Chemin du Lac), Shawinigan (Rue des Tournesols)",
2908 2908 "connector": "c3r",
2909 2909 "status": "actif",
2910 2910 "region": "Mauricie"
modified louka/connectors/c3r.py +110 −122
@@ -3,150 +3,138 @@
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