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%

Vague 2 : colonnes bedrooms/bathrooms, perf fb_marketplace/logisquebec, connecteurs Kangalou + Rentals.ca, visites virtuelles

- Schéma : colonnes bedrooms/bathrooms REAL (additives) dérivées dans
  finalize() — details explicites > type d'unité (n½ -> n-2, convention QC)
  > extraction texte ; backfill one-shot : 96,7 % bedrooms / 51 % bathrooms
  sur le parc actif.
- fb_marketplace : recherche et détails Scrapfly EN PARALLÈLE (6 fils,
  mêmes requêtes, aucun volume perdu), TTL détail 10 -> 30 j, annonces
  louées filtrées avant enrichissement : 43-47 min -> 7,3 min/cycle.
- logisquebec : budget détail 1200 -> 400 (dimensionné TTL 7 j / ~10
  passes/j), fiches jamais vues servies en premier, délai 0,7 -> 0,5 s :
  19 min -> 7,6 min/cycle, parc complet (6 393 fiches).
- Kangalou (nouveau) : découverte en 1 requête via les pins de la carte
  (/fr/recherche/ajax/search-map/, loués/commerciaux exclus), fiches SSR
  (adresse, chambres/SDB, GPS, galerie, dispo, superficie) : 7 916 annonces.
- Rentals.ca (nouveau) : même GraphQL que Louer.ca mais inventaire ~+15 %,
  rejoué via Scrapfly ASP sans rendu JS (Cloudflare), détails PAR LOTS de
  40 via nodes(ids), villes/volumes plafonnés : 3 452 annonces (les
  recoupements Louer.ca sont résorbés par la dédup, déjà dans PORTALS).
- Visites virtuelles : details.virtual_tour — tours{} Matterport/YouTube
  du réseau Rentals.ca (louer_ca clé cache v2 + rentals_ca) + détection
  générique Matterport/iGUIDE/Klapty dans les fiches logisquebec,
  duproprio, kangalou. 1 107 annonces actives en ont déjà une.
- TtlDetailCache : peek()/put() pour la planification de lots parallèles.
- sources.json : +kangalou, +rentals_ca (275 sources, 212 connecteurs).

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

12 changed files +808 −114

