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%

Portails & courtiers en location — 10 connecteurs portés d'Immo-Ka (Kijiji, LesPAC, RE/MAX, Sutton, Royal LePage, Via Capitale, Proprio Direct, Ubee, Barnes, M Immobilier)

- base.py : backends Scrapfly (scrapfly/get_scrapfly) + post() portés d'Immo-Ka
- _detailutil.py : helper partagé d'enrichissement fiche détail (cache BD, budget par sync) adapté au schéma Listing
- Kijiji c37 apparts/condos + c36 chambres/coloc (Apollo __NEXT_DATA__, QC l9001)
- LesPAC b457 logements + b458 coloc + b460 aînés (searchResponse, ancre Montréal = province entière triée par distance)
- RE/MAX Québec : index Meilisearch public shardé par RTA, slugs -a-louer seulement
- Sutton : sitemap inscriptions typeInscription=2 (= locations)
- Royal LePage : recherche transactionType=LEASE (formulaire caché), 28 villes d'ancrage
- Via Capitale : criteresJson AVendre:false via Firecrawl (Cloudflare)
- Proprio Direct : API searchListings filter rentOrSale:rent, volet résidentiel
- Ubee : API SearchProperties listingType Renter (askPrice = loyer mensuel)
- Barnes : Algolia facette type:rental (property_rent) ; M Immobilier : CODE_STATUT=AL
- sources.json : 255 → 265 (10 nouvelles sources « Province »/Montréal)
- Écartés après vérification live (pas de location résidentielle) : DuProprio, Profusion, Engel & Völkers, KW Prestige/Distinction/Urbain, Expert PM, Sotheby's

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

13 changed files +2,759 −1

