SPB Git

spb/lou-ka

Public

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

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 (Aug 13, 2026) parent 2f08659 Browse files

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
Large diff (191 lines) is not loaded by default to keep the page fast.Load diff
added louka/connectors/sutton.py +431 −0
Large diff (431 lines) is not loaded by default to keep the page fast.Load diff
added louka/connectors/ubee.py +123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added louka/connectors/via_capitale.py +230 −0
Large diff (231 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme