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%

tri_logis : réécriture pour le nouveau site trilogis.ca (Next.js/Supabase) + fenêtre syncs_since_h sur /api/stats

- tri_logis : l ancien site statique tri-logis.ca a migré vers trilogis.ca
  (Next.js App Router + Supabase) — 23 synchros à found=0. Le connecteur lit
  désormais le payload RSC rendu serveur de /a-louer (tableau "units" JSON
  complet : prix, statut, date de dispo, notation n½, secteur, GPS,
  inclusions) et la galerie signée de chaque fiche /a-louer/<slug>
  (bucket privé property-media, tokens ~7 j re-signés à chaque sync).
  Périmètre : résidentiel longue durée seulement (commercial exclu).
  Sync de test : found=51, added=51.
- web.py /api/stats : paramètre optionnel syncs_since_h (fenêtre en heures
  pour recent_syncs, LIMIT 4000, repli sur les 20 dernières si <20 dans la
  fenêtre) — le moniteur central api-ka voit enfin toute la fenêtre de 2 h
  au lieu de 20 entrées (~11 min), fin des fausses alertes stale.
  Sans paramètre : comportement inchangé.
- Diagnostics sans changement de code : courtemanche, place_florimay et
  atlas_immo retournent 0 annonce parce que les sites n affichent réellement
  aucune disponibilité (vérifié en live) — connecteurs sains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 2b0a73a

2 changed files +222 −190