modified README.md +3 −3
@@ -15,7 +15,7 @@
15 15
16 16 ![Annonces](https://img.shields.io/badge/annonces_actives-22_900%2B-1c5c41?style=flat-square)
17 17 ![Sources](https://img.shields.io/badge/sources_recens%C3%A9es-265-1c5c41?style=flat-square)
18 −![Connecteurs](https://img.shields.io/badge/connecteurs-205-1c5c41?style=flat-square)
18 +![Connecteurs](https://img.shields.io/badge/connecteurs-212-1c5c41?style=flat-square)
19 19 ![Villes](https://img.shields.io/badge/villes-820-1c5c41?style=flat-square)
20 20 ![Régions](https://img.shields.io/badge/r%C3%A9gions-11-1c5c41?style=flat-square)
21 21 ![Géocodées](https://img.shields.io/badge/g%C3%A9olocalis%C3%A9es-70%25-1c5c41?style=flat-square)
@@ -30,7 +30,7 @@ Lou-Ka n'est pas une plateforme d'annonces : c'est un **index fidèle** et un **
30 30
31 31 > Les sites d'agences n'offrent pas de webhooks. Lou-Ka reproduit l'équivalent : **synchronisation périodique + hash de contenu** → ajouts, mises à jour et retraits détectés automatiquement. Une annonce qui disparaît du site source disparaît de Lou-Ka (et répond **`410 Gone`** aux moteurs de recherche).
32 32
33 −**En chiffres** : **22 900+ annonces actives**, **265 sources recensées**, **205 connecteurs**, **820 villes**, **11 régions**, **~70 %** des annonces géolocalisées.
33 +**En chiffres** : **22 900+ annonces actives**, **275 sources recensées**, **212 connecteurs**, **820 villes**, **11 régions**, **~70 %** des annonces géolocalisées.
34 34
35 35 ## Fonctionnalités
36 36
@@ -92,7 +92,7 @@ lou-ka/
92 92 │ ├── marketstats.py # agrégats du marché (source unique stats/API/PDF)
93 93 │ ├── pdfgen.py # fiches PDF + rapport de marché (QR code)
94 94 │ ├── fixtures.py # enregistrement/rejeu HTTP pour tests hors-ligne
95 −│ └── connectors/ # 205 connecteurs + base.py (auto-découverte)
95 +│ └── connectors/ # 212 connecteurs + base.py (auto-découverte)
96 96 ├── frontend/ # React 18 + Vite + TypeScript (pages, composants, kamaps/)
97 97 ├── data/
98 98 │ ├── sources.json # registre des 265 sources (statut + raison si non connectable)
modified data/sources.json +21 −1
@@ -2728,6 +2728,26 @@
2728 2728 "connector": "fb_marketplace",
2729 2729 "status": "actif",
2730 2730 "region": "Province"
2731 + },
2732 + {
2733 + "id": "kangalou",
2734 + "name": "Kangalou",
2735 + "url": "https://www.kangalou.com",
2736 + "listing_url": "https://www.kangalou.com/fr/recherche/",
2737 + "sectors": "Province entière — portail locatif géré (Solutions Kangalou), ~8 500 annonces actives (pins carte, loués exclus)",
2738 + "connector": "kangalou",
2739 + "status": "actif",
2740 + "region": "Province"
2741 + },
2742 + {
2743 + "id": "rentals_ca",
2744 + "name": "Rentals.ca (Québec)",
2745 + "url": "https://rentals.ca",
2746 + "listing_url": "https://rentals.ca/quebec-city",
2747 + "sectors": "Grandes villes QC — même plateforme GraphQL que Louer.ca mais inventaire de marque ~15 % plus large ; accès via Scrapfly ASP (Cloudflare), volumes plafonnés par ville",
2748 + "connector": "rentals_ca",
2749 + "status": "actif",
2750 + "region": "Province"
2731 2751 }
2732 2752 ]
2733 −}
2753 +}
\ No newline at end of file
modified louka/connectors/_detailutil.py +42 −3
@@ -47,6 +47,25 @@ def ld_description(html: str) -> str:
47 47 return best
48 48
49 49
50 +# plateformes de visite virtuelle reconnues dans le HTML d'une fiche
51 +_VTOUR_RE = re.compile(
52 + r'https?://(?:my\.)?(?:matterport\.com/show/[^"\'\s<>]+'
53 + r'|(?:www\.)?youriguide\.com/[^"\'\s<>]+'
54 + r'|(?:www\.)?klapty\.com/tour/[^"\'\s<>]+'
55 + r'|kuula\.co/(?:share|post)/[^"\'\s<>]+'
56 + r'|tours?\.[a-z0-9-]+\.(?:com|ca)/[^"\'\s<>]*(?:tour|visite)[^"\'\s<>]*'
57 + r'|(?:www\.)?realvision\.com/[^"\'\s<>]+)', re.I)
58 +
59 +
60 +def virtual_tour(html: str) -> str | None:
61 + """Première URL de visite virtuelle (Matterport, iGUIDE, Klapty, Kuula…)
62 + trouvée dans le HTML d'une fiche. None si absente — jamais inventée."""
63 + m = _VTOUR_RE.search(html or "")
64 + if not m:
65 + return None
66 + return _html.unescape(m.group(0)).replace("\\/", "/").rstrip("\\").rstrip('&')
67 +
68 +
50 69 def gmaps_coords(html: str) -> tuple[float, float] | None:
51 70 m = _COORD_RE.search(html)
52 71 if not m:
@@ -150,9 +169,10 @@ class TtlDetailCache:
150 169 self.key = key
151 170 self.fetch_html = fetch_html or (lambda u: connector.get(u).text)
152 171
153 − def get(self, external_id: str, url: str, parse_fn) -> dict | None:
172 + def peek(self, external_id: str) -> tuple[dict | None, bool]:
173 + """(payload en cache, frais ?) SANS déclencher de requête ni toucher
174 + au budget — permet aux connecteurs de planifier des lots parallèles."""
154 175 import time as _time
155 − from .. import db
156 176 row = self.con.execute(
157 177 "SELECT key, payload, fetched_at FROM detail_cache"
158 178 " WHERE source=? AND external_id=?",
@@ -165,7 +185,20 @@ class TtlDetailCache:
165 185 stale = None
166 186 if (stale is not None and row["key"] == self.key
167 187 and _time.time() - (row["fetched_at"] or 0) < self.ttl):
168 − return stale # frais : aucun trafic
188 + return stale, True # frais : aucun trafic requis
189 + return stale, False
190 +
191 + def put(self, external_id: str, payload: dict) -> None:
192 + """Écrit un payload obtenu hors de get() (ex. téléchargement en lot)."""
193 + from .. import db
194 + db.put_cached_detail(self.con, self.connector.source_id,
195 + str(external_id), self.key, payload)
196 +
197 + def get(self, external_id: str, url: str, parse_fn) -> dict | None:
198 + from .. import db
199 + stale, fresh = self.peek(external_id)
200 + if fresh:
201 + return stale # frais : aucun trafic
169 202 if self.budget <= 0:
170 203 return stale # périmé toléré / None si jamais vu
171 204 self.budget -= 1
@@ -217,3 +250,9 @@ def apply_detail(lst: Listing, d: dict) -> None:
217 250 "pets", "furnished"):
218 251 if d.get(f) is not None and getattr(lst, f, None) is None:
219 252 setattr(lst, f, d[f])
253 + from ..normalize import coerce_count
254 + for f in ("bedrooms", "bathrooms"):
255 + if d.get(f) is not None and getattr(lst, f, None) is None:
256 + setattr(lst, f, coerce_count(d[f]))
257 + if d.get("virtual_tour") and not lst.details.get("virtual_tour"):
258 + lst.details["virtual_tour"] = d["virtual_tour"]
modified louka/connectors/duproprio.py +4 −0
@@ -106,6 +106,10 @@ def _parse_fiche(html: str) -> dict:
106 106 f"{ym}/{lid}/{slug1600}-{pid}.jpg")
107 107 if photos:
108 108 out["images"] = photos
109 +
110 + vt = du.virtual_tour(html) # visite virtuelle (Matterport/iGUIDE…)
111 + if vt:
112 + out.setdefault("details", {})["virtual_tour"] = vt
109 113 return out
110 114
111 115
modified louka/connectors/fb_marketplace.py +93 −49
@@ -18,6 +18,7 @@
18 18 # -----------------------------------------------------------------------------
19 19 from __future__ import annotations
20 20
21 +import concurrent.futures as _cf
21 22 import json
22 23 import os
23 24 import re
@@ -44,8 +45,13 @@ PRICE_BANDS = [(0, 800), (800, 1100), (1100, 1400), (1400, 1700),
44 45 PRICE_MIN, PRICE_MAX = 300, 12000
45 46
46 47 DETAIL_LIMIT = int(os.environ.get("LOUKA_FBMP_DETAIL_LIMIT", "120"))
47 −TTL_DAYS = float(os.environ.get("LOUKA_FBMP_TTL_DAYS", "10"))
48 +# les annonces FB changent peu après publication : TTL long = le budget détail
49 +# sert surtout aux NOUVELLES annonces plutôt qu'à re-visiter les connues
50 +TTL_DAYS = float(os.environ.get("LOUKA_FBMP_TTL_DAYS", "30"))
48 51 CITY_LIMIT = os.environ.get("LOUKA_FBMP_CITIES", "") # ex. "montreal,laval"
52 +# requêtes Scrapfly en parallèle (recherche ET détail) : le cycle passait 40+
53 +# minutes en attente sérielle de rendus JS de ~20 s chacun
54 +CONCURRENCY = int(os.environ.get("LOUKA_FBMP_CONCURRENCY", "6"))
49 55
50 56
51 57 def _walk_listings(html: str) -> dict:
@@ -145,17 +151,29 @@ class FacebookMarketplaceConnector(BaseConnector):
145 151 out["images"] = photos
146 152 return out
147 153
148 − def _search(self, slug: str) -> dict:
149 − found: dict = {}
150 − for lo, hi in PRICE_BANDS:
151 − url = (f"{BASE}/{slug}/propertyrentals"
152 − f"?minPrice={lo}&maxPrice={hi}&sortBy=creation_time_descend")
153 − try:
154 − html = self._scrape(url)
155 − except Exception:
156 − continue
157 − for lid, obj in _walk_listings(html).items():
158 − found.setdefault(lid, obj)
154 + def _search_all(self, cities: dict) -> dict[str, tuple[dict, str]]:
155 + """Toutes les pages de recherche (ville × tranche de prix) EN PARALLÈLE.
156 +
157 + Même nombre de requêtes Scrapfly qu'avant (aucune perte de volume) :
158 + seule l'attente des rendus JS (~20 s chacun) est recouverte.
159 + """
160 + tasks = []
161 + for slug, city in cities.items():
162 + for lo, hi in PRICE_BANDS:
163 + url = (f"{BASE}/{slug}/propertyrentals"
164 + f"?minPrice={lo}&maxPrice={hi}&sortBy=creation_time_descend")
165 + tasks.append((url, city))
166 + found: dict[str, tuple[dict, str]] = {}
167 + with _cf.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
168 + futs = {pool.submit(self._scrape, url): city for url, city in tasks}
169 + for fut in _cf.as_completed(futs):
170 + city = futs[fut]
171 + try:
172 + html = fut.result()
173 + except Exception:
174 + continue
175 + for lid, obj in _walk_listings(html).items():
176 + found.setdefault(lid, (obj, city))
159 177 return found
160 178
161 179 def fetch(self) -> list[Listing]:
@@ -166,46 +184,72 @@ class FacebookMarketplaceConnector(BaseConnector):
166 184 wanted = {c.strip() for c in CITY_LIMIT.split(",")}
167 185 cities = {k: v for k, v in CITIES.items() if k in wanted}
168 186
187 + found = self._search_all(cities)
188 +
189 + # candidats retenus AVANT tout enrichissement : pas de requête détail
190 + # gaspillée sur les annonces sans prix ou déjà louées
191 + candidates: list[tuple[str, dict, str]] = []
192 + for lid, (obj, city) in found.items():
193 + if _price(obj) is None:
194 + continue # sans prix mensuel valable : ignorer
195 + if obj.get("is_sold") or obj.get("is_pending"):
196 + continue # déjà loué / en attente
197 + candidates.append((lid, obj, city))
198 +
199 + # enrichissement détail : cache BD d'abord (peek), puis les fiches
200 + # jamais vues / périmées EN PARALLÈLE dans la limite du budget
169 201 cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,
170 202 key="v1", fetch_html=self._scrape)
171 − out: dict[str, Listing] = {}
203 + details: dict[str, dict] = {}
204 + todo: list[str] = []
172 205 try:
173 − for slug, city in cities.items():
174 − for lid, obj in self._search(slug).items():
175 − if lid in out:
176 − continue
177 − title = obj.get("marketplace_listing_title") or ""
178 − price = _price(obj)
179 − if price is None:
180 − continue # sans prix mensuel valable : ignorer
181 − loc = obj.get("location") or {}
182 − coords = loc.get("reverse_geocode") or {}
183 − obj_city = (coords.get("city")
184 − or (coords.get("city_page") or {}).get("display_name")
185 − or city)
186 − photo = ((obj.get("primary_listing_photo") or {})
187 − .get("image") or {}).get("uri") or ""
188 − url = f"{BASE}/item/{lid}/"
189 −
190 − detail = cache.get(lid, url, self._parse_detail) or {}
191 − desc = detail.get("description") or ""
192 − lst = Listing(
193 − source=self.source_id,
194 − external_id=lid,
195 − url=url,
196 − title=title[:200],
197 − city=obj_city,
198 − unit_type=_unit_type(title, desc),
199 − price=price,
200 − price_label=f"{price:,.0f} $/mois".replace(",", " "),
201 − description=desc,
202 − images=detail.get("images") or ([photo] if photo else []),
203 − lat=detail.get("lat"),
204 − lng=detail.get("lng"),
205 − )
206 − if obj.get("is_sold") or obj.get("is_pending"):
207 − continue # déjà loué / en attente
208 − out[lid] = lst
206 + for lid, _obj, _city in candidates:
207 + payload, fresh = cache.peek(lid)
208 + if payload is not None:
209 + details[lid] = payload # frais OU périmé toléré
210 + if not fresh and len(todo) < cache.budget:
211 + todo.append(lid)
212 +
213 + def _fetch_one(lid: str) -> tuple[str, dict]:
214 + try:
215 + return lid, self._parse_detail(
216 + self._scrape(f"{BASE}/item/{lid}/"))
217 + except Exception:
218 + return lid, {}
219 + if todo:
220 + with _cf.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
221 + for lid, payload in pool.map(_fetch_one, todo):
222 + if payload: # échec réseau : garder l'ancien
223 + cache.put(lid, payload)
224 + details[lid] = payload
225 +
226 + out: dict[str, Listing] = {}
227 + for lid, obj, city in candidates:
228 + title = obj.get("marketplace_listing_title") or ""
229 + price = _price(obj)
230 + loc = obj.get("location") or {}
231 + coords = loc.get("reverse_geocode") or {}
232 + obj_city = (coords.get("city")
233 + or (coords.get("city_page") or {}).get("display_name")
234 + or city)
235 + photo = ((obj.get("primary_listing_photo") or {})
236 + .get("image") or {}).get("uri") or ""
237 + detail = details.get(lid) or {}
238 + desc = detail.get("description") or ""
239 + out[lid] = Listing(
240 + source=self.source_id,
241 + external_id=lid,
242 + url=f"{BASE}/item/{lid}/",
243 + title=title[:200],
244 + city=obj_city,
245 + unit_type=_unit_type(title, desc),
246 + price=price,
247 + price_label=f"{price:,.0f} $/mois".replace(",", " "),
248 + description=desc,
249 + images=detail.get("images") or ([photo] if photo else []),
250 + lat=detail.get("lat"),
251 + lng=detail.get("lng"),
252 + )
209 253 finally:
210 254 cache.close()
211 255 return list(out.values())
added louka/connectors/kangalou.py +209 −0
@@ -0,0 +1,209 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/kangalou.py : Kangalou (kangalou.com) — portail locatif québécois
5 +# géré (Solutions Kangalou, ~8 700 annonces province entière). Découverte en
6 +# UNE requête : l'endpoint carte `POST /fr/recherche/ajax/search-map/`
7 +# (pinsOnly=true) renvoie tous les pins JSON — id, URL de fiche, GPS, ville,
8 +# type (ti/tn ; 10=Commercial et 11=Garage exclus), « n ½ » (an), prix (mc),
9 +# photo et l'état loué (ir, exclu). La fiche /fr/annonce/<slug>/<id>/ (SSR,
10 +# aucun anti-bot) complète : adresse civique, chambres/salles de bain, loyer,
11 +# étage, superficie, disponibilité, description, inclusions/caractéristiques
12 +# et galerie (slider--gallery, images glide) — via TtlDetailCache
13 +# (budget/TTL). L'API de liste HTML (POST /fr/api/search) existe aussi mais
14 +# pagine par 29 et masque l'état loué (CSS) : les pins sont la source fiable.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import os
19 +import re
20 +
21 +from ..schema import Listing, normalize_unit_type
22 +from .base import BaseConnector
23 +from . import _detailutil as du
24 +
25 +BASE = "https://www.kangalou.com"
26 +PINS_API = f"{BASE}/fr/recherche/ajax/search-map/"
27 +
28 +DETAIL_LIMIT = int(os.environ.get("LOUKA_KANGALOU_DETAIL_LIMIT", "350"))
29 +TTL_DAYS = float(os.environ.get("LOUKA_KANGALOU_TTL_DAYS", "7"))
30 +MAX_PINS = int(os.environ.get("LOUKA_KANGALOU_MAX", "0")) # 0 = tout
31 +
32 +# type id -> type d'unité par défaut (le « n ½ » du pin prime quand présent)
33 +_TYPES = {1: "", 2: "Condo", 3: "Maison", 4: "Chambre", 5: "Chalet",
34 + 6: "Studio", 7: "Loft"}
35 +_EXCLUDED_TI = {10, 11} # Commercial, Garage : pas du résidentiel
36 +
37 +# fiche détail (SSR)
38 +_GPS_RE = re.compile(r'maps/search/\?query=(-?\d+\.\d+)%2C(-?\d+\.\d+)')
39 +_ADDR_RE = re.compile(r'buds--separator[^>]*u-text-size-sm">\s*<span>([^<]+)</span>', re.S)
40 +# galerie : pleine taille dans gr-modal-gallery-src (les <img> sont lazyload) ;
41 +# hébergement variable (images/glide kangalou OU s3 shareimmo/lws…)
42 +_GALLERY_RE = re.compile(r'gr-modal-gallery-src="(https?://[^"]+)"')
43 +_FLAT_FIELD = {
44 + "loyer": re.compile(r"Loyer:\s*\|\s*([\d\s ,]+\$\s*/\s*mois)"),
45 + "etage": re.compile(r"Étage:\s*\|\s*(\d{1,2})\b"),
46 + "sdb": re.compile(r"Nombre de salles? de bain:\s*\|\s*(\d+(?:[.,]5)?)"),
47 + "chambres": re.compile(r"Nombre de chambres:\s*\|\s*(\d+)"),
48 + "superficie": re.compile(r"Superficie:\s*\|\s*([\d\s ,.]+)\s*m\s*\|\s*2"),
49 + "dispo": re.compile(r"Disponibilité:\s*\|\s*([^|]{1,60})"),
50 +}
51 +_DESC_RE = re.compile(
52 + r'Description du logement à louer\s*</h2>.*?<div class="typography"[^>]*>(.*?)</div>',
53 + re.S)
54 +
55 +
56 +def _parse_fiche(html: str) -> dict:
57 + """Champs riches d'une fiche Kangalou (SSR)."""
58 + out: dict = {}
59 + m = _GPS_RE.search(html)
60 + if m:
61 + out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))
62 + m = _ADDR_RE.search(html)
63 + if m:
64 + out["address"] = m.group(1).strip()
65 +
66 + flat = du.flatten(html)
67 + vals = {k: rx.search(flat) for k, rx in _FLAT_FIELD.items()}
68 + if vals["chambres"]:
69 + out["bedrooms"] = float(vals["chambres"].group(1))
70 + if vals["sdb"]:
71 + out["bathrooms"] = float(vals["sdb"].group(1).replace(",", "."))
72 + if vals["dispo"]:
73 + out["availability"] = vals["dispo"].group(1).strip(" .")
74 + if vals["etage"]:
75 + out["details"] = {"floor": int(vals["etage"].group(1))}
76 + if vals["superficie"]:
77 + try:
78 + m2 = float(vals["superficie"].group(1)
79 + .replace(" ", "").replace(" ", "").replace(",", "."))
80 + if 8 <= m2 <= 2000:
81 + out["area_sqft"] = round(m2 * 10.7639)
82 + except ValueError:
83 + pass
84 +
85 + m = _DESC_RE.search(html)
86 + if m:
87 + txt = re.sub(r"<br\s*/?>", "\n", m.group(1))
88 + txt = re.sub(r"<[^>]+>", " ", txt)
89 + import html as _h
90 + out["description"] = re.sub(r"[ \t]+", " ", _h.unescape(txt)).strip()[:6000]
91 +
92 + # commodités : sections Inclusions / Caractéristiques / Détail / Critères
93 + # (texte aplati « libellé | valeur ») — libellés courts jusqu'à la section
94 + # suivante, sans jamais interpréter
95 + amen: list[str] = []
96 + _sections = ("Inclusions", "Caractéristiques intérieures",
97 + "Caractéristiques extérieures", "Autres services",
98 + "Critères importants", "Détail", "Sécurité", "Buanderie")
99 + for section in _sections:
100 + m2m = re.search(r"\|\s*" + re.escape(section) + r"\s*\|", flat)
101 + if not m2m: # titre de SECTION seulement (« | Titre | »),
102 + continue # pas une mention dans la description
103 + i = m2m.end()
104 + seg = flat[i:i + 400]
105 + for item in seg.split("|"):
106 + item = item.strip()
107 + if not item or len(item) > 45:
108 + break
109 + if item in _sections: # section suivante : elle a sa passe
110 + break
111 + if re.match(r"^(Partager|Contacter|Description|Transport|À proximité"
112 + r"|Santé|Éducation|Informations)", item):
113 + break
114 + if item not in amen:
115 + amen.append(item)
116 + if amen:
117 + out["amenities"] = amen
118 +
119 + # galerie (slider--gallery ; les vignettes asnav dupliquées sont exclues)
120 + ig = html.find("slider--gallery")
121 + if ig >= 0:
122 + seg = html[ig:]
123 + cut = seg.find("slider__asnav")
124 + if cut > 0:
125 + seg = seg[:cut]
126 + photos = []
127 + for u in _GALLERY_RE.findall(seg):
128 + if u not in photos:
129 + photos.append(u)
130 + if photos:
131 + out["images"] = photos
132 +
133 + vt = du.virtual_tour(html)
134 + if vt:
135 + out.setdefault("details", {})["virtual_tour"] = vt
136 + return out
137 +
138 +
139 +class KangalouConnector(BaseConnector):
140 + source_id = "kangalou"
141 + request_delay = 0.5
142 +
143 + def _pins(self) -> list[dict]:
144 + resp = self.post(PINS_API, data={"pinsOnly": "true"},
145 + headers={"Accept": "application/json",
146 + "X-Requested-With": "XMLHttpRequest"})
147 + return (resp.json() or {}).get("pins") or []
148 +
149 + @staticmethod
150 + def _unit_type(pin: dict) -> str:
151 + an = (pin.get("an") or "").strip() # « 4 ½ », « 9 ½ + »
152 + if an:
153 + ut = normalize_unit_type(an)
154 + if re.match(r"^\d½$|^6½\+$", ut or ""):
155 + return ut
156 + return _TYPES.get(pin.get("ti"), "")
157 +
158 + def fetch(self) -> list[Listing]:
159 + pins = self._pins()
160 + if MAX_PINS:
161 + pins = pins[:MAX_PINS]
162 + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS)
163 + out: list[Listing] = []
164 + try:
165 + for pin in pins:
166 + if pin.get("ir"):
167 + continue # déjà loué : pas une offre active
168 + if pin.get("ti") in _EXCLUDED_TI or not pin.get("id"):
169 + continue # commercial / garage / malformé
170 + url = pin.get("l") or ""
171 + if not url:
172 + continue
173 + eid = str(pin["id"])
174 + price = None
175 + try:
176 + price = float(str(pin.get("mc")).replace(",", ""))
177 + except (TypeError, ValueError):
178 + pass
179 + if price is not None and not (100 <= price <= 20000):
180 + price = None # affichage « sur demande » etc.
181 + lat = lng = None
182 + try:
183 + lat, lng = float(pin["lt"]), float(pin["lg"])
184 + except (KeyError, TypeError, ValueError):
185 + pass
186 + photo = pin.get("p") or ""
187 + lst = Listing(
188 + source=self.source_id,
189 + external_id=eid,
190 + url=url,
191 + title=(pin.get("n") or "").strip(),
192 + city=(pin.get("c") or "").strip(),
193 + unit_type=self._unit_type(pin),
194 + price=price,
195 + price_label=(f"{price:,.0f} $/mois".replace(",", " ")
196 + if price else ""),
197 + images=[photo] if photo else [],
198 + lat=lat,
199 + lng=lng,
200 + )
201 + payload = cache.get(eid, url, _parse_fiche)
202 + if payload:
203 + du.apply_detail(lst, payload)
204 + if payload.get("address"):
205 + lst.title = payload["address"]
206 + out.append(lst)
207 + finally:
208 + cache.close()
209 + return out
modified louka/connectors/logisquebec.py +20 −2
@@ -50,7 +50,12 @@ _LOC_RE = re.compile(r"<loc>(.*?)</loc>")
50 50 _PHOTO_RE = re.compile(r"https://i\.logisquebec\.com/i-a-louer/\d+/\d+/[^'\"]+")
51 51 _DISPO_RE = re.compile(r"Disponible à partir de\s*:?\s*\|\s*([^|]{1,60})")
52 52
53 −DETAIL_LIMIT = int(os.environ.get("LOUKA_LOGISQUEBEC_DETAIL_LIMIT", "1200"))
53 +# Dimensionnement du budget détail : parc ~6 000 fiches, TTL 7 j, watch ~10
54 +# passes/jour -> ~90 rafraîchissements/passe suffisent à tenir le TTL. 400
55 +# laisse 4x de marge (poussées de nouvelles fiches) en ramenant le cycle de
56 +# ~19 min à ~5 min. Les fiches JAMAIS VUES sont servies en premier (voir
57 +# fetch) : aucune perte de volume, le parc se complète au même rythme.
58 +DETAIL_LIMIT = int(os.environ.get("LOUKA_LOGISQUEBEC_DETAIL_LIMIT", "400"))
54 59 TTL_DAYS = float(os.environ.get("LOUKA_LOGISQUEBEC_TTL_DAYS", "7"))
55 60 MAX_FICHES = int(os.environ.get("LOUKA_LOGISQUEBEC_MAX", "0")) # 0 = tout
56 61
@@ -119,12 +124,16 @@ def _parse_fiche(html: str) -> dict:
119 124 amenities.append(label)
120 125 if amenities:
121 126 out["amenities"] = amenities
127 +
128 + vt = du.virtual_tour(html) # lien Matterport/iGUIDE… si la fiche en a un
129 + if vt:
130 + out["virtual_tour"] = vt
122 131 return out
123 132
124 133
125 134 class LogisQuebecConnector(BaseConnector):
126 135 source_id = "logisquebec"
127 − request_delay = 0.7
136 + request_delay = 0.5
128 137
129 138 # -- découverte ------------------------------------------------------------
130 139 def _fiche_urls(self) -> list[tuple[str, str, str, str]]:
@@ -182,6 +191,8 @@ class LogisQuebecConnector(BaseConnector):
182 191 details["rooms"] = payload["rooms"] # numberOfRooms (JSON-LD)
183 192 if court_terme:
184 193 details["Location court terme"] = "oui"
194 + if payload.get("virtual_tour"):
195 + details["virtual_tour"] = payload["virtual_tour"]
185 196
186 197 amenities = payload.get("amenities") or []
187 198 lst = Listing(
@@ -215,6 +226,13 @@ class LogisQuebecConnector(BaseConnector):
215 226 if MAX_FICHES:
216 227 fiches = fiches[:MAX_FICHES]
217 228 cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS)
229 + # priorité du budget aux fiches jamais visitées (tri stable) : les
230 + # nouveautés sont toujours captées au premier passage, le reste du
231 + # budget sert au rafraîchissement TTL
232 + seen = {r["external_id"] for r in cache.con.execute(
233 + "SELECT external_id FROM detail_cache WHERE source=?",
234 + (self.source_id,))}
235 + fiches.sort(key=lambda f: f[3] in seen)
218 236 out: list[Listing] = []
219 237 try:
220 238 for url, type_slug, _ville, eid in fiches:
modified louka/connectors/louer_ca.py +123 −53
@@ -58,24 +58,31 @@ _AMENITY_FR = {
58 58 class LouerCaConnector(BaseConnector):
59 59 source_id = "louer_ca"
60 60 request_delay = 0.5
61 + # points d'entrée du réseau Rentals.ca — surchargés par rentals_ca.py
62 + home_url = HOME
63 + gql_url = GQL
64 + site = "https://louer.ca"
65 + fallback_key = FALLBACK_KEY
66 + page_size = PAGE_SIZE
67 + max_per_city = MAX_PER_CITY
61 68
62 69 def __init__(self) -> None:
63 70 super().__init__()
64 71 self.session.headers.update({
65 72 "Content-Type": "application/json",
66 − "Origin": "https://louer.ca",
67 − "Referer": "https://louer.ca/",
73 + "Origin": self.site,
74 + "Referer": self.home_url,
68 75 })
69 76 self._token = ""
70 77 self._token_time = 0.0
71 − self._api_key = FALLBACK_KEY
78 + self._api_key = self.fallback_key
72 79
73 80 # -- auth ------------------------------------------------------------------
74 81 def _ensure_token(self) -> None:
75 82 if self._token and time.time() - self._token_time < 2700: # ~45 min
76 83 return
77 84 try:
78 − home = self.get(HOME).text
85 + home = self.get(self.home_url).text
79 86 m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home)
80 87 if m:
81 88 self._api_key = m.group(1)
@@ -99,8 +106,8 @@ class LouerCaConnector(BaseConnector):
99 106 if auth:
100 107 self._ensure_token()
101 108 headers["Authorization"] = f"Bearer {self._token}"
102 − resp = self.post(GQL, data=json.dumps({"query": query,
103 − "variables": variables}),
109 + resp = self.post(self.gql_url, data=json.dumps({"query": query,
110 + "variables": variables}),
104 111 headers=headers)
105 112 payload = resp.json()
106 113 if payload.get("errors"):
@@ -108,8 +115,9 @@ class LouerCaConnector(BaseConnector):
108 115 if "AUTH" in msg.upper() and auth: # jeton expiré : on réessaie
109 116 self._token = ""
110 117 self._ensure_token()
111 − resp = self.post(GQL, data=json.dumps({"query": query,
112 − "variables": variables}),
118 + resp = self.post(self.gql_url,
119 + data=json.dumps({"query": query,
120 + "variables": variables}),
113 121 headers={"Authorization": f"Bearer {self._token}"})
114 122 payload = resp.json()
115 123 return payload.get("data") or {}
@@ -141,7 +149,7 @@ class LouerCaConnector(BaseConnector):
141 149 "edges{node{" + self._LIST_FRAG + "}}}}}}")
142 150 out, after = [], None
143 151 while True:
144 − d = self._gql(query, {"id": city_id, "first": PAGE_SIZE,
152 + d = self._gql(query, {"id": city_id, "first": self.page_size,
145 153 "after": after})
146 154 rl = ((d.get("node") or {}).get("rentalListings")) or {}
147 155 edges = rl.get("edges") or []
@@ -149,7 +157,7 @@ class LouerCaConnector(BaseConnector):
149 157 info = rl.get("pageInfo") or {}
150 158 if not info.get("hasNextPage") or not edges:
151 159 break
152 − if MAX_PER_CITY and len(out) >= MAX_PER_CITY:
160 + if self.max_per_city and len(out) >= self.max_per_city:
153 161 break
154 162 after = info.get("endCursor")
155 163 return out
@@ -157,8 +165,9 @@ class LouerCaConnector(BaseConnector):
157 165 _DETAIL_FRAG = ("id name path location "
158 166 "address{city{name regionCode} neighbourhood{name} "
159 167 "postalCode street} description{plain} "
160 − "imagesCount images{scales} "
161 − "floorPlans{beds baths rent size availability furnished}")
168 + "imagesCount images{scales} tours{name type refId} "
169 + "floorPlans{beds baths rent size availability furnished "
170 + "tours{name type refId}}")
162 171
163 172 def _detail(self, gid: str) -> dict:
164 173 d = self._gql("query($id:ID!){node(id:$id){... on RentalListing{"
@@ -208,6 +217,39 @@ class LouerCaConnector(BaseConnector):
208 217 imgs.append(best)
209 218 return imgs
210 219
220 + @staticmethod
221 + def _count(v) -> float | None:
222 + """beds/baths GraphQL -> float (0 = studio) ; None si inconnu."""
223 + try:
224 + f = float(v)
225 + except (TypeError, ValueError):
226 + return None
227 + return f if 0 <= f <= 20 else None
228 +
229 + @staticmethod
230 + def _tour_url(tours) -> str | None:
231 + """URL de visite virtuelle depuis les `tours` GraphQL du réseau
232 + Rentals.ca : refId Matterport/YouTube (ou URL complète). La visite
233 + interactive prime sur la simple vidéo."""
234 + video = None
235 + for t in tours or []:
236 + ref = str(t.get("refId") or "").strip()
237 + typ = t.get("type") or ""
238 + if not ref:
239 + continue
240 + if ref.startswith("http"):
241 + url = ref
242 + elif "matterport" in typ:
243 + url = f"https://my.matterport.com/show/?m={ref}"
244 + elif "youtube" in typ:
245 + url = f"https://www.youtube.com/watch?v={ref}"
246 + else:
247 + continue
248 + if "interactive" in typ or "matterport" in typ:
249 + return url
250 + video = video or url
251 + return video
252 +
211 253 @staticmethod
212 254 def _numeric_id(gid: str) -> str:
213 255 """« cmVudGFsbGlzdGluZzoxMTMyMTE2 » -> « 1132116 » (rentallisting:1132116)."""
@@ -221,12 +263,78 @@ class LouerCaConnector(BaseConnector):
221 263 m = re.search(r"(\d+)", gid)
222 264 return m.group(1) if m else gid
223 265
266 + def _card_listings(self, card: dict, node: dict, today: str,
267 + default_city: str = "") -> list[Listing]:
268 + """Annonces Lou-Ka d'une carte liste + son nœud détail GraphQL
269 + (une par plan d'étage). Partagé avec rentals_ca.py."""
270 + out: list[Listing] = []
271 + gid = card.get("id")
272 + addr = node.get("address") or {}
273 + city = (addr.get("city") or {}).get("name") or default_city
274 + sector = (addr.get("neighbourhood") or {}).get("name") or ""
275 + loc = card.get("location") or []
276 + lng, lat = (loc + [None, None])[:2]
277 + base_desc = ((node.get("description") or {}).get("plain")
278 + or "")[:6000]
279 + images = self._images(node)
280 + amenities = self._amenities(card.get("amenities"))
281 + common = dict(
282 + source=self.source_id, url=f"{self.site}/{card.get('path','')}",
283 + address=addr.get("street") or "", sector=sector, city=city,
284 + description=base_desc, amenities=amenities, images=images,
285 + lat=lat, lng=lng,
286 + )
287 + base_details: dict = {}
288 + if addr.get("postalCode"):
289 + base_details["Code postal"] = addr["postalCode"]
290 + tour = self._tour_url(node.get("tours"))
291 + if tour:
292 + base_details["virtual_tour"] = tour
293 + if base_details:
294 + common["details"] = base_details
295 +
296 + plans = node.get("floorPlans") or []
297 + if not plans:
298 + # pas de plan détaillé : une annonce « à partir de »
299 + rng = card.get("rentRange") or []
300 + price = rng[0] if rng else None
301 + beds = (card.get("bedsRange") or [None])[0]
302 + lst = self._mk(common, gid, "", price,
303 + self._unit_type(beds), None, today,
304 + price_from=bool(rng))
305 + lst.bedrooms = self._count(beds)
306 + lst.bathrooms = self._count(
307 + (card.get("bathsRange") or [None])[0])
308 + out.append(lst)
309 + return out
310 + for i, fp in enumerate(plans):
311 + avail = fp.get("availability") or {}
312 + adate = "now" if avail.get("now") else (
313 + avail.get("date") or "")[:10] or None
314 + if adate and adate != "now" and adate <= today:
315 + adate = "now"
316 + lst = self._mk(common, gid, f"-{i}", fp.get("rent"),
317 + self._unit_type(fp.get("beds")),
318 + fp.get("size"), today, adate=adate)
319 + lst.bedrooms = self._count(fp.get("beds"))
320 + lst.bathrooms = self._count(fp.get("baths"))
321 + fp_tour = self._tour_url(fp.get("tours"))
322 + if fp_tour: # la visite du plan précis bat celle de l'immeuble
323 + lst.details["virtual_tour"] = fp_tour
324 + if fp.get("furnished") == "yes":
325 + lst.furnished = True
326 + out.append(lst)
327 + return out
328 +
224 329 def fetch(self) -> list[Listing]:
225 330 # le « détail » est une requête GraphQL, pas une page HTML : le
226 331 # fetch_html du cache reçoit le gid et le renvoie tel quel, parse_fn
227 332 # exécute la requête GraphQL.
333 + # clé v2 : force le re-téléchargement progressif des détails pour
334 + # capter tours{} (visites virtuelles) et floorPlans.baths ajoutés en
335 + # vague 2 — les payloads v1 périmés restent utilisés en attendant
228 336 cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,
229 − key="v1", fetch_html=lambda gid: gid)
337 + key="v2", fetch_html=lambda gid: gid)
230 338 out: list[Listing] = []
231 339 today = datetime.date.today().isoformat()
232 340 try:
@@ -240,46 +348,8 @@ class LouerCaConnector(BaseConnector):
240 348 continue
241 349 node = cache.get(gid, gid,
242 350 lambda g: self._detail(g)) or {}
243 − addr = node.get("address") or {}
244 − city = (addr.get("city") or {}).get("name") or slug.title()
245 − sector = (addr.get("neighbourhood") or {}).get("name") or ""
246 − loc = card.get("location") or []
247 − lng, lat = (loc + [None, None])[:2]
248 − base_desc = ((node.get("description") or {}).get("plain")
249 − or "")[:6000]
250 − images = self._images(node)
251 − amenities = self._amenities(card.get("amenities"))
252 − common = dict(
253 − source=self.source_id, url=f"https://louer.ca/{card.get('path','')}",
254 − address=addr.get("street") or "", sector=sector, city=city,
255 − description=base_desc, amenities=amenities, images=images,
256 − lat=lat, lng=lng,
257 − )
258 − if addr.get("postalCode"):
259 − common["details"] = {"Code postal": addr["postalCode"]}
260 −
261 − plans = node.get("floorPlans") or []
262 − if not plans:
263 − # pas de plan détaillé : une annonce « à partir de »
264 − rng = card.get("rentRange") or []
265 − price = rng[0] if rng else None
266 − beds = (card.get("bedsRange") or [None])[0]
267 − out.append(self._mk(common, gid, "", price,
268 − self._unit_type(beds), None, today,
269 − price_from=bool(rng)))
270 − continue
271 − for i, fp in enumerate(plans):
272 − avail = fp.get("availability") or {}
273 − adate = "now" if avail.get("now") else (
274 − avail.get("date") or "")[:10] or None
275 − if adate and adate != "now" and adate <= today:
276 − adate = "now"
277 − lst = self._mk(common, gid, f"-{i}", fp.get("rent"),
278 − self._unit_type(fp.get("beds")),
279 − fp.get("size"), today, adate=adate)
280 − if fp.get("furnished") == "yes":
281 − lst.furnished = True
282 − out.append(lst)
351 + out.extend(self._card_listings(card, node, today,
352 + default_city=slug.title()))
283 353 finally:
284 354 cache.close()
285 355 return out
added louka/connectors/rentals_ca.py +174 −0
@@ -0,0 +1,174 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/rentals_ca.py : Rentals.ca — portail locatif national, filtré
5 +# PROVINCE DE QUÉBEC. Même plateforme GraphQL que Louer.ca (mêmes City.id,
6 +# mutation acquireAuthInfo, clé publique `rentalsGqlKey` de window.appconf),
7 +# mais l'inventaire de marque est un peu plus large (~+15 % vérifié sur
8 +# Québec : 78 annonces contre 67 côté Louer.ca) — les doublons inter-marques
9 +# sont résorbés par la dédup (rentals_ca est dans la liste PORTALS).
10 +# ⚠️ rentals.ca est derrière Cloudflare (« Just a moment… ») : chaque appel
11 +# GraphQL est REJOUÉ via Scrapfly ASP SANS rendu JS (POST JSON, coût minime
12 +# par rapport à un render_js). Pour contenir le budget Scrapfly :
13 +# - villes limitées (LOUKA_RENTALSCA_CITIES) et plafond par ville ;
14 +# - pages de 200 annonces (1 à 4 requêtes liste par ville) ;
15 +# - détails PAR LOTS de 40 via nodes(ids:[…]) — 1 requête pour 40 fiches —
16 +# avec cache TTL : seuls les lots nouveaux/périmés sont re-téléchargés.
17 +# Régime de croisière : ~25-40 appels Scrapfly par synchronisation.
18 +# ⚠️ CGU Rentals.ca : extraction interdite sans accord écrit (même réserve
19 +# que louer_ca).
20 +# -----------------------------------------------------------------------------
21 +from __future__ import annotations
22 +
23 +import datetime
24 +import json
25 +import os
26 +import re
27 +
28 +from ..schema import Listing
29 +from . import _detailutil as du
30 +from .louer_ca import LouerCaConnector
31 +
32 +# villes QC interrogées (slugs rentals.ca) — volontairement bornées : chaque
33 +# page liste coûte un appel Scrapfly ASP
34 +CITIES = [c.strip() for c in os.environ.get(
35 + "LOUKA_RENTALSCA_CITIES",
36 + "montreal,quebec-city,laval,gatineau,longueuil,sherbrooke,levis,"
37 + "trois-rivieres,saguenay,drummondville").split(",") if c.strip()]
38 +
39 +PAGE_SIZE = int(os.environ.get("LOUKA_RENTALSCA_PAGE_SIZE", "200"))
40 +MAX_PER_CITY = int(os.environ.get("LOUKA_RENTALSCA_MAX_PER_CITY", "800"))
41 +DETAIL_LIMIT = int(os.environ.get("LOUKA_RENTALSCA_DETAIL_LIMIT", "600"))
42 +DETAIL_BATCH = int(os.environ.get("LOUKA_RENTALSCA_DETAIL_BATCH", "40"))
43 +TTL_DAYS = float(os.environ.get("LOUKA_RENTALSCA_TTL_DAYS", "7"))
44 +
45 +
46 +class RentalsCaConnector(LouerCaConnector):
47 + source_id = "rentals_ca"
48 + request_delay = 0.3 # entre appels Scrapfly (API, pas le site)
49 + home_url = "https://rentals.ca/"
50 + gql_url = "https://rentals.ca/graphql"
51 + site = "https://rentals.ca"
52 + fallback_key = "kJFM-mm4c-xg6B-qiwy" # rentalsGqlKey publique (2026-08)
53 + page_size = PAGE_SIZE
54 + max_per_city = MAX_PER_CITY
55 +
56 + # -- transport : tout passe par Scrapfly ASP (POST JSON, sans rendu JS) ----
57 + def _scrapfly_gql(self, query: str, variables: dict,
58 + token: str | None = None) -> dict:
59 + headers = {"Content-Type": "application/json",
60 + "Origin": self.site, "Referer": self.home_url}
61 + if token:
62 + headers["Authorization"] = f"Bearer {token}"
63 + res = self.scrapfly(self.gql_url, render_js=False, asp=True,
64 + method="POST",
65 + body=json.dumps({"query": query,
66 + "variables": variables}),
67 + headers=headers)
68 + try:
69 + return json.loads(res.get("content") or "{}")
70 + except ValueError:
71 + return {}
72 +
73 + def _ensure_token(self) -> None:
74 + import time as _time
75 + if self._token and _time.time() - self._token_time < 2700:
76 + return
77 + payload = self._scrapfly_gql(
78 + "mutation($k:String!){acquireAuthInfo(credentials:{apiKey:$k})"
79 + "{jwt status}}", {"k": self._api_key})
80 + auth = ((payload.get("data") or {}).get("acquireAuthInfo")) or {}
81 + jwt = auth.get("jwt")
82 + if isinstance(jwt, str) and jwt.startswith("{"):
83 + jwt = json.loads(jwt)
84 + token = jwt.get("accessToken") if isinstance(jwt, dict) else jwt
85 + if not token:
86 + # clé périmée ? la relire dans window.appconf de la page d'accueil
87 + home = self.get_scrapfly(self.home_url, render_js=False, asp=True)
88 + m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home or "")
89 + if m:
90 + self._api_key = m.group(1)
91 + payload = self._scrapfly_gql(
92 + "mutation($k:String!){acquireAuthInfo(credentials:"
93 + "{apiKey:$k}){jwt status}}", {"k": self._api_key})
94 + auth = ((payload.get("data") or {})
95 + .get("acquireAuthInfo")) or {}
96 + jwt = auth.get("jwt")
97 + if isinstance(jwt, str) and jwt.startswith("{"):
98 + jwt = json.loads(jwt)
99 + token = (jwt.get("accessToken")
100 + if isinstance(jwt, dict) else jwt)
101 + if not token:
102 + raise RuntimeError("Rentals.ca: handshake JWT échoué")
103 + self._token = token
104 + self._token_time = _time.time()
105 +
106 + def _gql(self, query: str, variables: dict, auth: bool = True) -> dict:
107 + if auth:
108 + self._ensure_token()
109 + payload = self._scrapfly_gql(query, variables,
110 + token=self._token if auth else None)
111 + if payload.get("errors") and auth:
112 + msg = payload["errors"][0].get("message", "")
113 + if "AUTH" in msg.upper(): # jeton expiré : on réessaie
114 + self._token = ""
115 + self._ensure_token()
116 + payload = self._scrapfly_gql(query, variables,
117 + token=self._token)
118 + return payload.get("data") or {}
119 +
120 + # -- détail par LOTS (1 requête Scrapfly pour DETAIL_BATCH fiches) ---------
121 + def _details_batch(self, gids: list[str]) -> dict[str, dict]:
122 + q = ("query($ids:[ID!]!){nodes(ids:$ids){... on RentalListing{"
123 + + self._DETAIL_FRAG + "}}}")
124 + out: dict[str, dict] = {}
125 + for node in self._gql(q, {"ids": gids}).get("nodes") or []:
126 + if isinstance(node, dict) and node.get("id"):
127 + out[node["id"]] = node
128 + return out
129 +
130 + def fetch(self) -> list[Listing]:
131 + if not os.environ.get("SCRAPFLY_KEY"):
132 + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
133 + today = datetime.date.today().isoformat()
134 + cards: dict[str, tuple[dict, str]] = {}
135 + for slug in CITIES:
136 + cid = self._city_id(slug)
137 + if not cid:
138 + continue
139 + for card in self._city_listings(cid):
140 + gid = card.get("id")
141 + if gid:
142 + cards.setdefault(gid, (card, slug))
143 +
144 + # cache TTL : détails frais réutilisés sans trafic, le reste par lots
145 + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,
146 + key="v1", fetch_html=lambda gid: gid)
147 + details: dict[str, dict] = {}
148 + todo: list[str] = []
149 + out: list[Listing] = []
150 + try:
151 + for gid in cards:
152 + payload, fresh = cache.peek(gid)
153 + if payload is not None:
154 + details[gid] = payload
155 + if not fresh and len(todo) < DETAIL_LIMIT:
156 + todo.append(gid)
157 + for i in range(0, len(todo), DETAIL_BATCH):
158 + got = self._details_batch(todo[i:i + DETAIL_BATCH])
159 + for gid, node in got.items():
160 + cache.put(gid, node)
161 + details[gid] = node
162 +
163 + for gid, (card, slug) in cards.items():
164 + node = details.get(gid) or {}
165 + region = (((node.get("address") or {}).get("city") or {})
166 + .get("regionCode"))
167 + if region and region != "QC":
168 + continue # sécurité : Québec uniquement
169 + out.extend(self._card_listings(
170 + card, node, today,
171 + default_city=slug.replace("-", " ").title()))
172 + finally:
173 + cache.close()
174 + return out
modified louka/db.py +11 −3
@@ -38,6 +38,8 @@ CREATE TABLE IF NOT EXISTS listings (
38 38 sector TEXT,
39 39 city TEXT,
40 40 unit_type TEXT,
41 + bedrooms REAL,
42 + bathrooms REAL,
41 43 price REAL,
42 44 price_label TEXT,
43 45 availability TEXT,
@@ -158,6 +160,8 @@ _MIGRATIONS = {
158 160 "digest": "TEXT", # JSON louka/textmine.py (description structurée)
159 161 "dup_of": "TEXT", # uid de l'annonce canonique si doublon inter-sources
160 162 "dup_sources": "TEXT", # JSON : autres sources où l'annonce est publiée
163 + "bedrooms": "REAL", # chambres fermées (convention QC : 4½ = 2)
164 + "bathrooms": "REAL", # salles de bain (1.5 = salle d'eau en plus)
161 165 },
162 166 "sync_log": {
163 167 "stats": "TEXT",
@@ -258,6 +262,7 @@ def sync_source(con: sqlite3.Connection, source: str,
258 262 uid=lst.uid, source=lst.source, external_id=lst.external_id,
259 263 url=lst.url, title=lst.title, address=lst.address,
260 264 sector=lst.sector, city=lst.city, unit_type=lst.unit_type,
265 + bedrooms=lst.bedrooms, bathrooms=lst.bathrooms,
261 266 price=lst.price, price_label=lst.price_label,
262 267 availability=lst.availability,
263 268 availability_date=lst.availability_date,
@@ -274,13 +279,15 @@ def sync_source(con: sqlite3.Connection, source: str,
274 279 if row is None:
275 280 con.execute(
276 281 """INSERT INTO listings (uid, source, external_id, url, title,
277 − address, sector, city, unit_type, price, price_label,
282 + address, sector, city, unit_type, bedrooms, bathrooms,
283 + price, price_label,
278 284 availability, availability_date, area_sqft, pets, furnished,
279 285 description, digest, amenities, details, images, lat, lng,
280 286 content_hash, first_seen, last_seen, updated_at,
281 287 miss_count, active)
282 288 VALUES (:uid,:source,:external_id,:url,:title,:address,
283 − :sector,:city,:unit_type,:price,:price_label,:availability,
289 + :sector,:city,:unit_type,:bedrooms,:bathrooms,
290 + :price,:price_label,:availability,
284 291 :availability_date,:area_sqft,:pets,:furnished,
285 292 :description,:digest,:amenities,:details,:images,:lat,:lng,
286 293 :content_hash,:now,:now,:now,0,1)""", params)
@@ -293,7 +300,8 @@ def sync_source(con: sqlite3.Connection, source: str,
293 300 con.execute(
294 301 """UPDATE listings SET url=:url, title=:title,
295 302 address=:address, sector=:sector, city=:city,
296 − unit_type=:unit_type, price=:price,
303 + unit_type=:unit_type, bedrooms=:bedrooms,
304 + bathrooms=:bathrooms, price=:price,
297 305 price_label=:price_label, availability=:availability,
298 306 availability_date=:availability_date,
299 307 area_sqft=:area_sqft, pets=:pets, furnished=:furnished,
modified louka/normalize.py +79 −0
@@ -150,6 +150,85 @@ def normalize_unit_type(raw: str) -> str:
150 150 return _clean_text(str(raw))
151 151
152 152
153 +# ---------------------------------------------------------------------------
154 +# Chambres / salles de bain
155 +# ---------------------------------------------------------------------------
156 +
157 +_BEDROOMS_TXT_RE = re.compile(
158 + r"(\d+)\s*(?:chambres?(?:\s+a\s+coucher)?\b|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)")
159 +_BATHROOMS_TXT_RE = re.compile(
160 + r"(\d+(?:[.,]5)?)\s*(?:salles?\s+de\s+bains?\b|\bsdb\b|bath(?:room)?s?\b)")
161 +
162 +
163 +def bedrooms_from_unit_type(unit_type: str) -> float | None:
164 + """Chambres fermées selon la convention québécoise des « pièces et demie ».
165 +
166 + n½ = n pièces -> n-2 chambres (3½→1, 4½→2, 5½→3) ; 1½/2½ -> 0 ;
167 + 6½+ -> 4 (borne basse connue) ; Studio/Loft -> 0 ; Chambre -> 1.
168 + Types sans nombre de pièces (Maison, Condo…) -> None (inconnu).
169 + """
170 + if not unit_type:
171 + return None
172 + s = unit_type.strip()
173 + if s == "6½+":
174 + return 4.0
175 + m = re.match(r"^(\d+)½$", s)
176 + if m:
177 + return float(max(int(m.group(1)) - 2, 0))
178 + key = _key(s)
179 + if key in ("studio", "loft"):
180 + return 0.0
181 + if key == "chambre":
182 + return 1.0
183 + return None
184 +
185 +
186 +def parse_bedrooms(*texts: str) -> float | None:
187 + """Nombre de chambres explicite dans un texte : « 3 chambres », « 2 bedrooms ».
188 +
189 + Jamais de valeur inventée : None si aucun nombre plausible (0-12).
190 + """
191 + for t in texts:
192 + if not t:
193 + continue
194 + m = _BEDROOMS_TXT_RE.search(_key(str(t)))
195 + if m:
196 + n = int(m.group(1))
197 + if 0 <= n <= 12:
198 + return float(n)
199 + return None
200 +
201 +
202 +def parse_bathrooms(*texts: str) -> float | None:
203 + """Nombre de salles de bain : « 2 salles de bain », « 1.5 bath », « 1 sdb »."""
204 + for t in texts:
205 + if not t:
206 + continue
207 + m = _BATHROOMS_TXT_RE.search(_key(str(t)))
208 + if m:
209 + try:
210 + v = float(m.group(1).replace(",", "."))
211 + except ValueError:
212 + continue
213 + if 0.5 <= v <= 10:
214 + return v
215 + return None
216 +
217 +
218 +def coerce_count(raw) -> float | None:
219 + """« 2 », « 1,5 », 2, 2.0 -> float ; sinon None (valeurs de details JSON)."""
220 + if raw is None or isinstance(raw, bool):
221 + return None
222 + if isinstance(raw, (int, float)):
223 + v = float(raw)
224 + return v if 0 <= v <= 20 else None
225 + m = re.match(r"^\s*(\d+(?:[.,]5)?)\s*$", str(raw))
226 + if not m:
227 + return None
228 + v = float(m.group(1).replace(",", "."))
229 + return v if 0 <= v <= 20 else None
230 +
231 +
153 232 # ---------------------------------------------------------------------------
154 233 # Date de disponibilité
155 234 # ---------------------------------------------------------------------------
modified louka/schema.py +29 −0
@@ -18,12 +18,16 @@ import json
18 18 from dataclasses import dataclass, field, asdict
19 19
20 20 from .normalize import ( # ré-exportés pour les connecteurs existants
21 + bedrooms_from_unit_type,
21 22 clean_address,
23 + coerce_count,
22 24 extract_details,
23 25 merge_details,
24 26 normalize_unit_type,
25 27 parse_area_sqft,
26 28 parse_availability_date,
29 + parse_bathrooms,
30 + parse_bedrooms,
27 31 parse_price,
28 32 price_is_from,
29 33 strip_accents,
@@ -48,6 +52,8 @@ class Listing:
48 52 sector: str = "" # quartier/arrondissement (ex. Beauport)
49 53 city: str = "" # Québec, Lévis, ...
50 54 unit_type: str = "" # 1½ … 5½, 6½+, Studio, Loft, Chambre…
55 + bedrooms: float | None = None # chambres fermées (convention QC : 4½ = 2)
56 + bathrooms: float | None = None # salles de bain (1.5 = salle d'eau en plus)
51 57 price: float | None = None # loyer mensuel ($ CAD), le plus bas si "à partir de"
52 58 price_label: str = "" # texte original (ex. "à partir de 799$")
53 59 availability: str = "" # texte original (ex. "Libre immédiatement")
@@ -104,6 +110,29 @@ class Listing:
104 110 derived["price_from"] = True
105 111 self.details = merge_details(self.details, derived)
106 112
113 + # chambres / salles de bain : valeur explicite du connecteur d'abord,
114 + # puis champs structurés de details, puis type d'unité (n½ -> n-2),
115 + # puis extraction texte — jamais de valeur inventée (None = inconnu)
116 + if self.bedrooms is None:
117 + for k in ("bedrooms", "Chambres", "Chambre(s)"):
118 + self.bedrooms = coerce_count(self.details.get(k))
119 + if self.bedrooms is not None:
120 + break
121 + if self.bedrooms is None:
122 + self.bedrooms = bedrooms_from_unit_type(self.unit_type)
123 + if self.bedrooms is None:
124 + self.bedrooms = parse_bedrooms(self.title, " | ".join(self.amenities),
125 + self.description)
126 + if self.bathrooms is None:
127 + for k in ("bathrooms", "Salles de bain", "Salle de bain",
128 + "Salle(s) de bain"):
129 + self.bathrooms = coerce_count(self.details.get(k))
130 + if self.bathrooms is not None:
131 + break
132 + if self.bathrooms is None:
133 + self.bathrooms = parse_bathrooms(" | ".join(self.amenities),
134 + self.description)
135 +
107 136 # description structurée (nettoyage + extraction + sections)
108 137 if self.digest is None and self.description:
109 138 try:
110 139