modified data/sources.json +100 −0
@@ -2548,6 +2548,106 @@
2548 2548 "connector": null,
2549 2549 "status": "non connectable — archive Opal Estate (WP 5.5) de 167 fiches figées : prix uniquement en fiche détail et périmés (« 3 1/2 … 550.00$/mois », « LIBRE AU 1ER JUILLET 2021 »), aucune date de disponibilité maintenue, étiquette « trop tard déjà loué » posée sur une poignée de fiches seulement — inventaire non entretenu depuis ~2021",
2550 2550 "region": "Estrie"
2551 + },
2552 + {
2553 + "id": "kijiji",
2554 + "name": "Kijiji Québec (location)",
2555 + "url": "https://www.kijiji.ca",
2556 + "listing_url": "https://www.kijiji.ca/b-appartement-condo/quebec/c37l9001",
2557 + "sectors": "Province entière — appartements/condos (c37) + chambres & colocation (c36), particuliers et gestionnaires",
2558 + "connector": "kijiji",
2559 + "status": "actif",
2560 + "region": "Province"
2561 + },
2562 + {
2563 + "id": "lespac",
2564 + "name": "LesPAC (location)",
2565 + "url": "https://www.lespac.com",
2566 + "listing_url": "https://www.lespac.com/montreal/immobilier-location-logements_b457g17567k1R2.jsa",
2567 + "sectors": "Province entière — logements (b457), chambres & colocation (b458), résidences pour aînés (b460)",
2568 + "connector": "lespac",
2569 + "status": "actif",
2570 + "region": "Province"
2571 + },
2572 + {
2573 + "id": "via_capitale",
2574 + "name": "Via Capitale (location)",
2575 + "url": "https://www.viacapitalevendu.com",
2576 + "listing_url": "https://www.viacapitalevendu.com/recherche/residentiel/",
2577 + "sectors": "Province entière — inscriptions résidentielles à louer de la bannière (criteresJson AVendre:false, via Firecrawl)",
2578 + "connector": "via_capitale",
2579 + "status": "actif",
2580 + "region": "Province"
2581 + },
2582 + {
2583 + "id": "royal_lepage",
2584 + "name": "Royal LePage (location)",
2585 + "url": "https://www.royallepage.ca",
2586 + "listing_url": "https://www.royallepage.ca/fr/qc/rentals/",
2587 + "sectors": "Province entière — recherche transactionType=LEASE shardée par 28 villes d'ancrage",
2588 + "connector": "royal_lepage",
2589 + "status": "actif",
2590 + "region": "Province"
2591 + },
2592 + {
2593 + "id": "proprio_direct",
2594 + "name": "Proprio Direct (location)",
2595 + "url": "https://propriodirect.com",
2596 + "listing_url": "https://propriodirect.com/resultats/?rentOrSale=rent",
2597 + "sectors": "Province entière — API searchListings filter rentOrSale:rent, volet résidentiel",
2598 + "connector": "proprio_direct",
2599 + "status": "actif",
2600 + "region": "Province"
2601 + },
2602 + {
2603 + "id": "ubee",
2604 + "name": "Ubee (location)",
2605 + "url": "https://ubee.com",
2606 + "listing_url": "https://ubee.com/a-louer",
2607 + "sectors": "Province entière — API SearchProperties listingType Renter (résidentiel)",
2608 + "connector": "ubee",
2609 + "status": "actif",
2610 + "region": "Province"
2611 + },
2612 + {
2613 + "id": "barnes_quebec",
2614 + "name": "BARNES Québec (location)",
2615 + "url": "https://barnes-quebec.com",
2616 + "listing_url": "https://barnes-quebec.com/louer/",
2617 + "sectors": "Montréal et environs — locations haut de gamme (index Algolia, facette type:rental)",
2618 + "connector": "barnes_quebec",
2619 + "status": "actif",
2620 + "region": "Montréal"
2621 + },
2622 + {
2623 + "id": "m_immobilier",
2624 + "name": "M Immobilier (location)",
2625 + "url": "https://www.mimmobilier.com",
2626 + "listing_url": "https://www.mimmobilier.com/properties?CODE_STATUT=AL",
2627 + "sectors": "Grand Montréal — filtre serveur CODE_STATUT=AL, volet résidentiel",
2628 + "connector": "m_immobilier",
2629 + "status": "actif",
2630 + "region": "Montréal"
2631 + },
2632 + {
2633 + "id": "remax_quebec",
2634 + "name": "RE/MAX Québec (location)",
2635 + "url": "https://www.remax-quebec.com",
2636 + "listing_url": "https://www.remax-quebec.com/fr/proprietes",
2637 + "sectors": "Province entière — index Meilisearch public shardé par RTA (G/H/J), slugs « -a-louer » seulement",
2638 + "connector": "remax_quebec",
2639 + "status": "actif",
2640 + "region": "Province"
2641 + },
2642 + {
2643 + "id": "sutton",
2644 + "name": "Groupe Sutton Québec (location)",
2645 + "url": "https://www.suttonquebec.com",
2646 + "listing_url": "https://www.suttonquebec.com/fr/proprietes-a-louer.html",
2647 + "sectors": "Province entière — sitemap inscriptions, typeInscription=2 (locations)",
2648 + "connector": "sutton",
2649 + "status": "actif",
2650 + "region": "Province"
2551 2651 }
2552 2652 ]
2553 2653 }
\ No newline at end of file
added louka/connectors/_detailutil.py +159 −0
@@ -0,0 +1,159 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail »
5 +# Mutualise ce que les connecteurs de portails répètent pour capter TOUTES les
6 +# infos de la fiche source : description JSON-LD, coordonnées, application
7 +# au Listing avec cache BD et budget de requêtes par synchronisation.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import html as _html
12 +import json
13 +import re
14 +
15 +from ..schema import Listing
16 +
17 +_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)
18 +_COORD_RE = re.compile(r'(?:google\.[^"\']*?[?&](?:q|query|ll|center)=|maps/@)'
19 + r'(-?\d{1,2}\.\d+)[ ,%+A-Za-z]+?(-?\d{2,3}\.\d+)')
20 +_LD_PROP_TYPES = {"RealEstateListing", "Residence", "SingleFamilyResidence",
21 + "House", "Apartment", "Product", "Offer", "Place", "Accommodation"}
22 +
23 +
24 +def ld_nodes(html: str):
25 + """Itère les objets JSON-LD (aplatis depuis @graph)."""
26 + for block in _LD_RE.findall(html):
27 + try:
28 + data = json.loads(block)
29 + except ValueError:
30 + continue
31 + graph = data.get("@graph", [data]) if isinstance(data, dict) else data
32 + for node in (graph if isinstance(graph, list) else [graph]):
33 + if isinstance(node, dict):
34 + yield node
35 +
36 +
37 +def ld_description(html: str) -> str:
38 + """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé)."""
39 + best = ""
40 + for n in ld_nodes(html):
41 + t = n.get("@type")
42 + types = t if isinstance(t, list) else [t]
43 + if any(x in _LD_PROP_TYPES for x in types) and n.get("description"):
44 + d = _html.unescape(str(n["description"])).strip()
45 + if len(d) > len(best):
46 + best = d
47 + return best
48 +
49 +
50 +def gmaps_coords(html: str) -> tuple[float, float] | None:
51 + m = _COORD_RE.search(html)
52 + if not m:
53 + return None
54 + try:
55 + lat, lng = float(m.group(1)), float(m.group(2))
56 + except ValueError:
57 + return None
58 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
59 + return lat, lng
60 + return None
61 +
62 +
63 +def flatten(html: str) -> str:
64 + """HTML -> texte « valeur | libellé » pour extraire les tableaux de fiches."""
65 + t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))
66 + t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)
67 + return re.sub(r"[ \t]+", " ", t)
68 +
69 +
70 +# libellés Centris standard (fiches de courtiers) cherchés dans un texte aplati
71 +# « valeur | libellé » OU « libellé | valeur » — version location
72 +_LABELS = [
73 + "Type de propriété", "Genre de propriété", "Style de bâtiment",
74 + "Année de construction", "Superficie habitable", "Superficie du terrain",
75 + "Nombre de pièces", "Nombre d'unités", "Stationnement (total)",
76 + "Stationnement", "Garage", "Système de chauffage",
77 + "Énergie pour le chauffage", "Piscine", "Déménagement", "Date d'emménagement",
78 + "Disponibilité", "Bail", "Durée du bail", "Meublé", "Animaux",
79 + "Inclus dans le loyer", "Cuisine",
80 +]
81 +
82 +
83 +def centris_details(text: str) -> dict:
84 + """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres)."""
85 + out: dict = {}
86 + for label in _LABELS:
87 + lab = re.escape(label)
88 + m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)
89 + or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text))
90 + if m:
91 + val = m.group(1).strip(" |")
92 + if val and 1 <= len(val) <= 55 and val.lower() != label.lower():
93 + out[label] = val
94 + return out
95 +
96 +
97 +def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):
98 + """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.
99 +
100 + - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.
101 + - `key` : versionne le cache (changer pour forcer un rafraîchissement).
102 + - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer
103 + connector.get_rendered / get_scrapfly pour les sites derrière anti-bot.
104 + Le budget `limit` fait que chaque sync n'enrichit qu'un quota de fiches ;
105 + le parc se complète sur plusieurs cycles (le cache est permanent tant que
106 + la clé ne change pas).
107 + """
108 + if limit <= 0:
109 + return
110 + from .. import db
111 + fetch_html = fetch_html or (lambda u: connector.get(u).text)
112 + con = db.connect()
113 + budget = limit
114 + try:
115 + for lst in listings:
116 + cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)
117 + if cached is None:
118 + if budget <= 0:
119 + continue
120 + try:
121 + cached = parse_fn(fetch_html(lst.url))
122 + except Exception:
123 + cached = {}
124 + db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)
125 + budget -= 1
126 + apply_detail(lst, cached)
127 + finally:
128 + con.close()
129 +
130 +
131 +def apply_detail(lst: Listing, d: dict) -> None:
132 + """Applique un payload détail au Listing sans écraser les valeurs déjà
133 + présentes (sauf images : on garde la plus grande galerie)."""
134 + if not d:
135 + return
136 + imgs = d.get("images")
137 + if imgs and len(imgs) > len(lst.images):
138 + lst.images = imgs
139 + if d.get("amenities"):
140 + seen = {a.lower() for a in lst.amenities}
141 + for a in d["amenities"]:
142 + if a.lower() not in seen:
143 + lst.amenities.append(a)
144 + seen.add(a.lower())
145 + if d.get("details"):
146 + merged = dict(d["details"])
147 + merged.update(lst.details) # les valeurs du connecteur priment
148 + lst.details = merged
149 + # description : on garde la plus riche (la fiche détail bat le résumé liste)
150 + if d.get("description") and len(d["description"]) > len(lst.description or ""):
151 + lst.description = d["description"]
152 + for f in ("price_label", "address", "city", "sector", "unit_type",
153 + "availability", "title"):
154 + if d.get(f) and not getattr(lst, f, ""):
155 + setattr(lst, f, d[f])
156 + for f in ("price", "area_sqft", "lat", "lng", "availability_date",
157 + "pets", "furnished"):
158 + if d.get(f) is not None and getattr(lst, f, None) is None:
159 + setattr(lst, f, d[f])
added louka/connectors/barnes_quebec.py +233 −0
@@ -0,0 +1,233 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/barnes_quebec.py : BARNES Québec (barnes-quebec.com) — LOCATIONS
5 +# Agence de prestige (Montréal, Québec, Laval, Mont-Tremblant…). Site
6 +# WordPress indexé dans Algolia ; la config expose l'App ID et une clé API
7 +# dans le HTML. L'index « quebec_all » mélange ventes (type "property") et
8 +# locations (type "rental") : on filtre côté serveur avec
9 +# facetFilters=[["type:rental"]]. Chaque location porte le loyer mensuel
10 +# numérique (property_rent), l'adresse, chambres/salles de bains, superficie
11 +# et géolocalisation. Les enregistrements sont dupliqués par langue (FR/EN) →
12 +# on dédoublonne par titre normalisé en gardant la fiche FR. Adapté du
13 +# connecteur « à vendre » d'Immo-Ka (agent-courtage/immoka).
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import os
18 +import re
19 +
20 +from ..schema import Listing
21 +from .base import BaseConnector
22 +
23 +from . import _detailutil as du
24 +
25 +APP_ID = "HCW55VIQNM"
26 +API_KEY = "f4a20779fb6ded84a9c96a9b5976328b"
27 +INDEX = "quebec_all"
28 +QUERY_URL = f"https://{APP_ID}-dsn.algolia.net/1/indexes/{INDEX}/query"
29 +SITE = "https://barnes-quebec.com"
30 +STAGING = "stg-quebec-staging.kinsta.cloud" # permaliens parfois en staging
31 +HITS_PER_PAGE = 100
32 +MAX_PAGES = 40
33 +# facetFilters=[["type:rental"]] URL-encodé (le corps Algolia est un query string)
34 +FACET_RENTAL = "facetFilters=%5B%5B%22type%3Arental%22%5D%5D"
35 +DETAIL_LIMIT = int(os.environ.get("LOUKA_BARNES_DETAIL_LIMIT", "200"))
36 +
37 +# Galerie WordPress : .../wp-content/uploads/AAAA/MM/{ref}-{hash}-{L}x{H}.jpg
38 +_IMG_RE = re.compile(
39 + r'https://barnes-quebec\.com/wp-content/uploads/\d{4}/\d{2}/[^"\'\\ ]+?\.(?:jpg|jpeg|png|webp)',
40 + re.I)
41 +_SIZE_RE = re.compile(r'-(\d{2,4})x(\d{2,4})(?=\.[a-z]+$)', re.I)
42 +# ville Algolia au format « Montréal (Rosemont/La Petite-Patrie) »
43 +_CITY_SECTOR_RE = re.compile(r"^(.*?)\s*\(([^)]+)\)\s*$")
44 +
45 +# caractéristiques de la fiche Barnes (table Centris rendue « libellé | valeur »
46 +# uniquement — l'ordre inverse capte la table des pièces et produit du bruit)
47 +_DETAIL_LABELS = [
48 + "Année de construction", "Superficie habitable", "Superficie du terrain",
49 + "Stationnement", "Garage", "Mode de chauffage", "Énergie pour le chauffage",
50 + "Piscine", "Vue", "Zonage", "Date d'emménagement", "Bail", "Meublé",
51 + "Animaux", "Inclus dans le loyer",
52 +]
53 +
54 +
55 +class BarnesQuebecConnector(BaseConnector):
56 + source_id = "barnes_quebec"
57 + request_delay = 0.25
58 +
59 + def fetch(self) -> list[Listing]:
60 + # une location apparaît en plusieurs langues (FR/EN) avec des objectID
61 + # distincts mais le même titre. Clé stable = titre normalisé ; on garde
62 + # la meilleure fiche (loyer connu + FR).
63 + best: dict[str, tuple[int, Listing]] = {}
64 + page = 0
65 + while page < MAX_PAGES:
66 + data = self._query(page)
67 + hits = data.get("hits", [])
68 + if not hits:
69 + break
70 + for h in hits:
71 + if h.get("type") != "rental": # ceinture + bretelles
72 + continue
73 + lst = self._to_listing(h)
74 + if lst is None:
75 + continue
76 + key = " ".join(lst.title.split()).lower()
77 + score = (2 if lst.price else 0) + (1 if h.get("lang_fr") == 1 else 0)
78 + if key not in best or score > best[key][0]:
79 + best[key] = (score, lst)
80 + if page + 1 >= data.get("nbPages", 0):
81 + break
82 + page += 1
83 + listings = [lst for _, lst in best.values()]
84 + # Algolia n'expose qu'une photo : la galerie vient de la fiche.
85 + du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v1")
86 + return listings
87 +
88 + def _query(self, page: int) -> dict:
89 + resp = self.post(
90 + QUERY_URL,
91 + headers={"X-Algolia-API-Key": API_KEY,
92 + "X-Algolia-Application-Id": APP_ID,
93 + "Content-Type": "application/json"},
94 + json={"params": f"hitsPerPage={HITS_PER_PAGE}&page={page}&{FACET_RENTAL}"},
95 + )
96 + return resp.json()
97 +
98 + def _to_listing(self, h: dict) -> Listing | None:
99 + object_id = str(h.get("objectID") or "")
100 + mls = str(h.get("property_mls_reference") or "").strip()
101 + if not object_id and not mls:
102 + return None
103 +
104 + permalink = (h.get("permalink") or "").replace(STAGING, "barnes-quebec.com")
105 + if permalink.startswith("http://"):
106 + permalink = "https://" + permalink[len("http://"):]
107 +
108 + # loyer mensuel : champ numérique dédié (property_price est null pour
109 + # les locations). property_pretty_rent = libellé propre sans HTML.
110 + rent = h.get("property_rent") or 0
111 + try:
112 + rent = float(rent)
113 + except (TypeError, ValueError):
114 + rent = 0.0
115 + price_label = (h.get("property_pretty_rent") or "").strip()
116 +
117 + # « Montréal (Rosemont/La Petite-Patrie) » -> ville + secteur
118 + city_raw = (h.get("property_address_city") or "").strip()
119 + m = _CITY_SECTOR_RE.match(city_raw)
120 + city, sector = (m.group(1), m.group(2)) if m else (city_raw, "")
121 + regions = h.get("regions") or []
122 + if not city and regions: # rare : fiche sans ville renseignée
123 + city = str(regions[0]).strip()
124 +
125 + # le titre concatène adresse + ville (+ région) : on isole la rue
126 + title = (h.get("title") or "").strip()
127 + address = title
128 + if city_raw and city_raw in title:
129 + address = title[:title.find(city_raw)].strip(" ,-")
130 +
131 + beds = _pos(h.get("property_bedrooms_integer"))
132 + unit_type = ""
133 + if beds is not None:
134 + unit_type = "Studio" if beds == 0 else f"{beds} chambres"
135 +
136 + lat = h.get("property_address_latitude") or None
137 + lng = h.get("property_address_longitude") or None
138 + try:
139 + lat = float(lat) if lat else None
140 + lng = float(lng) if lng else None
141 + except (TypeError, ValueError):
142 + lat = lng = None
143 + if lat == 0 or lng == 0:
144 + lat = lng = None
145 +
146 + details: dict = {"Agence": "BARNES Québec"}
147 + if mls:
148 + details["No Centris"] = mls
149 + baths = _pos(h.get("property_bathrooms"))
150 + if baths is not None:
151 + details["Salles de bain"] = str(baths)
152 + rooms = _pos(h.get("property_rooms"))
153 + if rooms is not None:
154 + details["Nombre de pièces"] = str(rooms)
155 + ptype = (h.get("property_type") or "").strip()
156 + if ptype:
157 + details["Type de propriété"] = ptype
158 + if regions:
159 + details["Région"] = str(regions[0])
160 +
161 + # image « liste » (la galerie complète vient de la fiche détail)
162 + images = []
163 + for k in ("image_full", "image_large", "image_medium"):
164 + if h.get(k):
165 + images = [h[k]]
166 + break
167 +
168 + return Listing(
169 + source=self.source_id,
170 + external_id=mls or object_id,
171 + url=permalink or SITE + "/rental/",
172 + title=title,
173 + address=address,
174 + sector=sector,
175 + city=city,
176 + unit_type=unit_type,
177 + price=rent if rent > 0 else None, # explicite : pas de plafond parse_price
178 + price_label=price_label,
179 + area_sqft=_posf(h.get("property_area")),
180 + description=(h.get("content") or "")[:4000],
181 + details=details,
182 + images=images,
183 + lat=lat,
184 + lng=lng,
185 + )
186 +
187 +
188 +def _pos(v):
189 + try:
190 + n = float(v)
191 + return int(n) if n and n > 0 else None
192 + except (TypeError, ValueError):
193 + return None
194 +
195 +
196 +def _posf(v):
197 + try:
198 + n = float(v)
199 + return n if n > 0 else None
200 + except (TypeError, ValueError):
201 + return None
202 +
203 +
204 +def parse_barnes_detail(html: str) -> dict:
205 + """Galerie photo pleine résolution (absente d'Algolia) + description/pièces."""
206 + out: dict = {}
207 + # regroupe par image de base (sans le suffixe -LxH), garde la plus grande
208 + best: dict[str, tuple[int, str]] = {}
209 + for u in _IMG_RE.findall(html):
210 + m = _SIZE_RE.search(u)
211 + area = int(m.group(1)) * int(m.group(2)) if m else 10 ** 8 # sans suffixe = original
212 + base = _SIZE_RE.sub("", u)
213 + if base not in best or area > best[base][0]:
214 + best[base] = (area, u)
215 + imgs = [u for _, u in best.values()]
216 + # ignore les vignettes de courtiers/logos (les photos gardent la réf Centris chiffrée)
217 + imgs = [u for u in imgs if re.search(r"/\d{6,}", u)] or imgs
218 + if imgs:
219 + out["images"] = imgs[:60]
220 + desc = du.ld_description(html)
221 + if desc:
222 + out["description"] = desc
223 + text = du.flatten(html)
224 + det: dict = {}
225 + for label in _DETAIL_LABELS:
226 + m = re.search(re.escape(label) + r"\b\s*\|\s*([^|]{1,55})", text)
227 + if m:
228 + val = m.group(1).strip(" |,")
229 + if val and 1 <= len(val) <= 55 and val.lower() != label.lower():
230 + det[label] = val
231 + if det:
232 + out.setdefault("details", {}).update(det)
233 + return out
modified louka/connectors/base.py +75 −1
@@ -2,10 +2,12 @@
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/base.py : classe de base des connecteurs + backends de fetch
5 −# (requests direct, ou Firecrawl pour les sites JavaScript)
5 +# (requests direct, Firecrawl ou Scrapfly pour les sites
6 +# JavaScript / derrière anti-bot)
6 7 # -----------------------------------------------------------------------------
7 8 from __future__ import annotations
8 9
10 +import json
9 11 import os
10 12 import time
11 13
@@ -18,6 +20,7 @@ USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
18 20 "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)")
19 21
20 22 FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
23 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
21 24
22 25
23 26 class BaseConnector:
@@ -50,6 +53,16 @@ class BaseConnector:
50 53 resp.raise_for_status()
51 54 return resp
52 55
56 + def post(self, url: str, **kw) -> requests.Response:
57 + """POST direct avec throttling poli (APIs de recherche internes)."""
58 + wait = self.request_delay - (time.time() - self._last_request)
59 + if wait > 0:
60 + time.sleep(wait)
61 + resp = self.session.post(url, timeout=self.timeout, **kw)
62 + self._last_request = time.time()
63 + resp.raise_for_status()
64 + return resp
65 +
53 66 def get_rendered(self, url: str) -> str:
54 67 """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.
55 68
@@ -69,6 +82,67 @@ class BaseConnector:
69 82 data = resp.json()
70 83 return (data.get("data") or {}).get("html", "")
71 84
85 + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
86 + rendering_wait: int = 0, country: str = "ca",
87 + wait_for_selector: str | None = None,
88 + js_scenario: list | str | None = None,
89 + proxy_pool: str | None = None, headers: dict | None = None,
90 + method: str = "GET", body: str | None = None) -> dict:
91 + """Appel Scrapfly complet — retourne le dict `result` (content, status_code…).
92 +
93 + - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (encodée base64)
94 + pour charger les listes virtualisées (BoldTrail/kvCORE, etc.).
95 + - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif).
96 + - `headers`/`method`/`body` : pour REJOUER une API JSON interne via ASP.
97 + """
98 + import base64
99 + key = os.environ.get("SCRAPFLY_KEY")
100 + if not key:
101 + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
102 + params: dict = {"key": key, "url": url, "country": country}
103 + if asp:
104 + params["asp"] = "true"
105 + if render_js:
106 + params["render_js"] = "true"
107 + if rendering_wait:
108 + params["rendering_wait"] = rendering_wait
109 + if wait_for_selector:
110 + params["wait_for_selector"] = wait_for_selector
111 + if proxy_pool:
112 + params["proxy_pool"] = proxy_pool
113 + if js_scenario is not None:
114 + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)
115 + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()
116 + if headers:
117 + for k, v in headers.items():
118 + params[f"headers[{k}]"] = v
119 + wait = self.request_delay - (time.time() - self._last_request)
120 + if wait > 0:
121 + time.sleep(wait)
122 + if method.upper() == "POST":
123 + resp = requests.post(SCRAPFLY_API, params=params,
124 + data=(body or ""), timeout=180)
125 + else:
126 + resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
127 + self._last_request = time.time()
128 + try:
129 + return resp.json().get("result") or {}
130 + except ValueError:
131 + return {}
132 +
133 + def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
134 + rendering_wait: int = 0, country: str = "ca",
135 + wait_for_selector: str | None = None,
136 + js_scenario: list | str | None = None,
137 + proxy_pool: str | None = None) -> str:
138 + """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne
139 + le HTML (result.content) ou "" en cas d'échec ASP."""
140 + return self.scrapfly(url, render_js=render_js, asp=asp,
141 + rendering_wait=rendering_wait, country=country,
142 + wait_for_selector=wait_for_selector,
143 + js_scenario=js_scenario, proxy_pool=proxy_pool
144 + ).get("content") or ""
145 +
72 146 def detail(self, external_id: str, key: str, fetch_fn) -> dict:
73 147 """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
74 148 l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé.
added louka/connectors/kijiji.py +247 −0
@@ -0,0 +1,247 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces de LOCATION
5 +# UNIQUEMENT les catégories logement À LOUER, UNIQUEMENT le Québec (l9001) :
6 +# c37 appartements & condos à louer · c36 chambres à louer & colocation
7 +# Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre,
8 +# prix, GPS, adresse, date de disponibilité et attributs (meublé, animaux,
9 +# inclusions…) — aucune API privée nécessaire. Adapté du connecteur « à
10 +# vendre » d'Immo-Ka (agent-courtage/immoka).
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import json
15 +import os
16 +import re
17 +
18 +from ..schema import Listing
19 +from .base import BaseConnector
20 +
21 +from . import _detailutil as du
22 +
23 +BASE = "https://www.kijiji.ca"
24 +# (code catégorie, segment d'URL, type d'unité par défaut)
25 +CATEGORIES = [
26 + (37, "b-appartement-condo", ""), # unité déduite des attributs
27 + (36, "b-chambre-a-louer-colocataire", "Chambre"),
28 +]
29 +MAX_PAGES = int(os.environ.get("LOUKA_KIJIJI_MAX_PAGES", "100"))
30 +DETAIL_LIMIT = int(os.environ.get("LOUKA_KIJIJI_DETAIL_LIMIT", "400"))
31 +
32 +# les annonces vivent sous des clés Apollo « RealEstateListing:123 » (c37)
33 +# ou « StandardListing:123 » (c36)
34 +_LISTING_KEY_RE = re.compile(r"^(?:RealEstate|Standard)Listing:\d+$")
35 +_NEXT_RE = re.compile(
36 + r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)
37 +
38 +# attributs binaires -> commodité affichable (uniquement si la valeur est vraie)
39 +_AMENITY_LABELS = {
40 + "heat": "Chauffage inclus", "hydro": "Électricité incluse",
41 + "water": "Eau incluse", "internet": "Internet inclus",
42 + "cabletv": "Câble/télé inclus", "laundryinunit": "Laveuse/sécheuse dans l'unité",
43 + "laundryinbuilding": "Buanderie dans l'immeuble", "dishwasher": "Lave-vaisselle",
44 + "fridgefreezer": "Réfrigérateur/congélateur", "airconditioning": "Air climatisé",
45 + "balcony": "Balcon", "elevator": "Ascenseur", "gym": "Salle d'entraînement",
46 + "pool": "Piscine", "concierge": "Concierge",
47 + "twentyfourhoursecurity": "Sécurité 24 h", "storagelocker": "Espace de rangement",
48 + "bicycleparking": "Stationnement pour vélo", "yard": "Cour",
49 + "wheelchairaccessible": "Accessible en fauteuil roulant",
50 +}
51 +_UNIT_TYPES = {
52 + "apartment": "Appartement", "condo": "Condo",
53 + "basement-apartment": "Appartement au sous-sol", "house": "Maison",
54 + "townhouse": "Maison de ville", "duplex-triplex": "Duplex/Triplex",
55 +}
56 +_AGREEMENTS = {"one-year": "Bail de 1 an", "month-to-month": "Au mois",
57 + "not-available": ""}
58 +# villes fréquentes sans accents dans les adresses Kijiji
59 +_CITY_FIX = {
60 + "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis",
61 + "trois-rivieres": "Trois-Rivières", "riviere-des-prairies": "Montréal",
62 + "ville de montreal": "Montréal", "ville de quebec": "Québec",
63 +}
64 +
65 +
66 +def _fix_city(raw: str) -> str:
67 + key = (raw or "").strip().lower()
68 + if key in _CITY_FIX:
69 + return _CITY_FIX[key]
70 + return " ".join(w.capitalize() for w in key.replace("-", " ").split())
71 +
72 +
73 +def _attr_value(a: dict) -> str:
74 + """Première valeur d'un attribut Apollo (canonique, sinon affichée)."""
75 + for k in ("canonicalValues", "values"):
76 + vals = a.get(k) or []
77 + if vals:
78 + return str(vals[0])
79 + return ""
80 +
81 +
82 +def _apply_attrs(attrs: list[dict], out: dict) -> None:
83 + """Interprète les attributs Kijiji (mêmes clés en liste et en fiche)."""
84 + amenities = out.setdefault("amenities", [])
85 + details = out.setdefault("details", {})
86 + for a in attrs or []:
87 + cn = a.get("canonicalName") or ""
88 + val = _attr_value(a)
89 + if not val:
90 + continue
91 + if cn in _AMENITY_LABELS:
92 + if val == "1":
93 + amenities.append(_AMENITY_LABELS[cn])
94 + elif cn == "furnished":
95 + out["furnished"] = val == "1"
96 + elif cn == "petsallowed":
97 + out["pets"] = "oui" if val == "1" else "non"
98 + elif cn == "numberbedrooms":
99 + out["bedrooms"] = val # '0' = studio, sinon nb de chambres
100 + elif cn == "numberbathrooms":
101 + try: # canonique en dixièmes : '15' = 1.5
102 + n = int(val) / 10
103 + details["Salles de bain"] = f"{n:g}"
104 + except ValueError:
105 + pass
106 + elif cn in ("areainfeet", "sizesqft"):
107 + m = re.search(r"[\d.]+", val.replace(",", ""))
108 + if m and float(m.group(0)) > 0:
109 + out["area_sqft"] = float(m.group(0))
110 + elif cn == "dateavailable":
111 + m = re.match(r"(\d{4}-\d{2}-\d{2})", val)
112 + if m:
113 + out["availability_date"] = m.group(1)
114 + elif cn == "unittype":
115 + details["Type d'unité"] = _UNIT_TYPES.get(val, val)
116 + elif cn == "agreementtype":
117 + bail = _AGREEMENTS.get(val, val)
118 + if bail:
119 + details["Bail"] = bail
120 + elif cn == "numberparkingspots" and val.isdigit() and int(val) > 0:
121 + amenities.append(f"Stationnement ({val})")
122 +
123 +
124 +def _parse_kijiji_detail(html: str) -> dict:
125 + """Fiche Kijiji : description complète, attributs, galerie haute résolution."""
126 + m = _NEXT_RE.search(html)
127 + if not m:
128 + return {}
129 + try:
130 + data = json.loads(m.group(1))
131 + except ValueError:
132 + return {}
133 + apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {})
134 + it = next((v for k, v in apollo.items()
135 + if _LISTING_KEY_RE.match(k) and isinstance(v, dict)
136 + and v.get("description")), None)
137 + if not it:
138 + return {}
139 + out: dict = {}
140 + if it.get("description"):
141 + out["description"] = str(it["description"]).strip()[:6000]
142 + imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u)
143 + for u in it.get("imageUrls") or []]
144 + if imgs:
145 + out["images"] = imgs
146 + _apply_attrs((it.get("attributes") or {}).get("all") or [], out)
147 + out.pop("bedrooms", None) # le type d'unité est déjà fixé au niveau liste
148 + loc = it.get("location") or {}
149 + addr = (loc.get("address") or "").replace(", Canada", "")
150 + if re.match(r"\s*\d", addr):
151 + out["address"] = addr.split(",")[0]
152 + return out
153 +
154 +
155 +class KijijiConnector(BaseConnector):
156 + source_id = "kijiji"
157 + request_delay = 1.2
158 +
159 + def _page(self, seg: str, cat: int, page: int) -> list[dict]:
160 + """Annonces (Apollo state) d'une page de catégorie."""
161 + path = (f"{seg}/quebec/c{cat}l9001" if page == 1
162 + else f"{seg}/quebec/page-{page}/c{cat}l9001")
163 + html = self.get(f"{BASE}/{path}").text
164 + m = _NEXT_RE.search(html)
165 + data = json.loads(m.group(1)) if m else {}
166 + apollo = (data.get("props", {}).get("pageProps", {})
167 + .get("__APOLLO_STATE__", {}))
168 + return [v for k, v in apollo.items()
169 + if _LISTING_KEY_RE.match(k) and isinstance(v, dict)]
170 +
171 + def _to_listing(self, it: dict, unit_default: str) -> Listing | None:
172 + lid = str(it.get("id") or "")
173 + url = it.get("url") or ""
174 + if not lid or not url:
175 + return None
176 + price = None
177 + pr = it.get("price") or {}
178 + if isinstance(pr, dict) and pr.get("amount"):
179 + price = round(pr["amount"] / 100.0, 0) # cents → $/mois
180 + loc = it.get("location") or {}
181 + coords = loc.get("coordinates") or {}
182 + address = (loc.get("address") or "").replace(", Canada", "")
183 + parts = [p.strip() for p in address.split(",") if p.strip()]
184 + street = parts[0] if parts and re.match(r"\s*\d", parts[0]) else ""
185 + city = _fix_city(parts[1] if street and len(parts) > 1
186 + else (loc.get("name") or (parts[0] if parts else "")))
187 + images = [re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u)
188 + for u in it.get("imageUrls") or []]
189 + extra: dict = {}
190 + _apply_attrs((it.get("attributes") or {}).get("all") or [], extra)
191 + unit_type = unit_default
192 + beds = extra.pop("bedrooms", None)
193 + if not unit_type and beds:
194 + try: # Kijiji code parfois « 2.5 » (2 ch. + den)
195 + n = int(float(beds))
196 + except ValueError:
197 + n = 0
198 + unit_type = "Studio" if n == 0 else f"{n} chambres" # → n+2 ½
199 + lst = Listing(
200 + source=self.source_id,
201 + external_id=lid,
202 + url=url,
203 + title=it.get("title") or "",
204 + address=street,
205 + city=city,
206 + unit_type=unit_type,
207 + price=price,
208 + price_label=(f"{price:,.0f} $/mois".replace(",", " ")
209 + if price else ""),
210 + description=(it.get("description") or "")[:2000],
211 + amenities=extra.get("amenities") or [],
212 + details=extra.get("details") or {},
213 + images=images,
214 + lat=coords.get("latitude"),
215 + lng=coords.get("longitude"),
216 + )
217 + if extra.get("availability_date"):
218 + lst.availability_date = extra["availability_date"]
219 + lst.availability = f"Libre le {extra['availability_date']}"
220 + if extra.get("furnished") is not None:
221 + lst.furnished = extra["furnished"]
222 + if extra.get("pets"):
223 + lst.pets = extra["pets"]
224 + if extra.get("area_sqft"):
225 + lst.area_sqft = extra["area_sqft"]
226 + return lst
227 +
228 + def fetch(self) -> list[Listing]:
229 + out: dict[str, Listing] = {}
230 + for cat, seg, unit_default in CATEGORIES:
231 + for page in range(1, MAX_PAGES + 1):
232 + try:
233 + items = self._page(seg, cat, page)
234 + except Exception:
235 + break
236 + fresh = 0
237 + for it in items:
238 + lst = self._to_listing(it, unit_default)
239 + if lst is not None and lst.uid not in out:
240 + out[lst.uid] = lst
241 + fresh += 1
242 + # plus rien de neuf (page de fin remplie de topAds répétés)
243 + if fresh == 0 or len(items) < 10:
244 + break
245 + listings = list(out.values())
246 + du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1")
247 + return listings
added louka/connectors/lespac.py +173 −0
@@ -0,0 +1,173 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lespac.py : LesPAC (lespac.com) — petites annonces du Québec
5 +# UNIQUEMENT l'immobilier LOCATION résidentielle :
6 +# b457 logements · b458 chambres & colocation · b460 résidences pour aînés
7 +# Les pages « /montreal/…_b{cat}g17567k{page}R2.jsa » embarquent
8 +# `var searchResponse = {…}` côté serveur : 20-24 annonces/page + totalPages.
9 +# Le site force une ville d'ancrage (g17567 = Montréal) mais le jeu de
10 +# résultats couvre TOUTE la province, simplement trié par distance — vérifié :
11 +# l'ancre Québec (g15398) donne le même totalPages. Adapté du connecteur
12 +# « achat-vente » d'Immo-Ka (agent-courtage/immoka).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import html as _html
17 +import json
18 +import os
19 +import re
20 +
21 +from ..schema import Listing
22 +from .base import BaseConnector
23 +
24 +from . import _detailutil as du
25 +
26 +BASE = "https://www.lespac.com"
27 +ANCHOR = "montreal" # ville d'ancrage (tri par distance)
28 +ANCHOR_GEO = "g17567"
29 +DETAIL_LIMIT = int(os.environ.get("LOUKA_LESPAC_DETAIL_LIMIT", "400"))
30 +CATEGORIES = [
31 + (457, "immobilier-location-logements", ""),
32 + (458, "immobilier-location-colocataires", "Chambre"),
33 + (460, "immobilier-location-residences-pour-aines", ""),
34 +]
35 +_RE_RESP = re.compile(r"var searchResponse = (\{.*?\});\s*[\r\n]", re.S)
36 +_RE_DEMI = re.compile(r"(\d+)\s*(?:½|1/2)")
37 +
38 +
39 +def _clean(s: str) -> str:
40 + return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip()
41 +
42 +
43 +class LesPacConnector(BaseConnector):
44 + source_id = "lespac"
45 + request_delay = 0.9
46 +
47 + def _search_page(self, slug: str, cat: int, page: int) -> dict | None:
48 + url = f"{BASE}/{ANCHOR}/{slug}_b{cat}{ANCHOR_GEO}k{page}R2.jsa"
49 + m = _RE_RESP.search(self.get(url).text)
50 + return json.loads(m.group(1)) if m else None
51 +
52 + def _to_listing(self, r: dict, unit_default: str) -> Listing | None:
53 + lid = str(r.get("listingPublicId") or "")
54 + url = (r.get("listingDisplayUrl") or "").split("?")[0]
55 + if not lid or not url:
56 + return None
57 + title = r.get("title") or ""
58 + unit_type = unit_default
59 + if not unit_type:
60 + m = _RE_DEMI.search(title)
61 + if m:
62 + unit_type = f"{m.group(1)}½"
63 + # prix : LesPAC affiche « Par mois » / « Par semaine » dans priceNote
64 + note = (r.get("priceNote") or "").strip()
65 + price, price_label = None, ""
66 + if r.get("price") is not None and ("mois" in note.lower() or not note):
67 + price = float(r["price"])
68 + price_label = f"{r.get('priceLabel') or ''} par mois".strip()
69 + details: dict = {}
70 + if note and "mois" not in note.lower():
71 + details["Fréquence du loyer"] = note # ex. « Par semaine »
72 + track = r.get("searchPageTrackingInfo") or {}
73 + region = ((track.get("listing-region-code") or {}).get("value") or "")
74 + if region:
75 + details["Région"] = region
76 + # « Montréal / Centre-Sud / Centre-Ville » → ville + secteur ;
77 + # « Autres Provinces » est souvent une erreur de classement LesPAC :
78 + # on laisse vide, la vraie ville viendra de l'adresse en fiche détail
79 + city_label = r.get("cityLabel") or ""
80 + seg = [s.strip() for s in city_label.split("/") if s.strip()]
81 + city = seg[0] if seg else ""
82 + sector = " / ".join(seg[1:]) if len(seg) > 1 else ""
83 + if city.lower() == "autres provinces":
84 + city = ""
85 + images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery")
86 + for i in r.get("images") or [] if i.get("formattableImageUrl")]
87 + return Listing(
88 + source=self.source_id,
89 + external_id=lid,
90 + url=url,
91 + title=title,
92 + city=city,
93 + sector=sector,
94 + unit_type=unit_type,
95 + price=price,
96 + price_label=price_label,
97 + description=(r.get("description") or "")[:2000],
98 + details=details,
99 + images=images,
100 + )
101 +
102 + def fetch(self) -> list[Listing]:
103 + out: dict[str, Listing] = {}
104 + for cat, slug, unit_default in CATEGORIES:
105 + page, total_pages = 1, 1
106 + while page <= total_pages:
107 + try:
108 + d = self._search_page(slug, cat, page)
109 + except Exception:
110 + break
111 + if not d:
112 + break
113 + total_pages = min(int(d.get("totalPages") or 1), 400)
114 + fresh = 0
115 + for r in d.get("searchResults") or []:
116 + lst = self._to_listing(r, unit_default)
117 + if lst is not None and lst.uid not in out:
118 + out[lst.uid] = lst
119 + fresh += 1
120 + if fresh == 0 and page > 1: # fin réelle malgré totalPages
121 + break
122 + page += 1
123 + listings = list(out.values())
124 + du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1")
125 + return listings
126 +
127 +
128 +def _parse_lespac_detail(html: str) -> dict:
129 + """Fiche LesPAC : description complète, adresse civique, caractéristiques
130 + (boîte « Caractéristiques » : <p><span>Label</span><span>Valeur</span></p>,
131 + valeurs parfois enrobées de liens) et galerie pleine taille (basephoto)."""
132 + out: dict = {}
133 + md = re.search(r'<div id="description">.*?<p class="title">Description</p>\s*<p>(.*?)</p>',
134 + html, re.S | re.I)
135 + if md:
136 + desc = _clean(md.group(1))
137 + if desc:
138 + out["description"] = desc[:6000]
139 + amenities, details = [], {}
140 + mbox = re.search(r'>Caractéristiques</p>\s*<div class="box">(.*?)</div>',
141 + html, re.S)
142 + if mbox:
143 + for lm, vm in re.findall(r"<p><span>(.*?)</span>\s*<span>(.*?)</span>",
144 + mbox.group(1), re.S):
145 + label, value = _clean(lm), _clean(vm)
146 + if not label or not value:
147 + continue
148 + amenities.append(f"{label} : {value}")
149 + details[label] = value
150 + if label == "Adresse":
151 + # « 439 Rue Bellevue, Municipalité de Saint-Donat, QC, Canada »
152 + parts = [p.strip() for p in value.split(",") if p.strip()]
153 + if parts and re.match(r"\s*\d", parts[0]):
154 + out["address"] = parts[0]
155 + if len(parts) >= 3 and parts[-2].upper() == "QC":
156 + ville = re.sub(r"^(?:Municipalité|Ville|Paroisse|Canton)"
157 + r"(?:\s+de\s+|\s+d[e']\s*)?", "", parts[-3]).strip()
158 + if ville:
159 + out["city"] = ville
160 + elif label == "Nombre de pièces":
161 + out["unit_type"] = value # ex. « 4 1/2 pièces »
162 + if amenities:
163 + out["amenities"] = amenities
164 + if details:
165 + out["details"] = details
166 + imgs, seen = [], set()
167 + for u in re.findall(r'https://cdn\.lespac\.com/binary/basephoto/\d+\.jpg', html):
168 + if u not in seen:
169 + seen.add(u)
170 + imgs.append(u)
171 + if imgs:
172 + out["images"] = imgs
173 + return out
added louka/connectors/m_immobilier.py +195 −0
@@ -0,0 +1,195 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/m_immobilier.py : M Immobilier (mimmobilier.com) — LOCATIONS
5 +# Agence indépendante de prestige (Grand Montréal), inscriptions Centris.
6 +# La liste /properties est rendue serveur et paginée ; le formulaire expose
7 +# un filtre statut « à louer » (radio name="CODE_STATUT" value="AL") qu'on
8 +# rejoue en query string : /properties?CODE_STATUT=AL&page=N. Chaque carte
9 +# « cardProperty » porte l'URL (avec no Centris), le badge « à louer », la
10 +# ville, l'adresse, chambres, salles de bains, superficie (PC) et le loyer
11 +# « X $ / M ». Adapté du connecteur « à vendre » d'Immo-Ka
12 +# (agent-courtage/immoka), qui excluait justement ces cartes-là.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import html as _html
17 +import os
18 +import re
19 +
20 +from ..schema import Listing
21 +from .base import BaseConnector
22 +
23 +from . import _detailutil as du
24 +
25 +BASE = "https://www.mimmobilier.com"
26 +LISTING_URL = f"{BASE}/properties?CODE_STATUT=AL" # AL = à louer (serveur)
27 +CARD_SPLIT = "cardProperty col-span-12"
28 +HREF_RE = re.compile(r'href="(/properties/[^"]+/(\d+))"')
29 +IMG_RE = re.compile(r'(/images/centris-slideshow/\d+-\d+-\d+\.(?:jpg|jpeg|png|webp))', re.I)
30 +# loyer « 1 500.0 $ / M » (M = mois) — le montant précède le $
31 +_RENT_RE = re.compile(r"([\d\s ,]+(?:\.\d+)?)\s*\$\s*/\s*M", re.I)
32 +_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
33 +# l'agence loue aussi des locaux commerciaux au mois (bureaux, entrepôts…) sans
34 +# ligne « chambres » : on ne garde ces cartes-là que si l'extrait de description
35 +# contient un marqueur clairement résidentiel (studio, bachelor, logement…)
36 +_RESIDENTIAL_RE = re.compile(
37 + r"\b(studios?|bachelor|logements?|appartements?|condos?|chambres?|"
38 + r"r[ée]sidentiel(?:le)?s?|unit[ée]s?|laveuse|maison|complexe)\b", re.I)
39 +MAX_PAGES = int(os.environ.get("LOUKA_MIMMO_MAX_PAGES", "10"))
40 +DETAIL_LIMIT = int(os.environ.get("LOUKA_MIMMO_DETAIL_LIMIT", "100"))
41 +
42 +
43 +class MImmobilierConnector(BaseConnector):
44 + source_id = "m_immobilier"
45 + request_delay = 0.6
46 +
47 + def fetch(self) -> list[Listing]:
48 + out: dict[str, Listing] = {}
49 + for page in range(1, MAX_PAGES + 1):
50 + url = LISTING_URL if page == 1 else f"{LISTING_URL}&page={page}"
51 + try:
52 + html = self.get(url).text
53 + except Exception:
54 + break
55 + cards = html.split(CARD_SPLIT)[1:]
56 + fresh = 0
57 + for card in cards:
58 + lst = self._parse_card(CARD_SPLIT + card[:6000])
59 + if lst and lst.uid not in out:
60 + out[lst.uid] = lst
61 + fresh += 1
62 + # dernière page atteinte (vide ou uniquement des doublons)
63 + if not cards or fresh == 0:
64 + break
65 + listings = list(out.values())
66 + # fiche détail : galerie Centris complète + description + adresse pleine
67 + du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v1")
68 + return listings
69 +
70 + def _parse_card(self, card: str) -> Listing | None:
71 + m = HREF_RE.search(card)
72 + if not m:
73 + return None
74 + url = BASE + m.group(1)
75 + external_id = m.group(2)
76 +
77 + images = []
78 + for im in IMG_RE.findall(card):
79 + full = BASE + im
80 + if full not in images:
81 + images.append(full)
82 +
83 + # texte du carton, ligne par ligne : badge statut, ville, adresse,
84 + # extrait de description, puis paires libellé/valeur
85 + text = _html.unescape(re.sub(r"<[^>]+>", "\n", card))
86 + lines = [l.strip() for l in text.splitlines() if l.strip()]
87 + try:
88 + k = next(i for i, l in enumerate(lines)
89 + if l.lower().startswith(("à louer", "a louer")))
90 + except StopIteration:
91 + return None # vente/vendu/loué : pas une location active
92 + city_raw = lines[k + 1] if k + 1 < len(lines) else ""
93 + address = lines[k + 2] if k + 2 < len(lines) else ""
94 + snippet = lines[k + 3] if k + 3 < len(lines) else ""
95 +
96 + price_label = _after(lines, "prix")
97 + if "/ m" not in price_label.lower():
98 + return None # garde-fou : loyer mensuel attendu
99 + price = None
100 + pm = _RENT_RE.search(price_label)
101 + if pm:
102 + try:
103 + price = float(pm.group(1).replace(" ", "").replace(" ", "")
104 + .replace(",", ""))
105 + except ValueError:
106 + price = None
107 +
108 + beds = _int(_after(lines, "chambres")) # « 3 + 1 » -> 4
109 + baths = _after(lines, "salles de bains")
110 + sqft = _int(_after(lines, "pc")) # PC = pieds carrés
111 +
112 + # sans chambres, c'est souvent un local commercial loué au mois :
113 + # on exige un marqueur résidentiel dans l'extrait (studio, bachelor…)
114 + unit_type = f"{beds} chambres" if beds else ""
115 + if beds is None:
116 + if not _RESIDENTIAL_RE.search(snippet):
117 + return None
118 + if re.search(r"\b(studios?|bachelor)\b", snippet, re.I):
119 + unit_type = "Studio"
120 +
121 + # ville « Montréal (Le Plateau-Mont-Royal) » -> ville + secteur ;
122 + # les cartes tronquent à ~30 caractères (« Notr... ») -> on nettoie
123 + city, sector = city_raw, ""
124 + cm = re.match(r"^(.*?)\s*\(([^)]*)\)?\s*$", city_raw)
125 + if cm and cm.group(2):
126 + city, sector = cm.group(1).strip(), cm.group(2).strip()
127 + city = city.rstrip(".").rstrip()
128 + sector = re.sub(r"\.{2,}$", "", sector).rstrip("/ -")
129 +
130 + # adresse parfois tronquée (« 9017 Rue Jean-Baptiste-Gauthie... ») :
131 + # on la laisse vide et le H1 de la fiche détail la complète
132 + truncated = address.endswith("...")
133 + title = f"{re.sub(r'[.]{3,}$', '', address)}, {city}".strip(", ")
134 +
135 + lst = Listing(
136 + source=self.source_id,
137 + external_id=external_id,
138 + url=url,
139 + title=title,
140 + address="" if truncated else address,
141 + sector=sector,
142 + city=city,
143 + unit_type=unit_type,
144 + price=price,
145 + price_label=price_label,
146 + description=re.sub(r"[.]{3,}$", "", snippet),
147 + details={"Agence": "M Immobilier", "No Centris": external_id},
148 + images=images,
149 + )
150 + if baths:
151 + lst.details["Salles de bain"] = baths.replace(" + ", "+")
152 + if sqft:
153 + lst.area_sqft = float(sqft)
154 + return lst
155 +
156 +
157 +def parse_m_detail(html: str) -> dict:
158 + """Galerie Centris complète + description (JSON-LD) + adresse pleine (H1)."""
159 + out: dict = {}
160 + imgs = []
161 + seen = set()
162 + for im in IMG_RE.findall(html):
163 + full = BASE + im
164 + if full not in seen:
165 + seen.add(full)
166 + imgs.append(full)
167 + if imgs:
168 + out["images"] = imgs
169 + desc = du.ld_description(html)
170 + if desc:
171 + out["description"] = desc
172 + m = _H1_RE.search(html)
173 + if m:
174 + h1 = _html.unescape(re.sub(r"<[^>]+>", " ", m.group(1))).strip()
175 + if h1 and re.match(r"\d", h1):
176 + out["address"] = h1 # complète les adresses tronquées
177 + # pas de table de caractéristiques exploitable sur la fiche (seule la table
178 + # des pièces existe, qui produirait du bruit) : galerie + description suffisent
179 + return out
180 +
181 +
182 +def _after(lines: list[str], label: str) -> str:
183 + lab = label.lower()
184 + for i, l in enumerate(lines):
185 + if l.lower() == lab and i + 1 < len(lines):
186 + return lines[i + 1]
187 + return ""
188 +
189 +
190 +def _int(s: str):
191 + if not s:
192 + return None
193 + # « 3 + 1 » -> 4 (chambres principales + sous-sol)
194 + nums = [int(x) for x in re.findall(r"\d+", s)]
195 + return sum(nums) if nums else None
added louka/connectors/proprio_direct.py +196 −0
@@ -0,0 +1,196 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/proprio_direct.py : Proprio Direct (propriodirect.com) — LOCATION
5 +# L'inventaire (alimenté par le feed DDF de CREA) est servi par une API JSON
6 +# interne : POST https://propriodirect.com/fr/api/searchListings
7 +# Corps { "from": <offset>, "filter": {"rentOrSale": "rent"} } — le filtre
8 +# côté API fonctionne (~260 locations vs ~3200 annonces au total) ; 30
9 +# résultats/page, champ `total`. Chaque item porte forRent/forSale/sold,
10 +# le loyer en cents (rentPriceInCents, rentPriceFrequency), adresse, ville,
11 +# région, chambres, sdb, GPS, tags et galerie photos. On ne garde que le
12 +# volet RÉSIDENTIEL (tag « residential ») : les locaux commerciaux et
13 +# industriels (loyer en $/pi²/an) sont écartés. Adapté du connecteur
14 +# « à vendre » d'Immo-Ka (agent-courtage/immoka).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import os
19 +import re
20 +
21 +from ..schema import Listing
22 +from .base import BaseConnector
23 +
24 +from . import _detailutil as du
25 +
26 +SITE = "https://propriodirect.com"
27 +SEARCH_URL = f"{SITE}/fr/api/searchListings"
28 +PAGE_SIZE = 30
29 +MAX_PAGES = 60 # garde-fou (~260 locations = 9 pages)
30 +DETAIL_LIMIT = int(os.environ.get("LOUKA_PD_DETAIL_LIMIT", "400"))
31 +
32 +# tags source (feed DDF) -> caractéristiques lisibles
33 +_TAG_LABELS = {
34 + "car_garage": "Garage", "car_parking": "Stationnement", "car_foyer": "Foyer",
35 + "car_piscine": "Piscine", "car_climatisation": "Climatisation",
36 + "car_sous_sol": "Sous-sol", "car_bord_eau": "Bord de l'eau",
37 + "prox_park": "Parc à proximité", "prox_ecole_primaire": "École primaire",
38 + "prox_ecole_secondaire": "École secondaire", "prox_transport": "Transport en commun",
39 + "prox_garderie": "Garderie", "prox_piste_cyclable": "Piste cyclable",
40 + "prox_hopital": "Hôpital", "prox_autoroute": "Autoroute",
41 + "new_price": "Nouveau prix",
42 +}
43 +
44 +
45 +class ProprioDirectConnector(BaseConnector):
46 + source_id = "proprio_direct"
47 + request_delay = 0.4
48 +
49 + def _headers(self) -> dict:
50 + return {
51 + "Accept": "application/json",
52 + "Content-Type": "application/json",
53 + "Origin": SITE,
54 + "Referer": f"{SITE}/propriete-a-louer/",
55 + }
56 +
57 + def fetch(self) -> list[Listing]:
58 + out: list[Listing] = []
59 + seen: set[str] = set()
60 + offset = 0
61 + for _ in range(MAX_PAGES):
62 + try:
63 + data = self.post(SEARCH_URL, headers=self._headers(),
64 + json={"from": offset,
65 + "includeGeoJson": False,
66 + "filter": {"rentOrSale": "rent"}}).json()
67 + except Exception:
68 + break
69 + items = data.get("listings", []) if isinstance(data, dict) else []
70 + if not items:
71 + break
72 + for it in items:
73 + lst = self._to_listing(it)
74 + if lst and lst.uid not in seen:
75 + seen.add(lst.uid)
76 + out.append(lst)
77 + total = data.get("total") or 0
78 + offset += PAGE_SIZE
79 + if offset >= total:
80 + break
81 + # fiche détail : description (la galerie est déjà complète via l'API)
82 + du.enrich(self, out, DETAIL_LIMIT, _parse_pd_detail, key="v3")
83 + return out
84 +
85 + def _to_listing(self, it: dict) -> Listing | None:
86 + rid = it.get("id")
87 + tags = it.get("tags") or []
88 + # location résidentielle seulement (ceinture + bretelles : le filtre
89 + # API renvoie déjà uniquement des locations, on revalide chaque item)
90 + if (not rid or it.get("sold") or not it.get("forRent")
91 + or "residential" not in tags):
92 + return None
93 + # loyer mensuel : rentPriceInCents (fallback priceInCents si le feed
94 + # met le loyer dans le prix principal) — on écarte les baux annuels
95 + # au pi² qui se seraient glissés dans le volet résidentiel
96 + freq = it.get("rentPriceFrequency") or "monthly"
97 + if freq != "monthly":
98 + return None
99 + cents = it.get("rentPriceInCents") or it.get("priceInCents")
100 + price = (cents / 100.0) if isinstance(cents, (int, float)) and cents > 0 else None
101 +
102 + slug = it.get("slugURLFr") or it.get("slugURL") or ""
103 + url = f"{SITE}{slug}" if slug.startswith("/") else (slug or SITE)
104 + geo = it.get("geoLocation") or {}
105 +
106 + # type d'unité : nb de chambres (normalisé n+2 ½ par finalize()) ;
107 + # le genre Loft/Studio (LS) sans chambre est un studio
108 + genre = (it.get("genre") or "").upper()
109 + rooms = _pos_int(it.get("numberOfRooms"))
110 + unit_type = f"{rooms} chambres" if rooms else ("Studio" if genre == "LS" else "")
111 +
112 + details: dict = {"Courtier": "Proprio Direct"}
113 + if it.get("genreName"):
114 + details["Type"] = it["genreName"]
115 + if it.get("regionName"):
116 + details["Région"] = it["regionName"]
117 + baths = _pos_int(it.get("numberOfBathrooms"))
118 + if baths:
119 + details["Salles de bain"] = str(baths)
120 + if it.get("postalCode"):
121 + details["Code postal"] = it["postalCode"]
122 +
123 + return Listing(
124 + source=self.source_id,
125 + external_id=str(rid),
126 + url=url,
127 + title=it.get("addressLine", ""),
128 + address=it.get("addressLine", ""),
129 + sector=it.get("neighbourhood", "") or "",
130 + city=it.get("cityName", ""),
131 + unit_type=unit_type,
132 + price=price,
133 + price_label=it.get("rentPriceText", "") or "",
134 + amenities=[_TAG_LABELS[t] for t in tags if t in _TAG_LABELS],
135 + details=details,
136 + images=list(it.get("photosOriginal") or it.get("photosSmall") or []),
137 + lat=geo.get("lat"),
138 + lng=geo.get("lon"),
139 + )
140 +
141 +
142 +# libellés des « Points saillants » d'une fiche Proprio Direct — la page les
143 +# rend en liste « libellé | valeur » stricte (contrairement aux fiches Centris,
144 +# d'où un parseur dédié plutôt que du.centris_details qui appariait de travers)
145 +_PD_LABELS = [
146 + "Année de construction", "Superficie habitable", "Niveau",
147 + "Équipement disponible", "Zonage", "Mode de chauffage",
148 + "Énergie pour le chauffage", "Approvisionnement en eau",
149 + "Système d'égouts", "Restrictions/Permissions",
150 + "Garage", "Piscine", "Foyer-Poêle", "Meublé", "Animaux",
151 + "Disponibilité", "Date d'emménagement", "Bail",
152 +]
153 +_PD_SECTIONS = {"points saillants", "détails", "pièces", "inclus", "exclus",
154 + "style de vie", "description"}
155 +
156 +
157 +def _pd_details(text: str) -> dict:
158 + """Paires « libellé | valeur » des Points saillants (ordre strict)."""
159 + stop = {l.lower() for l in _PD_LABELS} | _PD_SECTIONS
160 + out: dict = {}
161 + for label in _PD_LABELS:
162 + m = re.search(re.escape(label) + r"\s*\|\s*([^|]{1,90})", text)
163 + if m:
164 + val = m.group(1).strip(" |")
165 + if val and val.lower() not in stop:
166 + out[label] = val
167 + return out
168 +
169 +
170 +def _parse_pd_detail(html: str) -> dict:
171 + """Fiche Proprio Direct : description + points saillants
172 + (photos déjà fournies par l'API)."""
173 + out: dict = {}
174 + desc = du.ld_description(html)
175 + # le JSON-LD des fiches sans texte retombe sur la description « méta »
176 + # générique (« … | No. Inscription 12345678 | Proprio Direct ») — on l'écarte
177 + if desc and "No. Inscription" not in desc:
178 + out["description"] = desc
179 + det = _pd_details(du.flatten(html))
180 + if det:
181 + out.setdefault("details", {}).update(det)
182 + # « Animaux permis sous conditions » / « Animaux non admis »…
183 + restr = det.get("Restrictions/Permissions", "").lower()
184 + if "animaux" in restr:
185 + out["pets"] = ("conditions" if "condition" in restr
186 + else "non" if ("non" in restr or "pas" in restr)
187 + else "oui")
188 + return out
189 +
190 +
191 +def _pos_int(v):
192 + try:
193 + n = int(v)
194 + return n if n > 0 else None
195 + except (TypeError, ValueError):
196 + return None
added louka/connectors/remax_quebec.py +406 −0
@@ -0,0 +1,406 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/remax_quebec.py : RE/MAX Québec (remax-quebec.com) — LOCATIONS
5 +# Le site interroge un index Meilisearch public (search-only key exposée dans
6 +# la config de la page). L'index « inscriptions » couvre toute la province et
7 +# mélange ventes et locations : on ne garde QUE les slugs « -a-louer » /
8 +# « -for-rent » (et on écarte les Loué/Vendu que l'index conserve).
9 +# Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc
10 +# par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on
11 +# dédoublonne par numéro d'inscription. Adapté du connecteur « à vendre »
12 +# d'Immo-Ka (agent-courtage/immoka).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import html as _html
17 +import os
18 +import re
19 +
20 +from ..normalize import parse_area_sqft, parse_price
21 +from ..schema import Listing
22 +from .base import BaseConnector
23 +
24 +from . import _detailutil as du
25 +
26 +SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search"
27 +# Clé de recherche (search-only) exposée publiquement dans la config du site.
28 +SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152"
29 +SITE = "https://www.remax-quebec.com"
30 +
31 +# Territoires postaux du Québec : préfixes G, H et J.
32 +FSA_LETTERS = ("G", "H", "J")
33 +# annonces conclues que l'index conserve encore quelque temps
34 +GONE_TOKENS = ("loué", "loue", "rented", "vendu", "sold")
35 +
36 +# Enrichissement page détail (photos + description + specs) : plafonné par
37 +# exécution pour garder chaque sync borné. Les fiches en cache sont réutilisées
38 +# gratis ; le parc se complète sur plusieurs cycles.
39 +DETAIL_LIMIT = int(os.environ.get("LOUKA_REMAX_DETAIL_LIMIT", "400"))
40 +
41 +# slug « condo-a-louer-montreal/... » -> type affichable. Les appartements /
42 +# condos / plex restent sans unit_type figé : la fiche détail fournit le nombre
43 +# de chambres (converti en n+2½ par la normalisation lou-ka).
44 +_TYPE_SLUG = {
45 + "maison": "Maison", "house": "Maison",
46 + "chalet": "Chalet", "cottage": "Chalet",
47 + "loft": "Loft",
48 + "condo": "Condo", "appartement": "Appartement", "apartment": "Appartement",
49 + "plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex",
50 +}
51 +# ce qui n'est pas un logement (lou-ka = résidentiel locatif uniquement)
52 +_EXCLUDE_SLUG = ("commercial", "commerciale", "terrain", "land", "industriel",
53 + "bureau", "ferme", "fermette")
54 +_RENT_SLUG_RE = re.compile(r"-(?:a-louer|for-rent)\b")
55 +
56 +
57 +class RemaxQuebecConnector(BaseConnector):
58 + source_id = "remax_quebec"
59 + request_delay = 0.15 # API JSON rapide ; on reste poli
60 +
61 + def fetch(self) -> list[Listing]:
62 + by_id: dict[int, dict] = {}
63 + for fsa in self._fsa_candidates():
64 + for h in self._search(fsa):
65 + nid = h.get("no_inscription")
66 + if nid is not None:
67 + by_id[nid] = h # dédoublonnage inter-shards
68 + listings = []
69 + for h in by_id.values():
70 + lst = self._to_listing(h)
71 + if lst is not None:
72 + listings.append(lst)
73 + du.enrich(self, listings, DETAIL_LIMIT, parse_remax_detail, key="v1")
74 + for lst in listings:
75 + _unit_from_bedrooms(lst)
76 + return listings
77 +
78 + # -- sharding --------------------------------------------------------------
79 + @staticmethod
80 + def _fsa_candidates():
81 + for letter in FSA_LETTERS:
82 + for digit in "0123456789":
83 + for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
84 + yield f"{letter}{digit}{last}"
85 +
86 + def _search(self, q: str) -> list[dict]:
87 + try:
88 + resp = self.post(
89 + SEARCH_URL,
90 + headers={"Authorization": f"Bearer {SEARCH_KEY}",
91 + "Content-Type": "application/json"},
92 + json={"q": q, "limit": 1000},
93 + )
94 + except Exception:
95 + return []
96 + data = resp.json()
97 + return data.get("hits", []) if isinstance(data, dict) else []
98 +
99 + # -- mapping ---------------------------------------------------------------
100 + def _to_listing(self, h: dict) -> Listing | None:
101 + nid = h.get("no_inscription")
102 + if nid is None:
103 + return None
104 + slug = (h.get("slug") or {}).get("fr", "") or ""
105 + slug_en = (h.get("slug") or {}).get("en", "") or ""
106 + # UNIQUEMENT les locations (l'index mélange ventes et locations) ;
107 + # l'URL de la fiche exige le slug français
108 + if not slug or not (_RENT_SLUG_RE.search(slug) or _RENT_SLUG_RE.search(slug_en)):
109 + return None
110 + price_label = (h.get("display_price") or {}).get("fr", "") or ""
111 + # exclure les logements déjà loués / retirés (l'index les conserve)
112 + if any(tok in price_label.lower() for tok in GONE_TOKENS):
113 + return None
114 + prop_type, region = _from_slug(slug)
115 + if prop_type is None: # commercial/terrain : pas un logement
116 + return None
117 +
118 + url = f"{SITE}/fr/proprietes/{slug}"
119 + full_addr = (h.get("full_address") or {}).get("fr", "") or ""
120 + address, sector, city = _split_address(full_addr)
121 +
122 + details: dict = {"MLS": str(nid)}
123 + if prop_type:
124 + details["Type"] = prop_type
125 + if region:
126 + details["Région"] = region
127 + return Listing(
128 + source=self.source_id,
129 + external_id=str(nid),
130 + url=url,
131 + title=address or full_addr,
132 + address=address,
133 + sector=sector,
134 + city=city,
135 + # appartement/condo/plex : la fiche détail donne les chambres
136 + unit_type=prop_type if prop_type in ("Maison", "Chalet", "Loft") else "",
137 + price=parse_price(price_label), # « 2 690$ par mois » -> 2690.0
138 + price_label=price_label,
139 + details=details,
140 + )
141 +
142 +
143 +def _unit_from_bedrooms(lst: Listing) -> None:
144 + """Condo/appartement : « Chambres » de la fiche détail -> unit_type
145 + « n chambres » (la normalisation lou-ka convertit en n+2½)."""
146 + if lst.unit_type not in ("", "Condo", "Appartement"):
147 + return
148 + m = re.match(r"\d+", str(lst.details.get("Chambres", "")))
149 + if not m:
150 + if not lst.unit_type:
151 + lst.unit_type = lst.details.get("Type", "")
152 + return
153 + n = int(m.group(0))
154 + lst.unit_type = "Studio" if n == 0 else f"{n} chambres"
155 +
156 +
157 +# ---------------------------------------------------------------------------
158 +# Analyse de l'adresse et du slug
159 +# ---------------------------------------------------------------------------
160 +
161 +_PAREN_RE = re.compile(r"\(([^)]*)\)")
162 +_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)
163 +
164 +
165 +def _split_address(full: str) -> tuple[str, str, str]:
166 + """« 9561 Boul. Perras, Montréal (Rivière-des-Prairies/…) (RDP), H1E4C4 »
167 + -> (adresse, secteur, ville)."""
168 + if not full:
169 + return "", "", ""
170 + parts = [p.strip() for p in full.split(",")]
171 + # retirer le code postal final
172 + if parts and _POSTAL_RE.search(parts[-1]):
173 + parts = parts[:-1]
174 + address = parts[0] if parts else ""
175 + city = sector = ""
176 + if len(parts) >= 2:
177 + muni = parts[1]
178 + parens = _PAREN_RE.findall(muni)
179 + city = _PAREN_RE.sub("", muni).strip()
180 + if parens:
181 + sector = parens[-1].strip()
182 + # secteur supplémentaire dans les champs suivants (avant le postal)
183 + for extra in parts[2:]:
184 + e = _PAREN_RE.sub("", extra).strip() or extra.strip()
185 + if e and not sector:
186 + sector = e
187 + return address, sector, city
188 +
189 +
190 +def _from_slug(slug: str) -> tuple[str | None, str]:
191 + """slug « condo-a-louer-montreal/9561-boul-perras-…-20357732 »
192 + -> (type affichable, région). Type None = pas un logement (commercial…)."""
193 + if not slug:
194 + return "", ""
195 + head = slug.split("/", 1)[0] # condo-a-louer-montreal
196 + if any(re.search(rf"\b{x}", head) for x in _EXCLUDE_SLUG):
197 + return None, ""
198 + prop_type = ""
199 + for key, canon in _TYPE_SLUG.items():
200 + if re.search(rf"\b{key}", head):
201 + prop_type = canon
202 + break
203 + # région = ce qui suit « -a-louer-/-for-rent- »
204 + m = re.search(r"(?:a-louer|for-rent)-(.+)$", head)
205 + region = m.group(1).replace("-", " ").title() if m else ""
206 + return prop_type, region
207 +
208 +
209 +# ---------------------------------------------------------------------------
210 +# Page détail : photos + description + caractéristiques + courtier
211 +# ---------------------------------------------------------------------------
212 +
213 +# Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium,
214 +# _small…) selon le lazy-load ; on les capte toutes et on les normalise en
215 +# pleine résolution, dédoublonnées par nom de fichier.
216 +_IMG_RE = re.compile(
217 + r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
218 + re.I)
219 +_IMG_SIZE_RE = re.compile(r"/www_[a-z]+/", re.I)
220 +_COORD_RE = re.compile(r"query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)")
221 +# en tête de fiche, la valeur précède le libellé : « 2 | Chambres », « 1 | Salle de bain »
222 +_BED_RE = re.compile(r"(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambres?\b", re.I)
223 +_BATH_RE = re.compile(r"(\d+)\s*\|\s*Salles? de bain", re.I)
224 +_WATER_RE = re.compile(r"(\d+)\s*\|\s*Salles? d'eau", re.I)
225 +_QSTAT_RE = re.compile(
226 + r"quick-stat-text[^>]*>\s*<span[^>]*>\s*(.*?)\s*</span>\s*<span[^>]*>\s*(.*?)\s*</span>", re.S)
227 +_INCL_RE = re.compile(
228 + r"inclusions-exclusions-section(.*?)(?:</section>|realtor-section|financial-section)", re.S)
229 +_ROOM_RE = re.compile(r"rooms-details-section__vertical-table[^>]*>(.*?)</div>\s*</div>", re.S)
230 +
231 +# Sur les fiches LOCATION, le tableau « Particularités » est en ordre
232 +# libellé | valeur (l'inverse des fiches vente) : on privilégie donc cet ordre
233 +# et du.centris_details ne sert que de repli.
234 +_RENT_LABELS = [
235 + "Type de propriété", "Genre de propriété", "Style de bâtiment",
236 + "Année de construction", "Superficie habitable", "Superficie du terrain",
237 + "Nombre de pièces", "Nombre d'unités", "Stationnement (total)", "Garage",
238 + "Mode de chauffage", "Système de chauffage", "Énergie pour le chauffage",
239 + "Approvisionnement en eau", "Système d'égouts", "Piscine", "Déménagement",
240 + "Date d'emménagement", "Disponibilité", "Bail", "Durée du bail", "Meublé",
241 + "Animaux", "Inclus dans le loyer", "Cuisine",
242 +]
243 +
244 +
245 +# une « valeur » qui est en fait un titre de section = extraction décalée
246 +_SECTION_VAL_RE = re.compile(
247 + r"^(?:Particularit[ée]s|Caract[ée]ristiques|Inclusions|Exclusions|Addenda)",
248 + re.I)
249 +
250 +
251 +def _labels_first(text: str, labels: list[str]) -> dict:
252 + """Extraction « libellé | valeur » (ordre des fiches location RE/MAX)."""
253 + out: dict = {}
254 + for label in labels:
255 + boundary = r"\b" if label[-1].isalnum() else ""
256 + m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text)
257 + if m:
258 + val = m.group(1).strip(" |")
259 + if (val and 1 <= len(val) <= 55 and val.lower() != label.lower()
260 + and val not in labels and not _SECTION_VAL_RE.match(val)):
261 + out[label] = val
262 + return out
263 +
264 +
265 +def _drop_section_values(details: dict) -> None:
266 + """Purge les valeurs qui sont des titres de section (repli valeur|libellé
267 + de du.centris_details décalé sur les fiches en ordre libellé|valeur)."""
268 + for k in [k for k, v in details.items()
269 + if isinstance(v, str) and _SECTION_VAL_RE.match(v)]:
270 + del details[k]
271 +
272 +
273 +def _ld_agents(html: str):
274 + """Nœuds RealEstateAgent (dans l'ordre du document), même imbriqués."""
275 + for n in du.ld_nodes(html):
276 + queue = [n]
277 + while queue:
278 + cur = queue.pop(0)
279 + if isinstance(cur, dict):
280 + if cur.get("@type") == "RealEstateAgent" and cur.get("name"):
281 + yield cur
282 + queue.extend(cur.values())
283 + elif isinstance(cur, list):
284 + queue.extend(cur)
285 +
286 +
287 +def parse_remax_detail(html: str) -> dict:
288 + """Fiche RE/MAX location : description JSON-LD, galerie pleine résolution,
289 + GPS, caractéristiques Centris, pièces, inclusions, courtier/agence."""
290 + out: dict = {}
291 + details: dict = {}
292 +
293 + # description via JSON-LD RealEstateListing
294 + desc = du.ld_description(html)
295 + if desc:
296 + out["description"] = desc[:6000]
297 +
298 + # photos : toutes tailles -> pleine résolution, dédoublonnées par fichier
299 + seen, images = set(), []
300 + for u in _IMG_RE.findall(html):
301 + full = _IMG_SIZE_RE.sub("/www_full/", u)
302 + fn = full.rsplit("/", 1)[-1]
303 + if fn not in seen and "nophoto" not in full:
304 + seen.add(fn)
305 + images.append(full)
306 + if images:
307 + out["images"] = images
308 +
309 + # coordonnées GPS (lien Google Maps)
310 + m = _COORD_RE.search(html)
311 + if m:
312 + lat, lng = float(m.group(1)), float(m.group(2))
313 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
314 + out["lat"], out["lng"] = lat, lng
315 +
316 + # courtier + agence via JSON-LD RealEstateAgent (souvent imbriqué)
317 + for n in _ld_agents(html):
318 + details.setdefault("Courtier", str(n["name"]).strip().title())
319 + if n.get("telephone"):
320 + details.setdefault("Téléphone", str(n["telephone"]).strip())
321 + org = n.get("parentOrganization") or {}
322 + org_name = org.get("name", "") if isinstance(org, dict) else ""
323 + if org_name:
324 + agence = str(org_name).strip().title() \
325 + .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.")
326 + details.setdefault("Agence", agence)
327 +
328 + text = du.flatten(html)
329 +
330 + # chambres / salles de bain (en tête : « 2 | Chambres », « 1 | Salle de bain »)
331 + m = _BED_RE.search(text)
332 + if m:
333 + details["Chambres"] = m.group(1)
334 + m = _BATH_RE.search(text)
335 + if m:
336 + details["Salles de bain"] = m.group(1)
337 + m = _WATER_RE.search(text)
338 + if m:
339 + details["Salles d'eau"] = m.group(1)
340 +
341 + # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur
342 + details.update(du.centris_details(text))
343 + details.update(_labels_first(text, _RENT_LABELS))
344 + _drop_section_values(details)
345 + # « Cuisine » happé depuis le tableau des pièces (dimensions/niveau) : retirer
346 + if re.search(r"\d\s*[xX]\s*\d|Niveau|^\d+$", str(details.get("Cuisine", ""))):
347 + details.pop("Cuisine", None)
348 +
349 + # quick stats (Nb de pièces, Superficie habitable…)
350 + for label, val in _QSTAT_RE.findall(html):
351 + label = _html.unescape(re.sub(r"\s+", " ", label)).strip()
352 + val = _html.unescape(re.sub(r"\s+", " ", val)).strip()
353 + if label and val and label not in details:
354 + details[label] = val
355 +
356 + # pièces avec dimensions/niveau/revêtement
357 + rooms = []
358 + for blk in _ROOM_RE.findall(html):
359 + rt = _html.unescape(re.sub(r"<[^>]+>", " ", blk))
360 + rt = re.sub(r"\s+", " ", rt).strip()
361 + name = re.split(r"Niveau\s*:", rt)[0].strip()
362 + niveau = re.search(r"Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)", rt)
363 + dim = re.search(r"Dimensions\s*:\s*([0-9\'\".,X x×]+)", rt)
364 + rev = re.search(r"Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)", rt)
365 + if name:
366 + rooms.append({
367 + "nom": name[:40],
368 + "niveau": (niveau.group(1).strip() if niveau else ""),
369 + "dimensions": (dim.group(1).strip() if dim else ""),
370 + "revetement": (rev.group(1).strip() if rev else ""),
371 + })
372 + if rooms:
373 + details["pieces"] = rooms[:20]
374 +
375 + # inclusions / exclusions -> commodités affichables
376 + mi = _INCL_RE.search(html)
377 + feats = []
378 + if mi:
379 + blk = _html.unescape(re.sub(r"<[^>]+>", "\n", mi.group(1)))
380 + for line in blk.split("\n"):
381 + line = line.strip(" \t•-")
382 + if 3 <= len(line) <= 120 and not line.lower().startswith(
383 + ("inclusion", "exclusion")):
384 + feats.append(line)
385 + if feats:
386 + out["amenities"] = feats[:25]
387 +
388 + # superficie habitable (quick stat ou tableau) -> pi²
389 + if details.get("Superficie habitable"):
390 + out["area_sqft"] = parse_area_sqft(details["Superficie habitable"])
391 + # date d'emménagement / déménagement -> disponibilité
392 + for k in ("Date d'emménagement", "Déménagement", "Disponibilité"):
393 + if details.get(k):
394 + out["availability"] = details[k]
395 + break
396 + # chambres -> type d'unité (les fiches liste condo/appartement sont vides)
397 + if details.get("Chambres"):
398 + try:
399 + n = int(details["Chambres"])
400 + out["unit_type"] = "Studio" if n == 0 else f"{n} chambres"
401 + except ValueError:
402 + pass
403 +
404 + if details:
405 + out["details"] = details
406 + return out
added louka/connectors/royal_lepage.py +191 −0
@@ -0,0 +1,191 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/royal_lepage.py : Royal LePage (royallepage.ca) — LOCATIONS Québec
5 +#
6 +# Le portail rend ses résultats côté serveur. La recherche par ville est
7 +# filtrable : /fr/search/homes/{page}/?search_str=...&prov_code=QC&city_name=
8 +# ...&lat=..&lng=..&search_type=city + « transactionType=LEASE » (trouvé dans
9 +# le formulaire caché du site) renvoie les propriétés À LOUER (46/page,
10 +# prix « 1 850.00 $ /mois » sur la carte). Chaque recherche plafonne à
11 +# ~1 250 résultats ; on SHARD par points d'ancrage répartis dans la province
12 +# et on dédoublonne par numéro MLS (dans l'URL /.../mls{no}/). Aucune
13 +# dépendance Firecrawl : requêtes directes. Adapté du connecteur « à vendre »
14 +# d'Immo-Ka (agent-courtage/immoka).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import html as _html
19 +import os
20 +import re
21 +
22 +from .base import BaseConnector
23 +from . import _detailutil as du
24 +from ..schema import Listing
25 +
26 +BASE = "https://www.royallepage.ca/fr/search/homes"
27 +MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250)
28 +# Le nom du BUREAU (« Royal LePage Humania »…) n'est pas sur la carte : il vient
29 +# de la fiche détail. Enrichissement plafonné + cache (se remplit au fil des cycles).
30 +DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "300"))
31 +_OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)',
32 + re.S | re.I)
33 +
34 +# Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches
35 +# + dédoublonnage MLS couvre la province.
36 +ANCHORS = [
37 + ("Montréal", 45.5089, -73.5542), ("Québec", 46.8139, -71.2080),
38 + ("Gatineau", 45.4765, -75.7013), ("Sherbrooke", 45.4040, -71.8929),
39 + ("Trois-Rivières", 46.3432, -72.5432), ("Saguenay", 48.4280, -71.0680),
40 + ("Laval", 45.6066, -73.7124), ("Longueuil", 45.5312, -73.5185),
41 + ("Drummondville", 45.8833, -72.4833), ("Saint-Jérôme", 45.7803, -74.0038),
42 + ("Granby", 45.4001, -72.7300), ("Rimouski", 48.4489, -68.5236),
43 + ("Rouyn-Noranda", 48.2360, -79.0230), ("Val-d'Or", 48.0975, -77.7972),
44 + ("Gaspé", 48.8330, -64.4870), ("Sept-Îles", 50.2001, -66.3821),
45 + ("Baie-Comeau", 49.2166, -68.1487), ("Joliette", 46.0167, -73.4500),
46 + ("Saint-Hyacinthe", 45.6300, -72.9569), ("Sorel-Tracy", 46.0500, -73.1200),
47 + ("Victoriaville", 46.0533, -71.9667), ("Mont-Laurier", 46.5500, -75.5000),
48 + ("Alma", 48.5500, -71.6500), ("Matane", 48.8500, -67.5300),
49 + ("Saint-Sauveur", 45.8939, -74.2051), ("Baie-Saint-Paul", 47.4400, -70.5000),
50 + ("Salaberry-de-Valleyfield", 45.2500, -74.1300), ("Thetford Mines", 46.1000, -71.3000),
51 +]
52 +
53 +_CARD_RE = re.compile(r'card--listing-card js-listing js-property-details(.*?)'
54 + r'(?=card--listing-card js-listing js-property-details|</main>|$)', re.S)
55 +_URL_RE = re.compile(r'/fr/property/quebec/[^"\']+?/mls[a-z0-9]+/', re.I)
56 +_MLS_RE = re.compile(r'/mls([a-z0-9]+)/', re.I)
57 +_KEY_RE = re.compile(r'data-rlp-key="(-?\d+\.\d+)\.(-?\d+\.\d+)"')
58 +_PHOTO_RE = re.compile(r'(//rlp\.jumplisting\.com/photos/[^"\']+)')
59 +_CAC_RE = re.compile(r'(\d+)\s*CAC', re.I)
60 +_SDB_RE = re.compile(r'(\d+)(?:\+\d+)?\s*SDB', re.I)
61 +# loyer sur la carte, aplatie en « 1 850.00 | $ | /mois » (une carte peut aussi
62 +# afficher un prix de VENTE : on ne retient que le montant suivi de /mois)
63 +_RENT_RE = re.compile(r'(\d[\d\s ,.]*?)\s*\|?\s*\$\s*\|?\s*/\s*mois', re.I)
64 +_TYPE_RE = re.compile(r'\|\s*(Maison|Condo|Copropriété|Appartement|Duplex|Triplex|'
65 + r'Plex|Chalet|Loft|Studio|Maison de ville|Jumelé)\s*\|', re.I)
66 +
67 +
68 +def _parse_rent(raw: str) -> float | None:
69 + """« 1 850.00 » / « 1,850 » -> 1850.0 ($/mois)."""
70 + s = raw.replace(" ", "").replace("\xa0", "").replace(" ", "")
71 + if re.search(r",\d{3}\b", s):
72 + s = s.replace(",", "")
73 + else:
74 + s = s.replace(",", ".")
75 + try:
76 + v = float(s)
77 + except ValueError:
78 + return None
79 + return v if 100 <= v <= 50000 else None
80 +
81 +
82 +class RoyalLepageConnector(BaseConnector):
83 + source_id = "royal_lepage"
84 + request_delay = 0.4
85 +
86 + def fetch(self) -> list[Listing]:
87 + by_id: dict[str, Listing] = {}
88 + for name, lat, lng in ANCHORS:
89 + self._search_anchor(name, lat, lng, by_id)
90 + listings = list(by_id.values())
91 + # bureau (sous-agence) via la fiche détail — plafonné, cache accumulé
92 + du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1")
93 + return listings
94 +
95 + def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None:
96 + empty_streak = 0
97 + for page in range(1, MAX_PAGES + 1):
98 + params = {
99 + "search_str": f"{name}, QC", "prov_code": "QC",
100 + "city_name": name, "lat": lat, "lng": lng, "search_type": "city",
101 + "transactionType": "LEASE",
102 + }
103 + try:
104 + html = self.get(f"{BASE}/{page}/", params=params,
105 + headers={"X-Requested-With": "XMLHttpRequest"}).text
106 + except Exception:
107 + break
108 + new = 0
109 + for lst in self._parse_cards(html):
110 + if lst.external_id not in out:
111 + out[lst.external_id] = lst
112 + new += 1
113 + if new == 0:
114 + empty_streak += 1
115 + if empty_streak >= 2: # cap serveur atteint (page répétée)
116 + break
117 + else:
118 + empty_streak = 0
119 +
120 + def _parse_cards(self, html: str) -> list[Listing]:
121 + out = []
122 + for seg in _CARD_RE.findall(html):
123 + murl = _URL_RE.search(seg)
124 + if not murl:
125 + continue
126 + url = "https://www.royallepage.ca" + murl.group(0)
127 + mls = _MLS_RE.search(url)
128 + ext = mls.group(1) if mls else url
129 + # /fr/property/quebec/{ville}/{adresse}/{id}/mls{no}/
130 + parts = murl.group(0).strip("/").split("/")
131 + city = parts[3].replace("-", " ").title() if len(parts) > 3 else ""
132 + address = parts[4].replace("-", " ").title() if len(parts) > 4 else ""
133 + sector = ""
134 + # « Montreal Ville Marie » → ville + arrondissement
135 + for big, accent in (("Montreal", "Montréal"), ("Quebec", "Québec"),
136 + ("Levis", "Lévis"), ("Gatineau", "Gatineau"),
137 + ("Longueuil", "Longueuil"), ("Laval", "Laval")):
138 + if city == big:
139 + city = accent
140 + break
141 + if city.startswith(big + " "):
142 + sector = city[len(big) + 1:]
143 + city = accent
144 + break
145 + flat = _html.unescape(re.sub(r"<[^>]+>", " | ", seg))
146 + flat = re.sub(r"(\s*\|\s*)+", " | ", re.sub(r"\s+", " ", flat))
147 + rent = _RENT_RE.search(flat)
148 + price = _parse_rent(rent.group(1)) if rent else None
149 + if price is None:
150 + continue # pas de « $ /mois » = pas une location
151 + beds = _CAC_RE.search(flat)
152 + baths = _SDB_RE.search(flat)
153 + typ = _TYPE_RE.search(flat)
154 + key = _KEY_RE.search(seg)
155 + photo = _PHOTO_RE.search(seg)
156 + images = []
157 + if photo:
158 + images = ["https:" + photo.group(1).replace("_0_med", "_0_lg")]
159 + unit_type = ""
160 + if typ and typ.group(1).lower() in ("loft", "studio"):
161 + unit_type = typ.group(1).title()
162 + elif beds:
163 + unit_type = f"{beds.group(1)} chambres" # normalisé en n+2 ½
164 + details = {"Courtier": "Royal LePage"}
165 + if mls:
166 + details["MLS"] = mls.group(1)
167 + if typ:
168 + details["Type"] = typ.group(1)
169 + if baths:
170 + details["Salles de bain"] = baths.group(1)
171 + out.append(Listing(
172 + source=self.source_id, external_id=str(ext), url=url,
173 + title=address, address=address, city=city, sector=sector,
174 + unit_type=unit_type,
175 + price=price,
176 + price_label=f"{price:,.0f} $/mois".replace(",", " "),
177 + details=details,
178 + images=images,
179 + lat=float(key.group(1)) if key else None,
180 + lng=float(key.group(2)) if key else None,
181 + ))
182 + return out
183 +
184 +
185 +def _parse_rlp_office(html: str) -> dict:
186 + """Extrait le bureau/brokerage (sous-agence) de la fiche détail."""
187 + m = _OFFICE_RE.search(html)
188 + if not m:
189 + return {}
190 + office = _html.unescape(m.group(1)).strip()
191 + return {"details": {"Agence": office}} if office else {}
added louka/connectors/sutton.py +431 −0
@@ -0,0 +1,431 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sutton.py : Groupe Sutton Québec (suttonquebec.com) — LOCATIONS
5 +# Couverture exhaustive via le sitemap : l'index /sitemap.cfm pointe vers des
6 +# sitemaps d'inscriptions paginés où chaque URL porte noInscription (numéro
7 +# Centris) et typeInscription (1 = à vendre, 2 = À LOUER — on ne garde que
8 +# les 2). Le slug donne type/adresse/ville approximatifs ; la fiche détail
9 +# fournit le loyer (« 1 950 $ / mois »), la galerie immo.vrtx.co, la
10 +# description, les pièces et l'adresse exacte (H1). Adapté du connecteur
11 +# « à vendre » d'Immo-Ka (agent-courtage/immoka).
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import html as _html
16 +import os
17 +import re
18 +
19 +from ..normalize import parse_area_sqft, strip_accents
20 +from ..schema import Listing
21 +from .base import BaseConnector
22 +
23 +from . import _detailutil as du
24 +
25 +BASE = "https://www.suttonquebec.com"
26 +DETAIL_LIMIT = int(os.environ.get("LOUKA_SUTTON_DETAIL_LIMIT", "400"))
27 +
28 +# Sitemap : l'index /sitemap.cfm pointe vers des sitemaps d'inscriptions.
29 +SITEMAP_INDEX = f"{BASE}/sitemap.cfm"
30 +SITEMAP_MAX_PAGES = 40 # garde-fou (≈13 pages réelles × ~1000)
31 +_SM_SUB_RE = re.compile(r'sitemap_inscription\.cfm\?[^<"\s]+', re.I)
32 +_SM_LOC_RE = re.compile(
33 + r'https://www\.suttonquebec\.com/fr/inscription/([^<"?]+)\.html\?'
34 + r'noInscription=(\d+)&(?:amp;)?typeInscription=(\d)', re.I)
35 +
36 +# toutes les photos de la fiche (normalisées pic450, dédoublonnées id-index)
37 +_DET_IMG_RE = re.compile(
38 + r'https://immo\.vrtx\.co/pic\d+/(\d+(?:-\d+)?)\.(?:jpg|jpeg|png|webp)', re.I)
39 +_DESC_RE = re.compile(
40 + r'Description de la propri[ée]t[ée](?:\s+[àa]\s+(?:vendre|louer))?[^A-Za-z0-9]*(.*?)'
41 + r'(?:Caract[ée]ristiques|Pi[èe]ce\s+Dimension|Inclusions|Exclusions|Addenda|$)',
42 + re.I | re.S)
43 +# loyer : montant suivi de « / mois » / « par mois » (le prix demandé précède
44 +# les loyers des « prochaines propriétés » en bas de page)
45 +_RENT_RE = re.compile(r"(?<!\d)(\d{1,2}\s\d{3}|\d{3,5})\s*\$\s*(?:/\s*|par\s+)mois",
46 + re.I)
47 +_ROOM_ROW_RE = re.compile(
48 + r'([A-ZÀ-Ÿ][^\d|]{2,38}?)\s+(\d[\d.,]*\s*[xX]\s*\d[\d.,]*)\s*P?\s+'
49 + r'([A-Za-zÀ-ÿ/ -]{3,30}?)\s+(RC|SS|AU|\d[eE]?r?e?)\b')
50 +_AREA_RE = re.compile(r"Aire habitable\s*:?\s*([\d.,]+)\s*(mc|m2|m²|pc|pi)", re.I)
51 +_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
52 +_BROKER_RE = re.compile(
53 + r'divResume courtier[^"]*">.*?class="h4">\s*([^<]{3,60}?)\s*</p>', re.S)
54 +_PHONE_RE = re.compile(r"\d{3}[-\s]\d{3}-\d{4}")
55 +_AGENCY_RE = re.compile(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', re.I)
56 +_PAREN_RE = re.compile(r"\(([^)]*)\)")
57 +_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)
58 +
59 +# types de slug qui ne sont pas des logements (lou-ka = résidentiel locatif)
60 +_EXCLUDE_TYPE_RE = re.compile(
61 + r"commercial|bureau|industriel|terrain|entrep[ôo]t|local\b", re.I)
62 +
63 +# Sur les fiches LOCATION, le tableau des caractéristiques est en ordre
64 +# libellé | valeur : on privilégie cet ordre, du.centris_details en repli.
65 +_RENT_LABELS = [
66 + "Type de propriété", "Genre de propriété", "Style de bâtiment",
67 + "Année de construction", "Aire habitable", "Superficie habitable",
68 + "Superficie du terrain", "Nombre de pièces", "Stationnement",
69 + "Garage", "Mode de chauffage", "Système de chauffage",
70 + "Énergie pour le chauffage", "Équipement disponible", "Zonage",
71 + "Approvisionnement en eau", "Système d'égouts", "Piscine",
72 + "Déménagement", "Date d'emménagement", "Disponibilité", "Bail",
73 + "Durée du bail", "Meublé", "Animaux", "Inclus dans le loyer", "Cuisine",
74 +]
75 +
76 +
77 +class SuttonConnector(BaseConnector):
78 + source_id = "sutton"
79 + request_delay = 0.5
80 +
81 + def fetch(self) -> list[Listing]:
82 + by_id: dict[str, Listing] = {}
83 + # 1) couverture exhaustive via le sitemap (type/adresse depuis le slug)
84 + for slug, centris, url in self._sitemap_listings():
85 + if centris in by_id:
86 + continue
87 + ptype, addr, city = _from_slug(slug)
88 + if ptype is None: # commercial/bureau : pas un logement
89 + continue
90 + details = {"MLS": centris}
91 + if ptype:
92 + details["Type"] = ptype
93 + by_id[centris] = Listing(
94 + source=self.source_id, external_id=centris, url=url,
95 + title=addr or _deslug(slug), address=addr, city=city,
96 + unit_type="Maison" if ptype.startswith("Maison") else "",
97 + details=details,
98 + )
99 + listings = list(by_id.values())
100 + # fiche détail : loyer, chambres, galerie complète, description,
101 + # pièces, inclusions, adresse exacte — plafonné, cache accumulé entre
102 + # cycles (tout le parc finit enrichi au fil des syncs).
103 + du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v1")
104 + for lst in listings:
105 + # adresse exacte du H1 de la fiche : remplace l'approx. du slug
106 + for det_key, attr in (("__adresse", "address"), ("__ville", "city"),
107 + ("__secteur", "sector")):
108 + val = lst.details.pop(det_key, "")
109 + if val:
110 + setattr(lst, attr, val)
111 + if lst.address:
112 + lst.title = lst.address
113 + _unit_from_bedrooms(lst)
114 + return listings
115 +
116 + def _sitemap_listings(self):
117 + """Itère (slug, centris, url) de toutes les inscriptions À LOUER."""
118 + try:
119 + index = self.get(SITEMAP_INDEX).text
120 + except Exception:
121 + return
122 + subs = []
123 + for s in _SM_SUB_RE.findall(index):
124 + s = s.replace("&amp;", "&")
125 + if s not in subs:
126 + subs.append(s)
127 + if not subs: # repli : pagination directe
128 + subs = [f"sitemap_inscription.cfm?page={p}&platine=0"
129 + for p in range(1, 14)] + ["sitemap_inscription.cfm?platine=1"]
130 + seen = set()
131 + for sub in subs[:SITEMAP_MAX_PAGES]:
132 + try:
133 + xml = self.get(f"{BASE}/{sub}").text
134 + except Exception:
135 + continue
136 + for m in _SM_LOC_RE.finditer(xml):
137 + slug, centris, typ = m.group(1), m.group(2), m.group(3)
138 + if typ != "2" or centris in seen: # 2 = à louer uniquement
139 + continue
140 + seen.add(centris)
141 + url = (f"{BASE}/fr/inscription/{slug}.html"
142 + f"?noInscription={centris}&typeInscription=2")
143 + yield slug, centris, url
144 +
145 +
146 +def _unit_from_bedrooms(lst: Listing) -> None:
147 + """« Chambres » de la fiche détail -> unit_type « n chambres »
148 + (la normalisation lou-ka convertit en n+2½)."""
149 + if lst.unit_type:
150 + return
151 + m = re.match(r"\d+", str(lst.details.get("Chambres", "")))
152 + if m:
153 + n = int(m.group(0))
154 + lst.unit_type = "Studio" if n == 0 else f"{n} chambres"
155 + else:
156 + lst.unit_type = lst.details.get("Type", "")
157 +
158 +
159 +def _deslug(s: str) -> str:
160 + return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()
161 +
162 +
163 +# villes/arrondissements composés fréquents dans les slugs Sutton (fin du slug)
164 +_CITY_HINTS = (
165 + "montreal-le-plateau-mont-royal", "montreal-ville-marie", "montreal-rosemont-la-petite-patrie",
166 + "montreal-le-sud-ouest", "montreal-cote-des-neiges-notre-dame-de-grace", "montreal-ahuntsic-cartierville",
167 + "montreal-villeray-saint-michel-parc-extension", "montreal-mercier-hochelaga-maisonneuve",
168 + "montreal-riviere-des-prairies-pointe-aux-trembles", "montreal-verdun-ile-des-soeurs",
169 + "montreal-saint-laurent", "montreal-lasalle", "montreal-outremont", "montreal-anjou",
170 + "montreal-lachine", "montreal-pierrefonds-roxboro", "montreal-saint-leonard",
171 + "montreal-montreal-nord", "montreal-l-ile-bizard-sainte-genevieve",
172 + "longueuil-le-vieux-longueuil", "longueuil-saint-hubert", "longueuil-greenfield-park",
173 + "laval-chomedey", "laval-sainte-dorothee", "laval-sainte-rose", "laval-duvernay",
174 + "laval-fabreville", "laval-vimont", "laval-auteuil", "laval-laval-des-rapides",
175 + "laval-pont-viau", "laval-laval-ouest", "laval-saint-francois", "laval-saint-vincent-de-paul",
176 + "quebec-la-cite-limoilou", "quebec-sainte-foy-sillery-cap-rouge", "quebec-charlesbourg",
177 + "quebec-beauport", "quebec-les-rivieres", "quebec-la-haute-saint-charles",
178 + "sherbrooke-les-nations", "sherbrooke-fleurimont", "saguenay-chicoutimi",
179 + "saguenay-jonquiere", "saguenay-la-baie", "trois-rivieres", "saint-jean-sur-richelieu",
180 + "salaberry-de-valleyfield", "mont-saint-hilaire", "notre-dame-de-l-ile-perrot",
181 + "vaudreuil-dorion", "sainte-adele", "sainte-therese", "saint-jerome", "levis", "quebec",
182 + "laval", "gatineau", "longueuil", "brossard", "sherbrooke", "granby", "magog",
183 + "sorel-tracy", "sainte-catherine", "saint-constant", "chateauguay", "coteau-du-lac",
184 + "saint-zotique", "sainte-anne-de-bellevue", "sainte-anne-des-lacs", "pointe-claire",
185 + "dorval", "beaconsfield", "kirkland", "dollard-des-ormeaux", "cote-saint-luc",
186 + "mont-royal", "westmount", "saint-lambert", "boucherville", "sainte-julie", "varennes",
187 + "saint-bruno-de-montarville", "candiac", "la-prairie", "chambly", "repentigny",
188 + "terrebonne", "mascouche", "blainville", "boisbriand", "rosemere", "mirabel",
189 + "deux-montagnes", "pincourt",
190 +)
191 +# accents perdus dans les slugs (villes fréquentes)
192 +_CITY_ACCENTS = {
193 + "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis",
194 + "trois-rivieres": "Trois-Rivières", "saint-jerome": "Saint-Jérôme",
195 + "sainte-adele": "Sainte-Adèle", "sainte-therese": "Sainte-Thérèse",
196 + "chateauguay": "Châteauguay", "rosemere": "Rosemère", "sainte-julie": "Sainte-Julie",
197 +}
198 +# arrondissement/secteur préfixé par sa grande ville dans le slug
199 +_BOROUGH_PREFIXES = {
200 + "montreal-": "Montréal", "longueuil-": "Longueuil", "laval-": "Laval",
201 + "quebec-": "Québec", "sherbrooke-": "Sherbrooke", "saguenay-": "Saguenay",
202 + "levis-": "Lévis",
203 +}
204 +
205 +
206 +def _hint_city(hint: str) -> str:
207 + """Ville lisible depuis un indice de slug ; les arrondissements composés
208 + « montreal-… » deviennent simplement « Montréal » (le secteur exact vient
209 + de la fiche détail)."""
210 + for prefix, city in _BOROUGH_PREFIXES.items():
211 + if hint.startswith(prefix):
212 + return city
213 + return _CITY_ACCENTS.get(hint, _deslug(hint))
214 +
215 +
216 +def _from_slug(slug: str) -> tuple[str | None, str, str]:
217 + """« appartement-a-louer-3762-rue-st-dominique-montreal-le-plateau-mont-royal »
218 + -> (type, adresse, ville). Best-effort : l'adresse exacte vient de la fiche.
219 + Type None = pas un logement (espace commercial, bureau…)."""
220 + # quelques slugs de location portent le libellé anglais, voire « for-sale »
221 + # par coquille : typeInscription=2 (sitemap) fait foi, pas le slug.
222 + m = re.search(r"^(.*?)-(?:a-louer|for-rent|a-vendre|for-sale)-(.+)$", slug, re.I)
223 + if not m:
224 + return "", _deslug(slug), ""
225 + if _EXCLUDE_TYPE_RE.search(m.group(1)):
226 + return None, "", ""
227 + ptype = _deslug(m.group(1))
228 + rest = m.group(2).lower()
229 + city = ""
230 + for hint in _CITY_HINTS:
231 + if rest.endswith("-" + hint) or rest == hint:
232 + city = _hint_city(hint)
233 + rest = rest[: -len(hint)].rstrip("-")
234 + break
235 + else:
236 + # sinon : dernier token comme ville (approx ; corrigé par la fiche détail)
237 + parts = rest.rsplit("-", 1)
238 + if len(parts) == 2 and not parts[1].isdigit():
239 + city, rest = _hint_city(parts[1]), parts[0]
240 + return ptype, _deslug(rest), city
241 +
242 +
243 +# régions administratives : parfois entre parenthèses dans le H1 après le
244 +# secteur — « Longueuil (Le Vieux-Longueuil) ( Montérégie ) » — à distinguer
245 +_REGIONS = {
246 + "monteregie", "estrie", "laurentides", "lanaudiere", "outaouais",
247 + "mauricie", "bas-saint-laurent", "gaspesie", "gaspesie--iles-de-la-madeleine",
248 + "abitibi-temiscamingue", "cote-nord", "saguenay--lac-saint-jean",
249 + "centre-du-quebec", "chaudiere-appalaches", "capitale-nationale",
250 + "nord-du-quebec",
251 +}
252 +_APT_RE = re.compile(r"(?i)^(?:app|apt|suite|local|unit[ée]|#)")
253 +
254 +
255 +def _split_h1(full: str) -> tuple[str, str, str, str]:
256 + """« 4781 Rue St-Ambroise, app. 201, Montréal (Le Sud-Ouest), H4C2E8 »
257 + -> (adresse, ville, secteur, région)."""
258 + parts = [p.strip() for p in full.split(",") if p.strip()]
259 + if parts and _POSTAL_RE.fullmatch(parts[-1].replace(" ", "")):
260 + parts = parts[:-1]
261 + address = parts[0] if parts else ""
262 + rest = parts[1:]
263 + # « app. 201 » collé après la rue : fait partie de l'adresse
264 + while rest and _APT_RE.match(rest[0]):
265 + address += ", " + rest.pop(0)
266 + city = sector = region = ""
267 + if rest:
268 + muni = rest[0]
269 + city = _PAREN_RE.sub("", muni).strip()
270 + for p in (p.strip() for p in _PAREN_RE.findall(muni)):
271 + if strip_accents(p.lower()).replace(" ", "-") in _REGIONS:
272 + region = p
273 + elif p:
274 + sector = p
275 + return address, city, sector, region
276 +
277 +
278 +def parse_sutton_detail(html: str) -> dict:
279 + """Fiche Sutton location : loyer, galerie complète, description, chambres,
280 + pièces (dimensions), inclusions, caractéristiques, agence, adresse exacte."""
281 + out: dict = {}
282 + details: dict = {}
283 +
284 + # photos : normaliser toute taille -> pic450, dédoublonner par id-index
285 + seen, imgs = set(), []
286 + for m in _DET_IMG_RE.finditer(html):
287 + key = m.group(1)
288 + if key not in seen:
289 + seen.add(key)
290 + imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg")
291 + if imgs:
292 + out["images"] = imgs
293 +
294 + # adresse exacte (H1 : « 3762 Rue St-Dominique, Montréal (…), H2W0A2 »)
295 + mh = _H1_RE.search(html)
296 + if mh:
297 + h1 = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", mh.group(1)))).strip()
298 + address, city, sector, region = _split_h1(h1)
299 + if address and re.match(r"\d", address):
300 + details["__adresse"] = address
301 + if city:
302 + details["__ville"] = city
303 + if sector:
304 + details["__secteur"] = sector
305 + if region:
306 + details["Région"] = region
307 +
308 + text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))
309 +
310 + # loyer : premier « N NNN $ / mois » plausible (100–20 000 $)
311 + for m in _RENT_RE.finditer(text):
312 + try:
313 + val = int(re.sub(r"\s", "", m.group(1)))
314 + except ValueError:
315 + continue
316 + if 100 <= val <= 20000:
317 + out["price"] = float(val)
318 + out["price_label"] = f"{val:,.0f} $/mois".replace(",", " ")
319 + break
320 +
321 + mb = re.search(r"(\d+)\s*chambres?\b(?!\s*[àa]\s*coucher)", text, re.I)
322 + if mb:
323 + details["Chambres"] = mb.group(1)
324 + ms = re.search(r"(\d+)\s*salles?\s*de\s*bain", text, re.I)
325 + if ms:
326 + details["Salles de bain"] = ms.group(1)
327 +
328 + md = _DESC_RE.search(text)
329 + if md:
330 + desc = md.group(1).strip()
331 + if len(desc) > 30:
332 + out["description"] = desc[:4000]
333 +
334 + # aire habitable : « 76.9 MC (828 pi²) » -> pi²
335 + ma = _AREA_RE.search(text)
336 + if ma:
337 + unit = ma.group(2).lower()
338 + raw = f"{ma.group(1)} {'m²' if unit in ('mc', 'm2', 'm²') else 'pi²'}"
339 + area = parse_area_sqft(raw)
340 + if area:
341 + out["area_sqft"] = area
342 +
343 + # pièces : « Nom DimxDim P Revêtement Niveau »
344 + rooms = []
345 + for m in _ROOM_ROW_RE.finditer(text):
346 + nom = m.group(1).strip(" -")
347 + # retirer l'en-tête de tableau qui peut coller au 1er nom
348 + nom = re.sub(r"^.*(?:Niveau|Rev[êe]tement|Dimensions?)\s+", "", nom).strip(" -")
349 + if not nom or nom.lower() in ("pièce", "piece"):
350 + continue
351 + rooms.append({"nom": nom[:40], "dimensions": m.group(2).replace(" ", ""),
352 + "revetement": m.group(3).strip(), "niveau": m.group(4)})
353 + if rooms:
354 + details["pieces"] = rooms[:20]
355 +
356 + # inclusions -> commodités affichables
357 + mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S)
358 + if mi:
359 + inc = mi.group(1).strip()
360 + feats = [s.strip() for s in re.split(r"[,;•]", inc) if 3 <= len(s.strip()) <= 90]
361 + if feats:
362 + out["amenities"] = feats[:20]
363 +
364 + # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur
365 + flat = du.flatten(html)
366 + details.update(du.centris_details(flat))
367 + details.update(_labels_first(flat, _RENT_LABELS))
368 + _drop_section_values(details)
369 + # « Cuisine » happé depuis le tableau des pièces (dimensions) : retirer
370 + if re.search(r"\d\s*[xX]\s*\d", str(details.get("Cuisine", ""))):
371 + details.pop("Cuisine", None)
372 + # « 76.9 MC (828 pi » : l'exposant ² coupe la valeur à l'aplatissement
373 + if str(details.get("Aire habitable", "")).endswith("pi"):
374 + details["Aire habitable"] += "²)"
375 +
376 + # date d'emménagement / déménagement -> disponibilité
377 + for k in ("Date d'emménagement", "Déménagement", "Disponibilité"):
378 + if details.get(k):
379 + out["availability"] = details[k]
380 + break
381 +
382 + # chambres -> type d'unité (rempli si la fiche liste n'en avait pas)
383 + if details.get("Chambres"):
384 + try:
385 + n = int(details["Chambres"])
386 + out["unit_type"] = "Studio" if n == 0 else f"{n} chambres"
387 + except ValueError:
388 + pass
389 +
390 + # courtier + sous-agence (bureau) : carte « divResume courtier »
391 + mc = _BROKER_RE.search(html)
392 + if mc:
393 + details["Courtier"] = _html.unescape(mc.group(1)).strip()
394 + mp = _PHONE_RE.search(html[mc.start():mc.start() + 1500])
395 + if mp:
396 + details["Téléphone"] = mp.group(0)
397 + mo = _AGENCY_RE.search(html)
398 + if mo:
399 + details["Agence"] = _deslug(mo.group(1))
400 +
401 + if details:
402 + out["details"] = details
403 + return out
404 +
405 +
406 +# une « valeur » qui est en fait un titre de section = extraction décalée
407 +_SECTION_VAL_RE = re.compile(
408 + r"^(?:Particularit[ée]s|Caract[ée]ristiques|D[ée]tails des|Inclusions"
409 + r"|Exclusions|Addenda|Publicit[ée])", re.I)
410 +
411 +
412 +def _labels_first(text: str, labels: list[str]) -> dict:
413 + """Extraction « libellé | valeur » (ordre des fiches location Sutton)."""
414 + out: dict = {}
415 + for label in labels:
416 + boundary = r"\b" if label[-1].isalnum() else ""
417 + m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text)
418 + if m:
419 + val = m.group(1).strip(" |")
420 + if (val and 1 <= len(val) <= 55 and val.lower() != label.lower()
421 + and val not in labels and not _SECTION_VAL_RE.match(val)):
422 + out[label] = val
423 + return out
424 +
425 +
426 +def _drop_section_values(details: dict) -> None:
427 + """Purge les valeurs qui sont des titres de section (repli valeur|libellé
428 + de du.centris_details décalé sur les fiches en ordre libellé|valeur)."""
429 + for k in [k for k, v in details.items()
430 + if isinstance(v, str) and _SECTION_VAL_RE.match(v)]:
431 + del details[k]
added louka/connectors/ubee.py +123 −0
@@ -0,0 +1,123 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/ubee.py : Ubee (ubee.com) — plateforme immobilière québécoise
5 +# API publique anonyme : POST api.ubee.ca/api/anonymous/Search/SearchProperties
6 +# (pageIndex=N, 24 résultats/page, JSON riche : adresse, GPS, loyer, pièces,
7 +# superficies m², année, galerie Cloudinary complète). Volet LOCATION :
8 +# listingType=Renter + isResidential (~250 annonces) ; `askPrice` est le
9 +# loyer MENSUEL ($) pour ce volet (vérifié en live : 300–5 300 $/mois).
10 +# Les fiches location vivent sous /a-louer/{ville}/{slug} (canonical du
11 +# site). Les terrains et le volet commercial sont écartés — pas des
12 +# logements. Adapté du connecteur « à vendre » d'Immo-Ka
13 +# (agent-courtage/immoka).
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +from ..schema import Listing
18 +from .base import BaseConnector
19 +
20 +API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties"
21 +SITE = "https://ubee.com"
22 +M2_TO_SQFT = 10.7639
23 +
24 +# type d'inscription -> vocabulaire brut (finalize() normalise) ;
25 +# Terrain/Commercial ne sont pas des logements -> exclus au filtrage
26 +_TYPES = {
27 + "Unifamiliale": "Maison", "Condo": "Condo",
28 + "Plex": "Logement", "Chalet": "Chalet", "Fermette": "Fermette",
29 +}
30 +_EXCLUDE_TYPES = {"Terrain", "Commercial"}
31 +
32 +
33 +class UbeeConnector(BaseConnector):
34 + source_id = "ubee"
35 + request_delay = 0.5
36 +
37 + def _search(self, body: dict) -> list[dict]:
38 + out, page = [], 0
39 + while True:
40 + r = self.post(f"{API}?pageIndex={page}", json=body).json()
41 + results = r.get("results") or []
42 + out.extend(results)
43 + if len(out) >= (r.get("totalCount") or 0) or not results:
44 + break
45 + page += 1
46 + if page > 200: # garde-fou
47 + break
48 + return out
49 +
50 + def _to_listing(self, it: dict) -> Listing | None:
51 + lid = str(it.get("id") or "")
52 + slug = it.get("slugFr") or it.get("slugEn") or ""
53 + if not lid or not slug:
54 + return None
55 + itype = (it.get("inscriptionType") or "").strip()
56 + if itype in _EXCLUDE_TYPES: # terrain/local : pas un logement
57 + return None
58 + city = (it.get("city") or "").strip()
59 + url = f"{SITE}/a-louer/{it.get('citySlug') or ''}/{slug}"
60 + images = [im["publicUrls"]["default_size"]
61 + for im in it.get("images") or []
62 + if (im.get("publicUrls") or {}).get("default_size")]
63 + living = it.get("livingSurfaceInMeters")
64 + ptype = _TYPES.get(itype, itype or "Logement")
65 +
66 + # askPrice = loyer mensuel sur le volet Renter (vérifié en live)
67 + price = it.get("askPrice")
68 + if not isinstance(price, (int, float)) or price <= 0:
69 + price = None
70 +
71 + # type d'unité : nb de chambres (normalisé n+2 ½ par finalize())
72 + beds = it.get("nbBedrooms")
73 + unit_type = ""
74 + if isinstance(beds, int):
75 + unit_type = "Studio" if beds == 0 else f"{beds} chambres"
76 +
77 + details: dict = {"Courtier": "Ubee", "Type": ptype}
78 + if isinstance(it.get("nbBathrooms"), int) and it["nbBathrooms"] > 0:
79 + details["Salles de bain"] = str(it["nbBathrooms"])
80 + if isinstance(it.get("nbHalfBaths"), int) and it["nbHalfBaths"] > 0:
81 + details["Salles d'eau"] = str(it["nbHalfBaths"])
82 + for k, lab in (("propertyType", "Sous-type"),
83 + ("buildingType", "Type de bâtiment"),
84 + ("yearBuilt", "Année de construction")):
85 + if it.get(k):
86 + details[lab] = str(it[k])
87 +
88 + lst = Listing(
89 + source=self.source_id,
90 + external_id=lid,
91 + url=url,
92 + title=f"{ptype} à louer — {city}" if city else f"{ptype} à louer",
93 + address=it.get("address") or "",
94 + city=city,
95 + unit_type=unit_type,
96 + price=price,
97 + price_label=(f"{price:,.0f} $/mois".replace(",", " ")
98 + if price else ""),
99 + area_sqft=round(living * M2_TO_SQFT) if living else None,
100 + amenities=[f for f in (
101 + "Neuf / à construire" if it.get("toBuild") else "",
102 + ) if f],
103 + details=details,
104 + images=images,
105 + lat=it.get("latitude"),
106 + lng=it.get("longitude"),
107 + )
108 + return lst
109 +
110 + def fetch(self) -> list[Listing]:
111 + # une seule passe : le volet isCommercial=Renter ne renvoie que des
112 + # locaux commerciaux (vérifié en live) — pas des logements
113 + out: dict[str, Listing] = {}
114 + body = {"sortBy": "DateDescending", "listingType": "Renter",
115 + "isResidential": True}
116 + for it in self._search(body):
117 + # Québec seulement (l'API est QC par nature, on double-vérifie)
118 + if (it.get("province") or "QC") != "QC":
119 + continue
120 + lst = self._to_listing(it)
121 + if lst is not None:
122 + out.setdefault(lst.uid, lst)
123 + return list(out.values())
added louka/connectors/via_capitale.py +230 −0
@@ -0,0 +1,230 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS
5 +# Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET)
6 +# mais protégé par Cloudflare : on passe par Firecrawl (get_rendered) qui
7 +# franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend
8 +# pas les cartes. La recherche accepte un blob base64 `criteresJson` ;
9 +# « AVendre »: false y bascule le même moteur en mode LOCATION (prix
10 +# « X $ / mois », ~34 cartes/page). Adapté du connecteur « à vendre »
11 +# d'Immo-Ka (agent-courtage/immoka).
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import base64
16 +import html as _htmlmod
17 +import json
18 +import os
19 +import re
20 +
21 +from .base import BaseConnector
22 +from . import _detailutil as du
23 +from ..schema import Listing, parse_price
24 +
25 +DETAIL_LIMIT = int(os.environ.get("LOUKA_VC_DETAIL_LIMIT", "150"))
26 +_FICHE_IMG_RE = re.compile(
27 + r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I)
28 +
29 +SITE = "https://www.viacapitalevendu.com"
30 +SEARCH = f"{SITE}/recherche/residentiel/"
31 +MAX_PAGES = int(os.environ.get("LOUKA_VC_MAX_PAGES", "40"))
32 +
33 +# La pagination du portail n'est active QUE si `criteresJson` est présent.
34 +# Blob = critères vides + « AVendre »: false (= locations seulement) —
35 +# construit au chargement pour rester lisible/modifiable.
36 +_CRITERES = {
37 + "Caracteristiques": [], "AutresCritere": [], "TypeDePropriete": [],
38 + "TypeDeBatiment": None, "PeriodesAffichage": None, "AnneeDeConstruction": None,
39 + "Region": None, "NomRegion": None, "NombreDeChambre": 0, "NombreDeBain": 0,
40 + "PrixMinimum": "0", "PrixMaximum": "0",
41 + "PrixLocationMinimum": "0", "PrixLocationMaximum": "0",
42 + "OrderBy": None, "Type": 0, "AVendre": False,
43 + "FromAfficherToutesPropriete": False,
44 + "SuperficieMinimum": "", "SuperficieMaximum": "", "UniteMesure": "PC",
45 + "MotsCles": None, "Zonage": None, "NombreUnites": 1,
46 + "SuccursaleCode": None, "AgenceCode": None, "MembreCode": None,
47 + "EquipeId": 0, "SuccursaleName": None, "AgenceName": None,
48 + "MembreName": None, "EquipeName": None, "inputRegions": None,
49 + "ReturnUrl": None, "Latitude": None, "Longitude": None,
50 + "NoInscriptionNonDispo": None, "EnRecherche": False,
51 + "PlusDeCriteres": "false", "Specialite": None,
52 + "NomPlanEau": None, "NomPlanEauMobile": None,
53 + "IdPlanEau": None, "IdPlanEauMobile": 0,
54 + "GenrePropreteFrUrl": None, "GenreProprieteFrUrl": None,
55 + "GenreProprieteEnUrl": None, "selecPAMobile": None,
56 + "selecCaracMobile": [None] * 10, "selecACMobile": [None] * 10,
57 + "selecTBMobile": [None] * 10,
58 +}
59 +CRITERES_JSON = base64.b64encode(
60 + json.dumps(_CRITERES, separators=(",", ":")).encode()).decode()
61 +IMG_HOST = "https://images.viacapitale.info"
62 +
63 +# Un bloc-carte commence à un lien vers une fiche d'inscription horodatée
64 +CARD_SPLIT = re.compile(r'<a href="(https://www\.viacapitalevendu\.com/[^"]*?-(\d{6,}))"')
65 +PRICE_RE = re.compile(r'class="price">\s*([\d ]+\$\s*/\s*mois)', re.I)
66 +ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I)
67 +IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I)
68 +TYPE_RE = re.compile(
69 + r'(Appartement|Condo[\w\s]*|Maison[\w\s\-àâéèêëîïôûùç]*|Duplex|Triplex|'
70 + r'Quadruplex|Loft|Studio|Maison de ville|Jumelé|Chalet)', re.I)
71 +_PAREN = re.compile(r"\(([^)]*)\)")
72 +
73 +
74 +def _unescape(s: str) -> str:
75 + return _htmlmod.unescape(s).replace("\xa0", " ").replace("&nbsp;", " ").strip()
76 +
77 +
78 +def _split_addr(full: str) -> tuple[str, str, str]:
79 + """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) »
80 + -> (adresse, ville, secteur)."""
81 + if not full:
82 + return "", "", ""
83 + parts = [p.strip() for p in full.split(",", 1)]
84 + address = parts[0]
85 + city = sector = ""
86 + if len(parts) > 1:
87 + muni = parts[1]
88 + parens = _PAREN.findall(muni)
89 + city = _PAREN.sub("", muni).strip()
90 + if parens:
91 + sector = parens[-1].strip()
92 + return address, city, sector
93 +
94 +
95 +# slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-… »
96 +_REGIONS = (
97 + "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale",
98 + "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue",
99 + "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine",
100 + "chaudiere-appalaches", "laval", "lanaudiere", "laurentides",
101 + "monteregie", "centre-du-quebec",
102 +)
103 +
104 +
105 +def _from_slug(url: str) -> tuple[str, str]:
106 + tail = url.rstrip("/").rsplit("/", 1)[-1]
107 + for reg in _REGIONS:
108 + if tail.startswith(reg):
109 + rest = tail[len(reg) + 1:]
110 + city = rest.split("-")[0].replace("-", " ").title() if rest else ""
111 + return reg.replace("-", " ").title(), city
112 + return "", ""
113 +
114 +
115 +class ViaCapitaleConnector(BaseConnector):
116 + source_id = "via_capitale"
117 +
118 + def fetch(self) -> list[Listing]:
119 + by_id: dict[str, Listing] = {}
120 + empty_streak = 0
121 + for page in range(1, MAX_PAGES + 1):
122 + url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}"
123 + # Cloudflare renvoie parfois un challenge/vide par intermittence :
124 + # on retente la page une fois avant de la considérer vraiment vide.
125 + cards = []
126 + for _attempt in range(2):
127 + try:
128 + html = self.get_rendered(url)
129 + except Exception:
130 + html = ""
131 + cards = self._parse_cards(html)
132 + if cards:
133 + break
134 + new = 0
135 + for lst in cards:
136 + if lst.uid not in by_id:
137 + by_id[lst.uid] = lst
138 + new += 1
139 + if not cards or new == 0:
140 + empty_streak += 1
141 + if empty_streak >= 3:
142 + break
143 + else:
144 + empty_streak = 0
145 + listings = list(by_id.values())
146 + # fiche détail (requête simple, hors Cloudflare) : galerie + description
147 + du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v1")
148 + return listings
149 +
150 + def _parse_cards(self, html: str) -> list[Listing]:
151 + # découpe la page en segments, un par carte (lien fiche + id)
152 + matches = list(CARD_SPLIT.finditer(html))
153 + out = []
154 + for i, m in enumerate(matches):
155 + url, code = m.group(1), m.group(2)
156 + seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches)
157 + else m.start() + 2500]
158 + out.append(self._to_listing(url, code, seg))
159 + return [x for x in out if x]
160 +
161 + def _to_listing(self, url: str, code: str, seg: str) -> Listing | None:
162 + price_m = PRICE_RE.search(seg)
163 + if not price_m:
164 + return None # pas de « $ / mois » = pas une location rendue
165 + price_label = _unescape(price_m.group(1))
166 + addr_m = ADDR_RE.search(seg)
167 + address_full = _unescape(addr_m.group(1).strip()) if addr_m else ""
168 + type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg))
169 + prop_type = (re.split(r"\s{2,}|\n", type_m.group(1))[0].strip()
170 + if type_m else "")
171 +
172 + address, city, sector = _split_addr(address_full)
173 +
174 + images = []
175 + for im in IMG_RE.finditer(seg):
176 + u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}"
177 + if u not in images:
178 + images.append(u)
179 +
180 + region, city_slug = _from_slug(url)
181 + details = {"Courtier": "Via Capitale"}
182 + if region:
183 + details["Région"] = region
184 + if prop_type:
185 + details["Type"] = prop_type
186 + unit_type = prop_type if prop_type.lower() in ("studio", "loft") else ""
187 + return Listing(
188 + source=self.source_id,
189 + external_id=code,
190 + url=f"{SITE}/inscription/fichedescriptive/?code={code}",
191 + title=f"{prop_type} — {address}".strip(" —") or address_full,
192 + address=address,
193 + city=city or city_slug,
194 + sector=sector,
195 + unit_type=unit_type,
196 + price=parse_price(price_label),
197 + price_label=price_label,
198 + details=details,
199 + images=images,
200 + )
201 +
202 +
203 +def parse_vc_detail(html: str) -> dict:
204 + """Fiche Via Capitale : galerie complète + description (plus long bloc)."""
205 + out: dict = {}
206 + seen, imgs = set(), []
207 + for m in _FICHE_IMG_RE.finditer(html):
208 + u = f"{IMG_HOST}/images/inscriptions/{m.group(1)}/{m.group(2)}"
209 + if u not in seen:
210 + seen.add(u)
211 + imgs.append(u)
212 + if imgs:
213 + out["images"] = imgs
214 + # description = plus long bloc de texte visible (marketing de la propriété)
215 + best = ""
216 + for b in re.findall(r"<div[^>]*>(.*?)</div>", html, re.S):
217 + txt = re.sub(r"\s+", " ", _htmlmod.unescape(re.sub(r"<[^>]+>", " ", b))).strip()
218 + if len(txt) > len(best) and "window" not in txt and "function" not in txt \
219 + and "{" not in txt:
220 + best = txt
221 + if len(best) > 120:
222 + out["description"] = best[:4000]
223 + det = du.centris_details(du.flatten(html))
224 + if det:
225 + out["details"] = det
226 + # « Nombre de pièces : 4 pièces » → unité normalisable (4½ etc.)
227 + pieces = det.get("Nombre de pièces")
228 + if pieces:
229 + out["unit_type"] = pieces
230 + return out
231