modified louka/connectors/tri_logis.py +201 −187
@@ -2,224 +2,238 @@
2 2 # Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # connectors/tri_logis.py : connecteur Société immobilière Tri-Logis inc.
5 −# (tri-logis.ca — Rouyn-Noranda, 600+ logements, référence n°1 en
6 −# Abitibi-Témiscamingue). Site custom statique, tout rendu serveur.
7 −# Liste /espaces-a-louer/logements : un bloc par immeuble (adresse, secteur,
8 −# proximité) ; seules les unités disponibles y ont une rangée (type, texte de
9 −# disponibilité, prix). Fiche unité /espaces-a-louer/immeuble/<imm>/<unité>
10 −# (via cache BD) : adresse complète avec code postal, description (bloc
11 −# « Apartment Features » : étage, inclusions, animaux…), galerie pleine
12 −# taille, inclusions. Périmètre : logements résidentiels seulement — les
13 −# pages /studios (meublés loués à la nuitée : « les prix des locations de
14 −# moins de 31 nuitées… ») et /chalets (court terme) sont exclues.
15 −# Pas de robots.txt (= tout permis).
5 +# (trilogis.ca — Rouyn-Noranda, 600+ logements, référence n°1 en
6 +# Abitibi-Témiscamingue). Réécrit 2026-08-23 : l'ancien site statique
7 +# tri-logis.ca (/espaces-a-louer/logements) a migré vers trilogis.ca
8 +# (301) — Next.js App Router (Vercel) + Supabase (projet
9 +# tcrymlwdnwmfmpnkrfeu.supabase.co, bucket privé property-media).
10 +# Méthode : la liste /a-louer est rendue serveur ; le payload RSC embarqué
11 +# dans le HTML (segments self.__next_f.push) contient le tableau JSON
12 +# `"units":[…]` complet des unités actuellement à louer — unit_id (UUID),
13 +# unit_name (slug de la fiche = minuscules), unit_type (residential /
14 +# commercial), building_address, status (vacant / departure_planned),
15 +# available_date, monthly_rent, rooms_notation (« 4 ½ »), bedrooms
16 +# (cohérent : n½ -> n-2), square_feet, sector, latitude/longitude,
17 +# cover_photo_url et le dict inclusions (heating/electricity/… =
18 +# landlord|tenant|na ; parking/laundry/furnished/balcony/pets = bool).
19 +# Fiche /a-louer/<slug> (RSC aussi) : galerie complète `"photos":[…]`.
20 +# ⚠ Les URLs d'images sont SIGNÉES (bucket privé, token ~7 jours, l'accès
21 +# public renvoie 400) : elles sont re-signées à chaque rendu, donc on
22 +# rafraîchit la galerie à chaque sync (clé de cache = iat du token de la
23 +# couverture) plutôt que de servir des liens expirés. La clé anon Supabase
24 +# n'est PAS exposée dans les chunks JS (tout est rendu serveur) : l'API
25 +# REST /rest/v1 n'est donc pas utilisable — le RSC est la voie robuste.
26 +# Périmètre : logements résidentiels longue durée seulement (unit_type
27 +# == "residential") ; les locaux commerciaux du même payload sont exclus,
28 +# comme l'étaient /studios et /chalets sur l'ancien site.
16 29 # -----------------------------------------------------------------------------
17 30 from __future__ import annotations
18 31
19 32 import hashlib
33 +import json
20 34 import re
21 35
22 −from bs4 import BeautifulSoup
23 −
24 −from ..schema import Listing, normalize_unit_type, parse_price, strip_accents
36 +from ..schema import Listing, normalize_unit_type
25 37 from .base import BaseConnector
26 38
27 −BASE = "https://tri-logis.ca"
28 −LIST_URL = f"{BASE}/espaces-a-louer/logements"
29 −
30 −# vignette redimensionnée « /_t282x186/ » ou « /_t600x407/ » -> pleine taille
31 −_THUMB_RE = re.compile(r"/_t\d+x\d+/")
32 −
33 −
34 −def _pets_value(raw: str) -> str | None:
35 − """Ligne « Pets allowed: … » de la fiche -> oui/non/conditions."""
36 − k = strip_accents((raw or "").strip().lower())
37 − if not k:
39 +BASE = "https://trilogis.ca"
40 +LIST_URL = f"{BASE}/a-louer"
41 +
42 +# segments RSC injectés par Next.js dans le HTML rendu serveur
43 +_PUSH_RE = re.compile(r'self\.__next_f\.push\(\[1,\s*"(.*?)"\]\)', re.S)
44 +# iat du token de signature Supabase (change à chaque rendu de la page)
45 +_IAT_RE = re.compile(r'"iat":(\d+)')
46 +
47 +# inclusions « landlord » (payées par le proprio) -> libellé affichable
48 +_INCL_LANDLORD = {
49 + "heating": "Chauffage inclus",
50 + "electricity": "Électricité incluse",
51 + "water": "Eau incluse",
52 + "hot_water": "Eau chaude incluse",
53 + "internet": "Internet inclus",
54 + "snow_entrance": "Déneigement de l'entrée inclus",
55 + "snow_parking": "Déneigement du stationnement inclus",
56 +}
57 +# inclusions booléennes -> commodité
58 +_INCL_BOOL = {
59 + "parking": "Stationnement",
60 + "laundry": "Buanderie",
61 + "balcony": "Balcon",
62 +}
63 +# statuts observés -> texte de disponibilité
64 +_STATUS_FR = {
65 + "vacant": "Disponible dès maintenant",
66 + "departure_planned": "Bientôt disponible",
67 +}
68 +
69 +
70 +def _rsc_blob(html: str) -> str:
71 + """Reconstitue le flux RSC (React Server Components) embarqué dans le HTML.
72 +
73 + Les segments sont des chaînes JS échappées façon JSON ; on les décode via
74 + json.loads pour restituer les \\uXXXX et \\" avant de les concaténer.
75 + """
76 + parts: list[str] = []
77 + for raw in _PUSH_RE.findall(html):
78 + try:
79 + parts.append(json.loads(f'"{raw}"'))
80 + except ValueError:
81 + parts.append(raw) # segment atypique : garder brut
82 + return "".join(parts)
83 +
84 +
85 +def _extract_array(blob: str, key: str) -> list | None:
86 + """Extrait le tableau JSON qui suit `"<key>":` dans le flux RSC."""
87 + i = blob.find(f'"{key}":')
88 + if i < 0:
89 + return None
90 + try:
91 + value, _ = json.JSONDecoder().raw_decode(blob, i + len(key) + 3)
92 + except ValueError:
38 93 return None
39 − if re.search(r"\bno\b|not allowed|aucun|non\b", k):
40 − return "non"
41 − if re.search(r"cats and dogs|chats et chiens|\byes\b|allowed", k):
42 − return "oui"
43 − if re.search(r"cat|chat|dog|chien|small|petit", k):
44 − return "conditions"
45 − return None
94 + return value if isinstance(value, list) else None
46 95
47 96
48 97 class TriLogisConnector(BaseConnector):
49 98 source_id = "tri_logis"
50 99 request_delay = 0.6
51 − max_details = 40 # garde-fou fiches unité (vraies requêtes par sync)
100 + max_details = 60 # garde-fou fiches unité (vraies requêtes par sync)
52 101
53 102 def fetch(self) -> list[Listing]:
54 103 html = self.get(LIST_URL).text
55 − soup = BeautifulSoup(html, "html.parser")
104 + blob = _rsc_blob(html)
105 + units = _extract_array(blob, "units")
106 + if units is None:
107 + # marqueur absent = structure du site changée (≠ 0 disponibilité,
108 + # où "units":[] serait présent) -> échec franc plutôt que found=0
109 + raise RuntimeError("payload RSC sans tableau \"units\" — "
110 + "structure trilogis.ca/a-louer changée ?")
111 +
112 + # les tokens de signature sont régénérés à chaque rendu : leur iat
113 + # sert de sel de cache pour re-signer la galerie à chaque sync
114 + m = _IAT_RE.search(blob)
115 + render_iat = m.group(1) if m else ""
56 116
57 117 self._fetched = 0
58 118 listings: dict[str, Listing] = {}
59 − for bloc in soup.select(".search-results .result.immeuble"):
119 + for u in units:
60 120 try:
61 − self._parse_building(bloc, listings)
121 + lst = self._parse_unit(u, render_iat)
62 122 except Exception:
63 123 continue
124 + if lst is not None and lst.external_id not in listings:
125 + listings[lst.external_id] = lst
64 126 return list(listings.values())
65 127
66 − # -- bloc immeuble (liste) ------------------------------------------------------
67 − def _parse_building(self, bloc, listings: dict[str, Listing]) -> None:
68 − h3 = bloc.select_one(".result-details h3")
69 − building = h3.get_text(strip=True) if h3 else ""
70 −
71 − sector_el = bloc.select_one(".result-details p strong")
72 − sector = sector_el.get_text(strip=True) if sector_el else ""
73 −
74 − # « Proximité : IGA Roy, École…, parc » -> commodités de l'immeuble
75 − proximity = ""
76 − for h5 in bloc.select(".result-details h5"):
77 − if "proximit" in strip_accents(h5.get_text(strip=True).lower()):
78 − p = h5.find_next_sibling("p")
79 − if p:
80 − proximity = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
81 − break
82 −
83 − # rangées d'unités disponibles (absentes quand 0 espace à louer)
84 − for a in bloc.select("a.apartment-details[href]"):
85 − url = a["href"]
86 − m = re.search(r"/espaces-a-louer/immeuble/([^/]+)/([^/?#]+)", url)
87 − if not m:
88 − continue
89 − ext_id = f"{m.group(1)}--{m.group(2)}"
90 − if ext_id in listings:
91 − continue
92 −
93 − type_el = a.find("h4")
94 − unit_label = type_el.get_text(strip=True) if type_el else ""
95 −
96 − # texte de disponibilité (« Disponible dès maintenant », « Disponible
97 − # le 24 juil. 2024 »…) — sans le bouton « Planifier une visite »
98 − availability = ""
99 − p = a.find("p")
100 − if p:
101 − txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
102 − m_av = re.search(r"(Disponible[^|]*?)(?:Planifier|$)", txt, re.I)
103 − if m_av:
104 − availability = m_av.group(1).strip()
105 −
106 − # prix : <div class="price"><div>1,160 $</div><div>mois</div></div>
107 − price_label = ""
108 − price_el = a.select_one(".price")
109 − if price_el:
110 − price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True))
111 − price = parse_price(re.sub(r"(\d),(\d{3})", r"\1\2", price_label))
112 −
113 − # inclusions annoncées sur la carte (« Inclus dans le prix » + liste)
114 − amenities: list[str] = []
115 − for h5 in a.find_all("h5"):
116 − if "inclus" in strip_accents(h5.get_text(strip=True).lower()):
117 − sib = h5.find_next_sibling("p")
118 − if sib:
119 − t = re.sub(r"\s+", " ", sib.get_text(" ", strip=True))
120 − if t:
121 − amenities.append(t)
122 − if proximity:
123 − amenities.append(f"À proximité : {proximity}")
124 −
125 − images = [img["src"] for img in a.select("img.img-responsive[src]")
126 − if img["src"].startswith("http")]
127 −
128 − lst = Listing(
129 − source=self.source_id,
130 − external_id=ext_id,
131 − url=url,
132 − title=f"{building} — {unit_label}".strip(" —"),
133 − address=building, # affinée par la fiche unité
134 − sector=sector,
135 − city="Rouyn-Noranda",
136 − unit_type=normalize_unit_type(unit_label),
137 − price=price,
138 − price_label=price_label,
139 − availability=availability,
140 − amenities=amenities,
141 − details={"building": building},
142 − images=[_THUMB_RE.sub("/", u) for u in images],
143 − )
144 −
128 + # -- unité (objet JSON du payload liste) -------------------------------------
129 + def _parse_unit(self, u: dict, render_iat: str) -> Listing | None:
130 + if (u.get("unit_type") or "") != "residential":
131 + return None # locaux commerciaux exclus
132 + ext_id = str(u.get("unit_id") or "").strip()
133 + unit_name = str(u.get("unit_name") or "").strip()
134 + if not ext_id or not unit_name:
135 + return None
136 +
137 + slug = unit_name.lower() # ex. 60-12_Perreault-E -> fiche
138 + url = f"{BASE}/a-louer/{slug}"
139 +
140 + address = str(u.get("building_address") or "").strip()
141 + # « 58-60 Perreault-E, Rouyn-Noranda » -> ville après la virgule
142 + city = "Rouyn-Noranda"
143 + if "," in address:
144 + tail = address.rsplit(",", 1)[1].strip()
145 + if tail:
146 + city = tail
147 +
148 + rooms = str(u.get("rooms_notation") or "").strip()
149 + status = str(u.get("status") or "").strip()
150 + avail_date = str(u.get("available_date") or "").strip()
151 + if status == "vacant":
152 + availability = _STATUS_FR["vacant"]
153 + availability_date = "now"
154 + elif avail_date:
155 + availability = f"Disponible le {avail_date}"
156 + availability_date = avail_date
157 + else:
158 + availability = _STATUS_FR.get(status, "")
159 + availability_date = None
160 +
161 + price = u.get("monthly_rent")
162 + price = float(price) if isinstance(price, (int, float)) else None
163 + price_label = f"{price:.0f} $ / mois" if price else ""
164 +
165 + # inclusions -> commodités affichables + animaux/meublé
166 + incl = u.get("inclusions") or {}
167 + amenities: list[str] = []
168 + for k, label in _INCL_LANDLORD.items():
169 + if incl.get(k) == "landlord":
170 + amenities.append(label)
171 + for k, label in _INCL_BOOL.items():
172 + if incl.get(k) is True:
173 + amenities.append(label)
174 + pets = None
175 + if isinstance(incl.get("pets"), bool):
176 + pets = "oui" if incl["pets"] else "non"
177 + furnished = incl.get("furnished") if isinstance(
178 + incl.get("furnished"), bool) else None
179 +
180 + bedrooms = u.get("bedrooms")
181 + bedrooms = float(bedrooms) if isinstance(bedrooms, (int, float)) else None
182 +
183 + sqft = u.get("square_feet")
184 + sqft = float(sqft) if isinstance(sqft, (int, float)) and sqft > 0 else None
185 +
186 + images = [u["cover_photo_url"]] if str(
187 + u.get("cover_photo_url") or "").startswith("http") else []
188 +
189 + lst = Listing(
190 + source=self.source_id,
191 + external_id=ext_id,
192 + url=url,
193 + title=f"{rooms or 'Logement'} — {address.split(',')[0].strip()}",
194 + address=address,
195 + sector=str(u.get("sector") or "").strip(),
196 + city=city,
197 + unit_type=normalize_unit_type(rooms),
198 + bedrooms=bedrooms,
199 + price=price,
200 + price_label=price_label,
201 + availability=availability,
202 + availability_date=availability_date,
203 + area_sqft=sqft,
204 + pets=pets,
205 + furnished=furnished,
206 + description=str(u.get("description_publique") or "").strip()[:1500],
207 + amenities=amenities,
208 + details={"building": str(u.get("building_name") or ""),
209 + "status": status},
210 + images=images,
211 + lat=u.get("latitude") if isinstance(u.get("latitude"), float) else None,
212 + lng=u.get("longitude") if isinstance(u.get("longitude"), float) else None,
213 + )
214 +
215 + # galerie complète via la fiche — la clé inclut l'iat du rendu : les
216 + # URLs signées expirent (~7 j), on les re-signe donc à chaque sync
217 + if (u.get("photo_count") or 0) > 1:
145 218 key = hashlib.sha1(
146 − f"{unit_label}|{price_label}|{availability}".encode("utf-8")
147 − ).hexdigest()
219 + f"{price_label}|{availability}|{u.get('photo_count')}|{render_iat}"
220 + .encode("utf-8")).hexdigest()
148 221 try:
149 222 payload = self.detail(ext_id, key,
150 − lambda u=url: self._fetch_detail(u))
151 − self._apply_detail(lst, payload)
223 + lambda s=slug: self._fetch_detail(s))
224 + if payload.get("images"):
225 + lst.images = payload["images"]
152 226 except Exception:
153 227 pass
154 − listings[ext_id] = lst
228 + return lst
155 229
156 − # -- fiche unité ------------------------------------------------------------------
157 − def _fetch_detail(self, url: str) -> dict:
158 − """Adresse complète, description (« Apartment Features »), inclusions,
159 − galerie pleine taille."""
230 + # -- fiche unité (/a-louer/<slug>) : galerie signée complète -----------------
231 + def _fetch_detail(self, slug: str) -> dict:
160 232 if self._fetched >= self.max_details:
161 233 raise RuntimeError("budget de fiches unité atteint")
162 234 self._fetched += 1
163 − html = self.get(url).text
164 − soup = BeautifulSoup(html, "html.parser")
165 − out: dict = {}
166 −
167 − details_bloc = soup.select_one(".specsheet .details")
168 − if details_bloc:
169 − # adresse civique complète : « 992 Av. Larivière, Rouyn-Noranda,
170 − # QC J9X 4K5 » (premier <p> contenant la ville)
171 − for p in details_bloc.find_all("p"):
172 − t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
173 − if re.search(r"rouyn|noranda|évain|evain|,\s*QC", t, re.I) \
174 − and len(t) < 120 and not t.lower().startswith("address:"):
175 − out["address"] = t
176 − break
177 −
178 − # description libre (bloc anglais « Address / Availability /
179 − # Apartment Features / Pets allowed… ») — champs bruts fidèles
180 − best = ""
181 − for p in details_bloc.find_all("p"):
182 − t = p.get_text("\n", strip=True)
183 − if len(t) > len(best):
184 − best = t
185 − if len(best) > 60:
186 − out["description"] = re.sub(r"\n{2,}", "\n",
187 − re.sub(r"[ \t]+", " ", best))[:1500]
188 −
189 − # galerie pleine taille (liens slick-colorbox)
190 − out["images"] = list(dict.fromkeys(
191 − a["href"] for a in details_bloc.select("a.slick-colorbox[href]")
192 − if a["href"].startswith("http")))[:25]
193 −
194 − # « Inclus dans le prix » du panneau latéral
195 − incl: list[str] = []
196 − for h5 in soup.select(".brown-panel h5"):
197 − if "inclus" in strip_accents(h5.get_text(strip=True).lower()):
198 − for sib in h5.find_next_siblings():
199 − if sib.name not in ("p", "ul", "li"):
200 − break # fin de la section (bouton…)
201 − for t in re.split(r"\s*[,;]\s*",
202 − sib.get_text(" ", strip=True)):
203 − t = re.sub(r"\s+", " ", t).strip()
204 − if t and t not in incl:
205 − incl.append(t)
206 − out["included"] = incl[:10]
207 − return out
208 −
209 − def _apply_detail(self, lst: Listing, d: dict) -> None:
210 − """Reporte le payload (frais ou en cache) sur l'annonce."""
211 − if not d:
212 − return
213 − if d.get("address"):
214 − lst.address = d["address"]
215 − if d.get("description"):
216 − lst.description = d["description"]
217 − m = re.search(r"Pets allowed\s*:\s*([^\n]+)", d["description"], re.I)
218 − if m:
219 − pets = _pets_value(m.group(1))
220 − if pets:
221 − lst.pets = pets
222 − if d.get("included"):
223 − lst.amenities = list(dict.fromkeys(lst.amenities + d["included"]))
224 − if d.get("images"):
225 − lst.images = d["images"]
235 + blob = _rsc_blob(self.get(f"{BASE}/a-louer/{slug}").text)
236 + photos = _extract_array(blob, "photos") or []
237 + images = [p["url"] for p in photos
238 + if isinstance(p, dict) and str(p.get("url") or "").startswith("http")]
239 + return {"images": list(dict.fromkeys(images))[:25]}
modified louka/web.py +21 −3
@@ -663,7 +663,17 @@ _VILLES_GM = (
663 663
664 664
665 665 @app.get("/api/stats")
666 −def stats():
666 +def stats(syncs_since_h: float | None = None):
667 + """Stats globales + journal de synchronisation.
668 +
669 + `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le
670 + moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne
671 + couvrent que ~11 min d'activité sur ~200 sources (fausses alertes
672 + « stale »). Avec le paramètre : toutes les entrées des N dernières heures
673 + (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières
674 + toutes époques confondues. Sans le paramètre : comportement historique
675 + inchangé (LIMIT 20).
676 + """
667 677 con = db.connect()
668 678 gm = ",".join("?" * len(_VILLES_GM))
669 679 row = con.execute(
@@ -677,8 +687,16 @@ def stats():
677 687 AVG(price) avg_price
678 688 FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL""",
679 689 list(_VILLES_GM) + list(_VILLES_GM)).fetchone()
680 − log = [dict(r) for r in con.execute(
681 − "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
690 + log: list[dict] = []
691 + if syncs_since_h is not None:
692 + import time as _time
693 + cutoff = _time.time() - syncs_since_h * 3600
694 + log = [dict(r) for r in con.execute(
695 + "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",
696 + (cutoff,))]
697 + if syncs_since_h is None or len(log) < 20:
698 + log = [dict(r) for r in con.execute(
699 + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
682 700 # qualité des données : complétude moyenne + quarantaine (quality.py)
683 701 qual = con.execute(
684 702 """SELECT ROUND(AVG(completeness),1) avg_completeness,
685 703