SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat: connecteurs Kijiji, LesPAC et Ubee (immobilier à vendre, Québec seulement)

- kijiji.py : c35 maisons + c643 condos + c641 terrains, l9001 (QC), __NEXT_DATA__/Apollo, prix en cents normalisés, GPS natif
- lespac.py : b37-b42 achat-vente province entière (exclut location et vente d'entreprises), searchResponse embarqué, caractéristiques structurées
- ubee.py : API anonyme SearchProperties (Seller, résidentiel+commercial), galerie Cloudinary complète, superficies m²→pi²

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 753d042

5 changed files +643 −333

modified README.md +3 −2
@@ -13,7 +13,7 @@
13 13 ![SQLite](https://img.shields.io/badge/SQLite-WAL-141814?style=for-the-badge&logo=sqlite&logoColor=c7f230)
14 14
15 15 ![Propriétés](https://img.shields.io/badge/propri%C3%A9t%C3%A9s_agr%C3%A9g%C3%A9es-54_800%2B-e23744?style=flat-square)
16 −![Connecteurs](https://img.shields.io/badge/connecteurs-74-e23744?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs-83-e23744?style=flat-square)
17 17 ![Bannières](https://img.shields.io/badge/banni%C3%A8res-15-e23744?style=flat-square)
18 18 ![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-e23744?style=flat-square)
19 19 ![MàJ](https://img.shields.io/badge/mise_%C3%A0_jour-automatique-e23744?style=flat-square)
@@ -68,6 +68,7 @@ architecture, schéma adapté à la vente.
68 68 | Barnes Québec | ~340 | Algolia + page détail |
69 69 | Profusion (Christie's) | ~360 | WordPress + JSON-LD |
70 70 | M Immobilier · Vendirect · Sotheby's · L'Expert PM | ~180 | Centris / source.immo / JSON-LD |
71 +| Kijiji · LesPAC · Ubee (petites annonces / plateformes) | *actif* | immobilier à vendre QC seulement (pas location, pas entreprises) |
71 72 | Sous-agences RE/MAX / Via Capitale / Century 21 | *backup* | 60+ connecteurs `*_ag_*`, dédup par n° Centris |
72 73
73 74 **≈ 40 000 propriétés dédupliquées**, mises à jour automatiquement. Les sous-agences
@@ -79,7 +80,7 @@ sans double-comptage (déduplication par numéro Centris).
79 80 ```mermaid
80 81 flowchart LR
81 82 subgraph Sources["Agences de courtage du Québec"]
82 − S1["RE/MAX · Royal LePage · Sutton<br/>Via Capitale · Century 21 · Proprio Direct<br/>Engel & Völkers · Barnes · Profusion<br/>… 73 connecteurs"]
83 + S1["RE/MAX · Royal LePage · Sutton<br/>Via Capitale · Century 21 · Proprio Direct<br/>Engel & Völkers · Barnes · Profusion<br/>… 80 connecteurs"]
83 84 end
84 85 subgraph ImmoKa["Immo-Ka"]
85 86 C["Connecteurs<br/><i>1 adaptateur / agence</i>"] --> N["Normalisation<br/><i>schéma PropertyListing</i>"]
modified data/sources.json +352 −331
@@ -1,333 +1,354 @@
1 1 {
2 − "_comment": "Immo-Ka — Registre des sources (agences de courtage immobilier, province de Québec). Un connecteur par agence. Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 − "sources": [
4 − {
5 − "id": "remax_quebec",
6 − "name": "RE/MAX Québec",
7 − "url": "https://www.remax-quebec.com",
8 − "listing_url": "https://www.remax-quebec.com/fr/maison-a-vendre",
9 − "coverage": "Toute la province — ~5 000 courtiers, ~119 agences (bannière #1 au Québec)",
10 − "connector": "remax_quebec",
11 − "status": "actif",
12 − "type": "franchise"
13 − },
14 − {
15 − "id": "royal_lepage",
16 − "name": "Royal LePage (Québec)",
17 − "url": "https://www.royallepage.ca",
18 − "listing_url": "https://www.royallepage.ca/fr/search/homes/1/",
19 − "coverage": "Toute la province — ~2 233 courtiers, ~47 agences (Bridgemarq)",
20 − "connector": "royal_lepage",
21 − "status": "actif",
22 − "type": "franchise",
23 − "note": "Recherche par ville filtrable QC : GET /fr/search/homes/{page}/?search_str=<ville>, QC&prov_code=QC&city_name=<ville>&lat=&lng=&search_type=city -> 100% QC (46/page), classé par proximité, cap ~1250/recherche. Sharding par ~28 points d'ancrage QC + dédup MLS (= n° Centris dans /.../mls{no}/). Requêtes directes (pas de Firecrawl). Champs liste: adresse, ville, type, prix, CAC, SDB, coords (data-rlp-key), photo jumplisting, MLS."
24 − },
25 − {
26 − "id": "sutton",
27 − "name": "Groupe Sutton Québec",
28 − "url": "https://suttonquebec.com",
29 − "listing_url": "https://suttonquebec.com/maisons-a-vendre.html",
30 − "coverage": "Toute la province — ~1 300-1 500 courtiers, ~20 agences",
31 − "connector": "sutton",
32 − "status": "actif",
33 − "type": "franchise"
34 − },
35 − {
36 − "id": "via_capitale",
37 − "name": "Via Capitale",
38 − "url": "https://www.viacapitalevendu.com",
39 − "listing_url": "https://www.viacapitalevendu.com/recherche/residentiel/",
40 − "coverage": "Toute la province — ~5 300 propriétés, ~900-1 000 courtiers (québécoise, Bridgemarq)",
41 − "connector": "via_capitale",
42 − "status": "actif",
43 − "type": "franchise",
44 − "note": "ASP.NET rendu serveur derrière Cloudflare : accès via Firecrawl proxy=stealth. Résultats paginés ?page=N (34/page, ~155 pages). Parse: adresse, ville, secteur, région, type, prix, photos (images.viacapitale.info). CAC/SDB non captés à ce stade (icônes)."
45 − },
46 − {
47 − "id": "century21",
48 − "name": "Century 21 (Québec)",
49 − "url": "https://www.c21.ca",
50 − "listing_url": "https://listings.moxiworks.com/m214-1731446488/fr-ca",
51 − "coverage": "Toute la province — ~339 courtiers, ~22 agences",
52 − "connector": "century21",
53 − "status": "actif",
54 − "type": "franchise",
55 − "note": "Central c21.ca->moxiworks (company_uuid=3341197) : même flux search_v2 anonyme accessible, MAIS données Rest-of-Canada/MLS (pas Centris QC : Québec-ville=0). La couverture QC de C21 vient des sous-agences source.immo (c21_ag_*), déjà actives. Le central moxi n'ajoute rien au QC."
56 − },
57 − {
58 − "id": "proprio_direct",
59 − "name": "Proprio Direct",
60 − "url": "https://propriodirect.com",
61 − "listing_url": "https://propriodirect.com/propriete-a-vendre/",
62 − "coverage": "Toute la province — ~746 courtiers, agence unique (Bridgemarq)",
63 − "connector": "proprio_direct",
64 − "status": "actif",
65 − "type": "agence",
66 − "note": "Feed DDF (CREA) servi par API JSON interne POST propriodirect.com/fr/api/searchListings (corps {from:offset}, 30/page, champ total). ~3201 propriétés à vendre, données complètes + galeries photos. Hôte canonique sans www."
67 − },
68 − {
69 − "id": "exp_quebec",
70 − "name": "eXp Agence immobilière (Québec)",
71 − "url": "https://www.exprealty.ca",
72 − "listing_url": "https://www.exprealty.ca/fr-CA/search",
73 − "coverage": "Toute la province — ~400+ courtiers, agence virtuelle",
74 − "connector": "exp_quebec",
75 − "status": "a_faire",
76 − "type": "agence",
77 − "note": "Next.js + kvCORE/BoldTrail. Scrapfly asp+render_js rend la page mais ?state=QC NE filtre PAS (cartes = ON/Toronto par defaut, data-adm1st=ON) ; cartes marquees data-compliance-requirement=signIn (DDF gated). js_scenario scroll n'ajoute pas de cartes (18 data-listing, 6 uniques, ON). Pages region QC (/montreal-qc-real-estate) rendent 0 carte. Reste: API kvCORE authentifiee (provider-id 30000) filtree state=QC, non contournable sans session."
78 − },
79 − {
80 − "id": "kw_urbain",
81 − "name": "Keller Williams Urbain",
82 − "url": "https://www.kwurbain.ca",
83 − "listing_url": "https://www.kwurbain.ca/inscriptions/",
84 − "coverage": "Grand Montréal + Rive-Sud/Rive-Nord",
85 − "connector": "kw_urbain",
86 − "status": "actif",
87 − "type": "franchise",
88 − "note": "Cartes HTML rendues serveur (plateforme realestate.marketingwebsites.ca) — 349 inscriptions sur une page. Champs: adresse, ville, région, prix, chambres, sdb, photo."
89 − },
90 − {
91 − "id": "kw_distinction",
92 − "name": "Keller Williams Distinction",
93 − "url": "https://www.kwdistinction.com",
94 − "listing_url": "https://www.kwdistinction.com/sitemap.xml",
95 − "coverage": "Gatineau / Outaouais",
96 − "connector": "kw_distinction",
97 − "status": "actif",
98 − "type": "franchise",
99 − "note": "ASP.NET (tonikwebstudio) : grille/fiche en AJAX (prix/photos non rendus statiquement, ni fiablement via Firecrawl). Enumération via sitemap.xml (167 propr.) ; slug d'URL = ville/type/chambres/rue/no-Centris. Champs liste: adresse, ville, secteur, type, chambres, Centris. Prix/photos NON dispo."
100 − },
101 − {
102 − "id": "vendirect",
103 − "name": "Vendirect",
104 − "url": "https://vendirect.ca",
105 − "listing_url": "https://vendirect.ca/proprietes/",
106 − "coverage": "Toute la province — ~609 courtiers, commission réduite",
107 − "connector": "vendirect",
108 − "status": "actif",
109 − "type": "agence",
110 − "note": "WordPress + plugin source-immo. API publique api-v1.source.immo (en-têtes x-si-account/api/appId depuis _configs.json), pagination next_token. ~64 propriétés à vendre, données complètes (prix, ch., sdb, pi², année, coords, photo)."
111 − },
112 − {
113 − "id": "expert_immobilier_pm",
114 − "name": "L'Expert Immobilier P.M.",
115 − "url": "https://expertimmobilierpm.com",
116 − "listing_url": "https://expertimmobilierpm.com/proprietes/",
117 − "coverage": "Grand Montréal — ~305 courtiers, grosse indépendante",
118 − "connector": "expert_immobilier_pm",
119 − "status": "actif",
120 − "type": "agence",
121 − "note": "Vrai site QC trouve (l'ancienne URL lexpertimmobilier.com = agence de Casablanca). WordPress + plugin mw-properties2 (plateforme marketingwebsites.ca, comme KW Urbain). /proprietes/ rend 9 fiches serveur (inventaire complet derriere l'API REST mw-properties/v2 en 401). Cartes: adresse, ville, type, prix, chambres, sdb, superficie ; fiche detail: galerie complete (marketingwebsites) + description. external_id = no Centris."
122 − },
123 − {
124 − "id": "engel_volkers",
125 − "name": "Engel & Völkers Montréal / Québec",
126 − "url": "https://immobilier.evquebec.com",
127 − "listing_url": "https://immobilier.evquebec.com/property-sitemap.xml",
128 − "coverage": "Montréal, Québec, Tremblant — luxe #1 au Québec",
129 − "connector": "engel_volkers",
130 − "status": "actif",
131 − "type": "luxe",
132 − "note": "immobilier.evquebec.com : property-sitemap.xml (~1240 fiches FR /propriete/{no-centris}/). Fiche = JSON-LD (prix/adresse/description) ; galerie séquentielle media.evquebec.com/listings/{id}/NN.webp (sondée jusqu'au 1er trou). Enrichissement cache+plafond (IMMOKA_EV_DETAIL_LIMIT)."
133 − },
134 − {
135 − "id": "sothebys_quebec",
136 − "name": "Sotheby's International Realty Québec",
137 − "url": "https://sothebysrealty.ca",
138 − "listing_url": "https://sothebysrealty.ca/en/search-results/province-quebec-real-estate/",
139 − "coverage": "Toute la province — luxe",
140 − "connector": "sothebys_quebec",
141 − "status": "actif",
142 − "type": "luxe",
143 − "note": "Recherche rendue côté client. Fiches enumérées via pages de résultats (grid public plafonné ~40 fiches province-wide ; pagination AJAX non exposée en URL). Chaque fiche enrichie via son JSON-LD Product : prix, galerie S3 complète (35-75 photos), description, MLS (sku), type. Limite : couverture = set public courant (~40) ; coverage complète nécessiterait l'endpoint AJAX de pagination."
144 − },
145 − {
146 − "id": "profusion",
147 − "name": "Profusion Immobilier (Christie's)",
148 − "url": "https://www.profusion.global",
149 − "listing_url": "https://www.profusion.global/acheter/nos-inscriptions/",
150 − "coverage": "Grand Montréal + province — luxe, affiliée Christie's",
151 − "connector": "profusion",
152 − "status": "actif",
153 − "type": "luxe",
154 − "note": "WordPress DIVI ; nos-inscriptions liste ~357 fiches /propriete/{no-centris}/ (HTML). Fiche = JSON-LD RealEstateListing (adresse, description, prix, courtier) + galerie profusionimmo.ca. Enrichissement cache+plafond (IMMOKA_PROFUSION_DETAIL_LIMIT)."
155 − },
156 − {
157 − "id": "barnes_quebec",
158 − "name": "BARNES Québec",
159 − "url": "https://barnes-quebec.com",
160 − "listing_url": "https://barnes-quebec.com/property",
161 − "coverage": "Montréal + province — luxe international",
162 − "connector": "barnes_quebec",
163 − "status": "actif",
164 − "type": "luxe",
165 − "note": "Algolia (App HCW55VIQNM, index quebec_all). ~344 propriétés, prix/MLS/chambres/sdb/superficie/géoloc. Images non indexées (viendront de la page détail). Dédoublonnage FR/EN par adresse."
166 − },
167 − {
168 − "id": "m_immobilier",
169 − "name": "M Immobilier",
170 − "url": "https://www.mimmobilier.com",
171 − "listing_url": "https://www.mimmobilier.com/properties",
172 − "coverage": "Grand Montréal — indépendante primée (Leading RE)",
173 − "connector": "m_immobilier",
174 − "status": "actif",
175 − "type": "luxe",
176 − "note": "Cartes HTML rendues serveur (/properties, PAS /proprietes). ~65 propriétés à vendre (locations filtrées). Champs: ville, adresse, prix, chambres, sdb, photos Centris, no Centris. Type non exposé."
177 − },
178 − {
179 − "id": "via_ag_elite",
180 − "name": "Via Capitale Elite",
181 − "url": "https://viacapitaleelite.immo",
182 − "listing_url": "https://viacapitaleelite.immo/proprietes/",
183 − "coverage": "Sous-agence Via Capitale (source.immo)",
184 − "connector": "via_ag_elite",
185 − "status": "actif",
186 − "type": "franchise",
187 − "role": "backup"
188 − },
189 − {
190 − "id": "via_ag_select",
191 − "name": "Via Capitale Select",
192 − "url": "https://viacapitaleselect.com",
193 − "listing_url": "https://viacapitaleselect.com/proprietes/",
194 − "coverage": "Sous-agence Via Capitale (source.immo)",
195 − "connector": "via_ag_select",
196 − "status": "actif",
197 − "type": "franchise",
198 − "role": "backup"
199 − },
200 − {
201 − "id": "via_ag_dumontroyal",
202 − "name": "Via Capitale Dumontroyal",
203 − "url": "https://viacapitaledumontroyal.com",
204 − "listing_url": "https://viacapitaledumontroyal.com/proprietes/",
205 − "coverage": "Sous-agence Via Capitale (source.immo)",
206 − "connector": "via_ag_dumontroyal",
207 − "status": "actif",
208 − "type": "franchise",
209 − "role": "backup"
210 − },
211 − {
212 − "id": "via_ag_acces",
213 − "name": "Via Capitale Acces",
214 − "url": "https://viacapitaleacces.com",
215 − "listing_url": "https://viacapitaleacces.com/proprietes/",
216 − "coverage": "Sous-agence Via Capitale (source.immo)",
217 − "connector": "via_ag_acces",
218 − "status": "actif",
219 − "type": "franchise",
220 − "role": "backup"
221 − },
222 − {
223 − "id": "c21_ag_quebec",
224 − "name": "Century 21 Québec (All Quebec)",
225 − "url": "https://quebec.c21.ca",
226 − "listing_url": "https://quebec.c21.ca",
227 − "connector": "c21_ag_quebec",
228 − "status": "actif",
229 − "role": "sous-agence",
230 − "platform": "source.immo",
231 − "note": "Compte source.immo partagé (quebec/excel/immoplus) = inventaire provincial C21 complet (~1600 à vendre). Pagination st/nt. Dédup par n° Centris."
232 − },
233 − {
234 − "id": "c21_ag_vision",
235 − "name": "Century 21 Vision (Outaouais)",
236 − "url": "https://vision.c21.ca",
237 − "listing_url": "https://vision.c21.ca",
238 − "connector": "c21_ag_vision",
239 − "status": "actif",
240 − "role": "sous-agence",
241 − "platform": "source.immo",
242 − "note": "Century 21 Vision (Gatineau) — compte source.immo distinct (~139 à vendre)."
243 − },
244 − {
245 − "id": "duproprio",
246 − "name": "DuProprio (sans courtier)",
247 − "url": "https://duproprio.com",
248 − "listing_url": "https://duproprio.com/sitemaps/fr/index.xml.gz",
249 − "coverage": "Toute la province — vente par le propriétaire (FSBO), ~7 600 propriétés QC",
250 − "connector": "duproprio",
251 − "status": "actif",
252 − "type": "fsbo",
253 − "note": "FSBO. Enumeration via sitemaps FR par region QC (/sitemaps/fr/<region>-listings.xml.gz) : l'URL encode region/ville/type/id. Fiche detail JSON-LD Product (prix, description, galerie photos.duproprio.com). Fetch direct + Scrapfly ASP en secours. agency='DuProprio (sans courtier)'."
254 − },
255 − {
256 − "id": "realta",
257 − "name": "Réalta Agence Immobilière",
258 − "url": "https://www.realta.ca",
259 − "listing_url": "https://www.realta.ca/proprietes/",
260 − "coverage": "Agence indépendante (Québec)",
261 − "connector": "realta",
262 − "status": "actif",
263 − "type": "agence",
264 − "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 83 propriétés à vendre. Généré par agences_source_immo.py."
265 − },
266 − {
267 − "id": "immodev",
268 − "name": "Immodev Agence Immobilière",
269 − "url": "https://www.immodev.ca",
270 − "listing_url": "https://www.immodev.ca/proprietes/",
271 − "coverage": "Agence indépendante (Québec)",
272 − "connector": "immodev",
273 − "status": "actif",
274 − "type": "agence",
275 − "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 93 propriétés à vendre. Généré par agences_source_immo.py."
276 − },
277 − {
278 − "id": "gaillardetz",
279 − "name": "Gaillardetz Agence Immobilière",
280 − "url": "https://www.gaillardetz.ca",
281 − "listing_url": "https://www.gaillardetz.ca/proprietes/",
282 − "coverage": "Agence indépendante (Québec)",
283 − "connector": "gaillardetz",
284 − "status": "actif",
285 − "type": "agence",
286 − "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 13 propriétés à vendre. Généré par agences_source_immo.py."
287 − },
288 − {
289 − "id": "domicilia",
290 − "name": "Domicilia Agence Immobilière",
291 − "url": "https://www.domicilia.com",
292 − "listing_url": "https://www.domicilia.com/proprietes/",
293 − "coverage": "Agence indépendante (Québec)",
294 − "connector": "domicilia",
295 − "status": "actif",
296 − "type": "agence",
297 − "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 1427 propriétés à vendre. Généré par agences_source_immo.py."
298 − },
299 − {
300 − "id": "niddamour",
301 − "name": "Nid d'Amour Agence Immobilière",
302 − "url": "https://www.niddamour.ca",
303 − "listing_url": "https://www.niddamour.ca/proprietes/",
304 − "coverage": "Agence indépendante (Québec)",
305 − "connector": "niddamour",
306 − "status": "a_faire",
307 − "type": "agence",
308 − "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 0 propriétés à vendre. Généré par agences_source_immo.py."
309 − },
310 − {
311 − "id": "kw_prestige",
312 − "name": "Keller Williams Prestige",
313 − "url": "https://www.kwprestige.com",
314 − "listing_url": "https://www.kwprestige.com/sitemap.xml",
315 − "coverage": "Grand Montréal — bannière KW prestige",
316 − "connector": "kw_prestige",
317 − "status": "actif",
318 − "type": "franchise",
319 − "note": "Grille/fiche en widget JS (pagination AJAX non exploitable). Enumeration via sitemap.xml (277 fiches a-vendre) ; slug URL = type-a-vendre-ville-secteur-{noCentris}-detail-Fr. Champs liste: type, ville, secteur, n Centris. Prix/photos = widget JS (list cards rendus serveur mais pagination JS ; enrichissement futur via Scrapfly js_scenario)."
320 − },
321 − {
322 − "id": "coldwell_banker",
323 − "name": "Coldwell Banker Québec",
324 − "url": "https://coldwellbanker.ca",
325 − "listing_url": "https://coldwellbanker.ca/",
326 − "coverage": "Québec — franchise (L'Héritage, Prestige…)",
327 − "connector": "coldwell_banker",
328 − "status": "a_faire",
329 − "type": "franchise",
330 − "note": "moxiworks org m102181-1733247042 / company_uuid=4277740. FLUX ANONYME CRACKÉ : GET svc.moxiworks.com/service/v1/listing/search_v2?company_uuid=<id>&bounds_west/east/south/north=..&status=active (pas d'auth ; le WAF n'est que sur listings.moxiworks.com, pas sur svc). MAIS le feed = Rest-of-Canada/MLS (TREB Toronto : MLS C.../T...) ; couverture Québec/Centris ~nulle (Québec-ville=0, Montréal=4 fiches à MLS torontois). Non exploitable pour Immo-Ka (QC). CB Québec quasi inexistant sur ce feed."
331 − }
332 − ]
2 + "_comment": "Immo-Ka — Registre des sources (agences de courtage immobilier, province de Québec). Un connecteur par agence. Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 + "sources": [
4 + {
5 + "id": "remax_quebec",
6 + "name": "RE/MAX Québec",
7 + "url": "https://www.remax-quebec.com",
8 + "listing_url": "https://www.remax-quebec.com/fr/maison-a-vendre",
9 + "coverage": "Toute la province — ~5 000 courtiers, ~119 agences (bannière #1 au Québec)",
10 + "connector": "remax_quebec",
11 + "status": "actif",
12 + "type": "franchise"
13 + },
14 + {
15 + "id": "royal_lepage",
16 + "name": "Royal LePage (Québec)",
17 + "url": "https://www.royallepage.ca",
18 + "listing_url": "https://www.royallepage.ca/fr/search/homes/1/",
19 + "coverage": "Toute la province — ~2 233 courtiers, ~47 agences (Bridgemarq)",
20 + "connector": "royal_lepage",
21 + "status": "actif",
22 + "type": "franchise",
23 + "note": "Recherche par ville filtrable QC : GET /fr/search/homes/{page}/?search_str=<ville>, QC&prov_code=QC&city_name=<ville>&lat=&lng=&search_type=city -> 100% QC (46/page), classé par proximité, cap ~1250/recherche. Sharding par ~28 points d'ancrage QC + dédup MLS (= n° Centris dans /.../mls{no}/). Requêtes directes (pas de Firecrawl). Champs liste: adresse, ville, type, prix, CAC, SDB, coords (data-rlp-key), photo jumplisting, MLS."
24 + },
25 + {
26 + "id": "sutton",
27 + "name": "Groupe Sutton Québec",
28 + "url": "https://suttonquebec.com",
29 + "listing_url": "https://suttonquebec.com/maisons-a-vendre.html",
30 + "coverage": "Toute la province — ~1 300-1 500 courtiers, ~20 agences",
31 + "connector": "sutton",
32 + "status": "actif",
33 + "type": "franchise"
34 + },
35 + {
36 + "id": "via_capitale",
37 + "name": "Via Capitale",
38 + "url": "https://www.viacapitalevendu.com",
39 + "listing_url": "https://www.viacapitalevendu.com/recherche/residentiel/",
40 + "coverage": "Toute la province — ~5 300 propriétés, ~900-1 000 courtiers (québécoise, Bridgemarq)",
41 + "connector": "via_capitale",
42 + "status": "actif",
43 + "type": "franchise",
44 + "note": "ASP.NET rendu serveur derrière Cloudflare : accès via Firecrawl proxy=stealth. Résultats paginés ?page=N (34/page, ~155 pages). Parse: adresse, ville, secteur, région, type, prix, photos (images.viacapitale.info). CAC/SDB non captés à ce stade (icônes)."
45 + },
46 + {
47 + "id": "century21",
48 + "name": "Century 21 (Québec)",
49 + "url": "https://www.c21.ca",
50 + "listing_url": "https://listings.moxiworks.com/m214-1731446488/fr-ca",
51 + "coverage": "Toute la province — ~339 courtiers, ~22 agences",
52 + "connector": "century21",
53 + "status": "actif",
54 + "type": "franchise",
55 + "note": "Central c21.ca->moxiworks (company_uuid=3341197) : même flux search_v2 anonyme accessible, MAIS données Rest-of-Canada/MLS (pas Centris QC : Québec-ville=0). La couverture QC de C21 vient des sous-agences source.immo (c21_ag_*), déjà actives. Le central moxi n'ajoute rien au QC."
56 + },
57 + {
58 + "id": "proprio_direct",
59 + "name": "Proprio Direct",
60 + "url": "https://propriodirect.com",
61 + "listing_url": "https://propriodirect.com/propriete-a-vendre/",
62 + "coverage": "Toute la province — ~746 courtiers, agence unique (Bridgemarq)",
63 + "connector": "proprio_direct",
64 + "status": "actif",
65 + "type": "agence",
66 + "note": "Feed DDF (CREA) servi par API JSON interne POST propriodirect.com/fr/api/searchListings (corps {from:offset}, 30/page, champ total). ~3201 propriétés à vendre, données complètes + galeries photos. Hôte canonique sans www."
67 + },
68 + {
69 + "id": "exp_quebec",
70 + "name": "eXp Agence immobilière (Québec)",
71 + "url": "https://www.exprealty.ca",
72 + "listing_url": "https://www.exprealty.ca/fr-CA/search",
73 + "coverage": "Toute la province — ~400+ courtiers, agence virtuelle",
74 + "connector": "exp_quebec",
75 + "status": "a_faire",
76 + "type": "agence",
77 + "note": "Next.js + kvCORE/BoldTrail. Scrapfly asp+render_js rend la page mais ?state=QC NE filtre PAS (cartes = ON/Toronto par defaut, data-adm1st=ON) ; cartes marquees data-compliance-requirement=signIn (DDF gated). js_scenario scroll n'ajoute pas de cartes (18 data-listing, 6 uniques, ON). Pages region QC (/montreal-qc-real-estate) rendent 0 carte. Reste: API kvCORE authentifiee (provider-id 30000) filtree state=QC, non contournable sans session."
78 + },
79 + {
80 + "id": "kw_urbain",
81 + "name": "Keller Williams Urbain",
82 + "url": "https://www.kwurbain.ca",
83 + "listing_url": "https://www.kwurbain.ca/inscriptions/",
84 + "coverage": "Grand Montréal + Rive-Sud/Rive-Nord",
85 + "connector": "kw_urbain",
86 + "status": "actif",
87 + "type": "franchise",
88 + "note": "Cartes HTML rendues serveur (plateforme realestate.marketingwebsites.ca) — 349 inscriptions sur une page. Champs: adresse, ville, région, prix, chambres, sdb, photo."
89 + },
90 + {
91 + "id": "kw_distinction",
92 + "name": "Keller Williams Distinction",
93 + "url": "https://www.kwdistinction.com",
94 + "listing_url": "https://www.kwdistinction.com/sitemap.xml",
95 + "coverage": "Gatineau / Outaouais",
96 + "connector": "kw_distinction",
97 + "status": "actif",
98 + "type": "franchise",
99 + "note": "ASP.NET (tonikwebstudio) : grille/fiche en AJAX (prix/photos non rendus statiquement, ni fiablement via Firecrawl). Enumération via sitemap.xml (167 propr.) ; slug d'URL = ville/type/chambres/rue/no-Centris. Champs liste: adresse, ville, secteur, type, chambres, Centris. Prix/photos NON dispo."
100 + },
101 + {
102 + "id": "vendirect",
103 + "name": "Vendirect",
104 + "url": "https://vendirect.ca",
105 + "listing_url": "https://vendirect.ca/proprietes/",
106 + "coverage": "Toute la province — ~609 courtiers, commission réduite",
107 + "connector": "vendirect",
108 + "status": "actif",
109 + "type": "agence",
110 + "note": "WordPress + plugin source-immo. API publique api-v1.source.immo (en-têtes x-si-account/api/appId depuis _configs.json), pagination next_token. ~64 propriétés à vendre, données complètes (prix, ch., sdb, pi², année, coords, photo)."
111 + },
112 + {
113 + "id": "expert_immobilier_pm",
114 + "name": "L'Expert Immobilier P.M.",
115 + "url": "https://expertimmobilierpm.com",
116 + "listing_url": "https://expertimmobilierpm.com/proprietes/",
117 + "coverage": "Grand Montréal — ~305 courtiers, grosse indépendante",
118 + "connector": "expert_immobilier_pm",
119 + "status": "actif",
120 + "type": "agence",
121 + "note": "Vrai site QC trouve (l'ancienne URL lexpertimmobilier.com = agence de Casablanca). WordPress + plugin mw-properties2 (plateforme marketingwebsites.ca, comme KW Urbain). /proprietes/ rend 9 fiches serveur (inventaire complet derriere l'API REST mw-properties/v2 en 401). Cartes: adresse, ville, type, prix, chambres, sdb, superficie ; fiche detail: galerie complete (marketingwebsites) + description. external_id = no Centris."
122 + },
123 + {
124 + "id": "engel_volkers",
125 + "name": "Engel & Völkers Montréal / Québec",
126 + "url": "https://immobilier.evquebec.com",
127 + "listing_url": "https://immobilier.evquebec.com/property-sitemap.xml",
128 + "coverage": "Montréal, Québec, Tremblant — luxe #1 au Québec",
129 + "connector": "engel_volkers",
130 + "status": "actif",
131 + "type": "luxe",
132 + "note": "immobilier.evquebec.com : property-sitemap.xml (~1240 fiches FR /propriete/{no-centris}/). Fiche = JSON-LD (prix/adresse/description) ; galerie séquentielle media.evquebec.com/listings/{id}/NN.webp (sondée jusqu'au 1er trou). Enrichissement cache+plafond (IMMOKA_EV_DETAIL_LIMIT)."
133 + },
134 + {
135 + "id": "sothebys_quebec",
136 + "name": "Sotheby's International Realty Québec",
137 + "url": "https://sothebysrealty.ca",
138 + "listing_url": "https://sothebysrealty.ca/en/search-results/province-quebec-real-estate/",
139 + "coverage": "Toute la province — luxe",
140 + "connector": "sothebys_quebec",
141 + "status": "actif",
142 + "type": "luxe",
143 + "note": "Recherche rendue côté client. Fiches enumérées via pages de résultats (grid public plafonné ~40 fiches province-wide ; pagination AJAX non exposée en URL). Chaque fiche enrichie via son JSON-LD Product : prix, galerie S3 complète (35-75 photos), description, MLS (sku), type. Limite : couverture = set public courant (~40) ; coverage complète nécessiterait l'endpoint AJAX de pagination."
144 + },
145 + {
146 + "id": "profusion",
147 + "name": "Profusion Immobilier (Christie's)",
148 + "url": "https://www.profusion.global",
149 + "listing_url": "https://www.profusion.global/acheter/nos-inscriptions/",
150 + "coverage": "Grand Montréal + province — luxe, affiliée Christie's",
151 + "connector": "profusion",
152 + "status": "actif",
153 + "type": "luxe",
154 + "note": "WordPress DIVI ; nos-inscriptions liste ~357 fiches /propriete/{no-centris}/ (HTML). Fiche = JSON-LD RealEstateListing (adresse, description, prix, courtier) + galerie profusionimmo.ca. Enrichissement cache+plafond (IMMOKA_PROFUSION_DETAIL_LIMIT)."
155 + },
156 + {
157 + "id": "barnes_quebec",
158 + "name": "BARNES Québec",
159 + "url": "https://barnes-quebec.com",
160 + "listing_url": "https://barnes-quebec.com/property",
161 + "coverage": "Montréal + province — luxe international",
162 + "connector": "barnes_quebec",
163 + "status": "actif",
164 + "type": "luxe",
165 + "note": "Algolia (App HCW55VIQNM, index quebec_all). ~344 propriétés, prix/MLS/chambres/sdb/superficie/géoloc. Images non indexées (viendront de la page détail). Dédoublonnage FR/EN par adresse."
166 + },
167 + {
168 + "id": "m_immobilier",
169 + "name": "M Immobilier",
170 + "url": "https://www.mimmobilier.com",
171 + "listing_url": "https://www.mimmobilier.com/properties",
172 + "coverage": "Grand Montréal — indépendante primée (Leading RE)",
173 + "connector": "m_immobilier",
174 + "status": "actif",
175 + "type": "luxe",
176 + "note": "Cartes HTML rendues serveur (/properties, PAS /proprietes). ~65 propriétés à vendre (locations filtrées). Champs: ville, adresse, prix, chambres, sdb, photos Centris, no Centris. Type non exposé."
177 + },
178 + {
179 + "id": "via_ag_elite",
180 + "name": "Via Capitale Elite",
181 + "url": "https://viacapitaleelite.immo",
182 + "listing_url": "https://viacapitaleelite.immo/proprietes/",
183 + "coverage": "Sous-agence Via Capitale (source.immo)",
184 + "connector": "via_ag_elite",
185 + "status": "actif",
186 + "type": "franchise",
187 + "role": "backup"
188 + },
189 + {
190 + "id": "via_ag_select",
191 + "name": "Via Capitale Select",
192 + "url": "https://viacapitaleselect.com",
193 + "listing_url": "https://viacapitaleselect.com/proprietes/",
194 + "coverage": "Sous-agence Via Capitale (source.immo)",
195 + "connector": "via_ag_select",
196 + "status": "actif",
197 + "type": "franchise",
198 + "role": "backup"
199 + },
200 + {
201 + "id": "via_ag_dumontroyal",
202 + "name": "Via Capitale Dumontroyal",
203 + "url": "https://viacapitaledumontroyal.com",
204 + "listing_url": "https://viacapitaledumontroyal.com/proprietes/",
205 + "coverage": "Sous-agence Via Capitale (source.immo)",
206 + "connector": "via_ag_dumontroyal",
207 + "status": "actif",
208 + "type": "franchise",
209 + "role": "backup"
210 + },
211 + {
212 + "id": "via_ag_acces",
213 + "name": "Via Capitale Acces",
214 + "url": "https://viacapitaleacces.com",
215 + "listing_url": "https://viacapitaleacces.com/proprietes/",
216 + "coverage": "Sous-agence Via Capitale (source.immo)",
217 + "connector": "via_ag_acces",
218 + "status": "actif",
219 + "type": "franchise",
220 + "role": "backup"
221 + },
222 + {
223 + "id": "c21_ag_quebec",
224 + "name": "Century 21 Québec (All Quebec)",
225 + "url": "https://quebec.c21.ca",
226 + "listing_url": "https://quebec.c21.ca",
227 + "connector": "c21_ag_quebec",
228 + "status": "actif",
229 + "role": "sous-agence",
230 + "platform": "source.immo",
231 + "note": "Compte source.immo partagé (quebec/excel/immoplus) = inventaire provincial C21 complet (~1600 à vendre). Pagination st/nt. Dédup par n° Centris."
232 + },
233 + {
234 + "id": "c21_ag_vision",
235 + "name": "Century 21 Vision (Outaouais)",
236 + "url": "https://vision.c21.ca",
237 + "listing_url": "https://vision.c21.ca",
238 + "connector": "c21_ag_vision",
239 + "status": "actif",
240 + "role": "sous-agence",
241 + "platform": "source.immo",
242 + "note": "Century 21 Vision (Gatineau) — compte source.immo distinct (~139 à vendre)."
243 + },
244 + {
245 + "id": "duproprio",
246 + "name": "DuProprio (sans courtier)",
247 + "url": "https://duproprio.com",
248 + "listing_url": "https://duproprio.com/sitemaps/fr/index.xml.gz",
249 + "coverage": "Toute la province — vente par le propriétaire (FSBO), ~7 600 propriétés QC",
250 + "connector": "duproprio",
251 + "status": "actif",
252 + "type": "fsbo",
253 + "note": "FSBO. Enumeration via sitemaps FR par region QC (/sitemaps/fr/<region>-listings.xml.gz) : l'URL encode region/ville/type/id. Fiche detail JSON-LD Product (prix, description, galerie photos.duproprio.com). Fetch direct + Scrapfly ASP en secours. agency='DuProprio (sans courtier)'."
254 + },
255 + {
256 + "id": "realta",
257 + "name": "Réalta Agence Immobilière",
258 + "url": "https://www.realta.ca",
259 + "listing_url": "https://www.realta.ca/proprietes/",
260 + "coverage": "Agence indépendante (Québec)",
261 + "connector": "realta",
262 + "status": "actif",
263 + "type": "agence",
264 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 83 propriétés à vendre. Généré par agences_source_immo.py."
265 + },
266 + {
267 + "id": "immodev",
268 + "name": "Immodev Agence Immobilière",
269 + "url": "https://www.immodev.ca",
270 + "listing_url": "https://www.immodev.ca/proprietes/",
271 + "coverage": "Agence indépendante (Québec)",
272 + "connector": "immodev",
273 + "status": "actif",
274 + "type": "agence",
275 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 93 propriétés à vendre. Généré par agences_source_immo.py."
276 + },
277 + {
278 + "id": "gaillardetz",
279 + "name": "Gaillardetz Agence Immobilière",
280 + "url": "https://www.gaillardetz.ca",
281 + "listing_url": "https://www.gaillardetz.ca/proprietes/",
282 + "coverage": "Agence indépendante (Québec)",
283 + "connector": "gaillardetz",
284 + "status": "actif",
285 + "type": "agence",
286 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 13 propriétés à vendre. Généré par agences_source_immo.py."
287 + },
288 + {
289 + "id": "domicilia",
290 + "name": "Domicilia Agence Immobilière",
291 + "url": "https://www.domicilia.com",
292 + "listing_url": "https://www.domicilia.com/proprietes/",
293 + "coverage": "Agence indépendante (Québec)",
294 + "connector": "domicilia",
295 + "status": "actif",
296 + "type": "agence",
297 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 1427 propriétés à vendre. Généré par agences_source_immo.py."
298 + },
299 + {
300 + "id": "niddamour",
301 + "name": "Nid d'Amour Agence Immobilière",
302 + "url": "https://www.niddamour.ca",
303 + "listing_url": "https://www.niddamour.ca/proprietes/",
304 + "coverage": "Agence indépendante (Québec)",
305 + "connector": "niddamour",
306 + "status": "a_faire",
307 + "type": "agence",
308 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 0 propriétés à vendre. Généré par agences_source_immo.py."
309 + },
310 + {
311 + "id": "kw_prestige",
312 + "name": "Keller Williams Prestige",
313 + "url": "https://www.kwprestige.com",
314 + "listing_url": "https://www.kwprestige.com/sitemap.xml",
315 + "coverage": "Grand Montréal — bannière KW prestige",
316 + "connector": "kw_prestige",
317 + "status": "actif",
318 + "type": "franchise",
319 + "note": "Grille/fiche en widget JS (pagination AJAX non exploitable). Enumeration via sitemap.xml (277 fiches a-vendre) ; slug URL = type-a-vendre-ville-secteur-{noCentris}-detail-Fr. Champs liste: type, ville, secteur, n Centris. Prix/photos = widget JS (list cards rendus serveur mais pagination JS ; enrichissement futur via Scrapfly js_scenario)."
320 + },
321 + {
322 + "id": "coldwell_banker",
323 + "name": "Coldwell Banker Québec",
324 + "url": "https://coldwellbanker.ca",
325 + "listing_url": "https://coldwellbanker.ca/",
326 + "coverage": "Québec — franchise (L'Héritage, Prestige…)",
327 + "connector": "coldwell_banker",
328 + "status": "a_faire",
329 + "type": "franchise",
330 + "note": "moxiworks org m102181-1733247042 / company_uuid=4277740. FLUX ANONYME CRACKÉ : GET svc.moxiworks.com/service/v1/listing/search_v2?company_uuid=<id>&bounds_west/east/south/north=..&status=active (pas d'auth ; le WAF n'est que sur listings.moxiworks.com, pas sur svc). MAIS le feed = Rest-of-Canada/MLS (TREB Toronto : MLS C.../T...) ; couverture Québec/Centris ~nulle (Québec-ville=0, Montréal=4 fiches à MLS torontois). Non exploitable pour Immo-Ka (QC). CB Québec quasi inexistant sur ce feed."
331 + },
332 + {
333 + "id": "kijiji",
334 + "name": "Kijiji Québec",
335 + "url": "https://www.kijiji.ca/b-maison-a-vendre/quebec/c35l9001",
336 + "status": "actif",
337 + "notes": "Petites annonces — catégories immobilier à vendre QC seulement (maisons c35, condos c643, terrains c641), __NEXT_DATA__/Apollo."
338 + },
339 + {
340 + "id": "lespac",
341 + "name": "LesPAC Québec",
342 + "url": "https://www.lespac.com/quebec/immobilier-achat-vente_b36k1R2.jsa",
343 + "status": "actif",
344 + "notes": "Petites annonces QC — immobilier achat-vente b37/b38/b39/b40/b41/b42 (pas location ni entreprises), searchResponse embarqué."
345 + },
346 + {
347 + "id": "ubee",
348 + "name": "Ubee",
349 + "url": "https://ubee.com/carte/a-vendre",
350 + "status": "actif",
351 + "notes": "Plateforme QC — API anonyme api.ubee.ca SearchProperties (Seller, résidentiel+commercial), galerie complète."
352 + }
353 + ]
333 354 }
\ No newline at end of file
added immoka/connectors/kijiji.py +102 −0
@@ -0,0 +1,102 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces immobilières
5 +# UNIQUEMENT les catégories immobilier À VENDRE, UNIQUEMENT le Québec (l9001) :
6 +# c35 maisons à vendre · c643 condos à vendre · c641 terrains à vendre
7 +# Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre,
8 +# prix, GPS, adresse et vignette — aucune API privée nécessaire.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import json
13 +import os
14 +import re
15 +
16 +from ..schema import PropertyListing
17 +from .base import BaseConnector
18 +
19 +BASE = "https://www.kijiji.ca"
20 +# (code catégorie, segment d'URL, type canonique)
21 +CATEGORIES = [
22 + (35, "b-maison-a-vendre", "Maison"),
23 + (643, "b-condo-a-vendre", "Condo"),
24 + (641, "b-terrain-a-vendre", "Terrain"),
25 +]
26 +MAX_PAGES = int(os.environ.get("IMMOKA_KIJIJI_MAX_PAGES", "100"))
27 +
28 +
29 +class KijijiConnector(BaseConnector):
30 + source_id = "kijiji"
31 + request_delay = 1.2
32 +
33 + def _next_data(self, html: str) -> dict:
34 + m = re.search(
35 + r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
36 + html, re.S)
37 + return json.loads(m.group(1)) if m else {}
38 +
39 + def _page(self, seg: str, cat: int, page: int) -> list[dict]:
40 + """Annonces (Apollo state) d'une page de catégorie."""
41 + path = (f"{seg}/quebec/c{cat}l9001" if page == 1
42 + else f"{seg}/quebec/page-{page}/c{cat}l9001")
43 + html = self.get(f"{BASE}/{path}").text
44 + data = self._next_data(html)
45 + apollo = (data.get("props", {}).get("pageProps", {})
46 + .get("__APOLLO_STATE__", {}))
47 + return [v for k, v in apollo.items()
48 + if k.startswith("StandardListing:") and isinstance(v, dict)]
49 +
50 + def _to_listing(self, it: dict, ptype: str) -> PropertyListing | None:
51 + lid = str(it.get("id") or "")
52 + url = it.get("url") or ""
53 + if not lid or not url:
54 + return None
55 + price = None
56 + pr = it.get("price") or {}
57 + if isinstance(pr, dict) and pr.get("amount"):
58 + price = round(pr["amount"] / 100.0, 0) # cents → $
59 + loc = it.get("location") or {}
60 + coords = loc.get("coordinates") or {}
61 + address = (loc.get("address") or "").replace(", Canada", "")
62 + # « Saint-Hubert, QC J3Y 6Y3 » → ville avant la 1re virgule
63 + city = loc.get("name") or (address.split(",")[0] if address else "")
64 + images = []
65 + for u in it.get("imageUrls") or []:
66 + images.append(re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u))
67 + return PropertyListing(
68 + source=self.source_id,
69 + external_id=lid,
70 + url=url,
71 + title=it.get("title") or "",
72 + address=address.split(",")[0] if re.match(r"\s*\d", address) else "",
73 + city=city,
74 + property_type=ptype,
75 + price=price,
76 + price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
77 + description=(it.get("description") or "")[:2000],
78 + images=images,
79 + lat=coords.get("latitude"),
80 + lng=coords.get("longitude"),
81 + broker_name="Kijiji (particuliers)",
82 + agency="Kijiji Québec",
83 + )
84 +
85 + def fetch(self) -> list[PropertyListing]:
86 + out: dict[str, PropertyListing] = {}
87 + for cat, seg, ptype in CATEGORIES:
88 + for page in range(1, MAX_PAGES + 1):
89 + try:
90 + items = self._page(seg, cat, page)
91 + except Exception:
92 + break
93 + fresh = 0
94 + for it in items:
95 + lst = self._to_listing(it, ptype)
96 + if lst is not None and lst.uid not in out:
97 + out[lst.uid] = lst
98 + fresh += 1
99 + # plus rien de neuf (page de fin remplie de topAds répétés)
100 + if fresh == 0 or len(items) < 10:
101 + break
102 + return list(out.values())
added immoka/connectors/lespac.py +92 −0
@@ -0,0 +1,92 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (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 ACHAT-VENTE (pas la location, pas les entreprises) :
6 +# b37 résidentiel · b38 terrains · b39 commercial-industriel ·
7 +# b40 chalets · b41 fermes · b42 immeubles à revenus
8 +# Les pages « /quebec/… _b{cat}k{page}R2.jsa » (toute la province) embarquent
9 +# `var searchResponse = {…}` côté serveur : 20 annonces/page + totalPages.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +import re
15 +
16 +from ..schema import PropertyListing
17 +from .base import BaseConnector
18 +
19 +BASE = "https://www.lespac.com"
20 +CATEGORIES = [
21 + (37, "immobilier-achat-vente-residentiel", "Maison"),
22 + (38, "immobilier-achat-vente-terrains", "Terrain"),
23 + (39, "immobilier-achat-vente-commercial-industriel", "Commercial"),
24 + (40, "immobilier-achat-vente-chalets", "Chalet"),
25 + (41, "immobilier-achat-vente-fermes", "Fermette"),
26 + (42, "immobilier-achat-vente-immeubles-a-revenus", "Immeuble à revenus"),
27 +]
28 +_RE_RESP = re.compile(r"var searchResponse = (\{.*?\});\s*[\r\n]", re.S)
29 +
30 +
31 +class LesPacConnector(BaseConnector):
32 + source_id = "lespac"
33 + request_delay = 0.9
34 +
35 + def _search_page(self, slug: str, cat: int, page: int) -> dict | None:
36 + url = f"{BASE}/quebec/{slug}_b{cat}k{page}R2.jsa"
37 + m = _RE_RESP.search(self.get(url).text)
38 + return json.loads(m.group(1)) if m else None
39 +
40 + def _to_listing(self, r: dict, ptype: str) -> PropertyListing | None:
41 + lid = str(r.get("listingPublicId") or "")
42 + url = (r.get("listingDisplayUrl") or "").split("?")[0]
43 + if not lid or not url:
44 + return None
45 + # caractéristiques structurées (« Type de propriété », « Chambres »…)
46 + chars = {c.get("label", ""): str(c.get("value", ""))
47 + for c in r.get("characteristics") or [] if c.get("label")}
48 + def as_int(label):
49 + m = re.search(r"\d+", chars.get(label, ""))
50 + return int(m.group(0)) if m else None
51 + # ville = 1er segment du chemin de l'annonce
52 + seg = url.replace(BASE + "/", "").split("/")
53 + city = seg[0].replace("-", " ").title() if seg else ""
54 + images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery")
55 + for i in r.get("images") or [] if i.get("formattableImageUrl")]
56 + return PropertyListing(
57 + source=self.source_id,
58 + external_id=lid,
59 + url=url,
60 + title=r.get("title") or "",
61 + city=city,
62 + property_type=chars.get("Type de propriété") or ptype,
63 + price=r.get("price"),
64 + price_label=r.get("priceLabel") or "",
65 + bedrooms=as_int("Chambres"),
66 + bathrooms=as_int("Salles de bain") or as_int("Salle de bain"),
67 + year_built=as_int("Année de construction"),
68 + description=(r.get("description") or "")[:2000],
69 + features=[f"{k} : {v}" for k, v in chars.items()],
70 + images=images,
71 + broker_name=r.get("advertiserName") or "LesPAC (particuliers)",
72 + agency="LesPAC Québec",
73 + )
74 +
75 + def fetch(self) -> list[PropertyListing]:
76 + out: dict[str, PropertyListing] = {}
77 + for cat, slug, ptype in CATEGORIES:
78 + page, total_pages = 1, 1
79 + while page <= total_pages:
80 + try:
81 + d = self._search_page(slug, cat, page)
82 + except Exception:
83 + break
84 + if not d:
85 + break
86 + total_pages = min(int(d.get("totalPages") or 1), 400)
87 + for r in d.get("searchResults") or []:
88 + lst = self._to_listing(r, ptype)
89 + if lst is not None:
90 + out.setdefault(lst.uid, lst)
91 + page += 1
92 + return list(out.values())
added immoka/connectors/ubee.py +94 −0
@@ -0,0 +1,94 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (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, prix, pièces,
7 +# superficies m², année, galerie Cloudinary complète).
8 +# Deux volets À VENDRE (listingType=Seller) : résidentiel + commercial.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +from ..schema import PropertyListing
13 +from .base import BaseConnector
14 +
15 +API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties"
16 +SITE = "https://ubee.com"
17 +M2_TO_SQFT = 10.7639
18 +
19 +_TYPES = {
20 + "Unifamiliale": "Maison", "Condo": "Condo", "Terrain": "Terrain",
21 + "Plex": "Immeuble à revenus", "Commercial": "Commercial",
22 + "Fermette": "Fermette", "Chalet": "Chalet",
23 +}
24 +
25 +
26 +class UbeeConnector(BaseConnector):
27 + source_id = "ubee"
28 + request_delay = 0.5
29 +
30 + def _search(self, body: dict) -> list[dict]:
31 + out, page = [], 0
32 + while True:
33 + r = self.post(f"{API}?pageIndex={page}", json=body).json()
34 + results = r.get("results") or []
35 + out.extend(results)
36 + if len(out) >= (r.get("totalCount") or 0) or not results:
37 + break
38 + page += 1
39 + if page > 200: # garde-fou
40 + break
41 + return out
42 +
43 + def _to_listing(self, it: dict) -> PropertyListing | None:
44 + lid = str(it.get("id") or "")
45 + slug = it.get("slugFr") or it.get("slugEn") or ""
46 + if not lid or not slug:
47 + return None
48 + city = (it.get("city") or "").strip()
49 + url = f"{SITE}/a-vendre/{it.get('citySlug') or ''}/{slug}"
50 + images = [im["publicUrls"]["default_size"]
51 + for im in it.get("images") or []
52 + if (im.get("publicUrls") or {}).get("default_size")]
53 + living = it.get("livingSurfaceInMeters")
54 + land = it.get("landSurfaceInMeters")
55 + ptype = _TYPES.get(it.get("inscriptionType") or "",
56 + it.get("inscriptionType") or "")
57 + return PropertyListing(
58 + source=self.source_id,
59 + external_id=lid,
60 + url=url,
61 + title=f"{ptype} à vendre — {city}" if city else f"{ptype} à vendre",
62 + address=it.get("address") or "",
63 + city=city,
64 + property_type=ptype,
65 + price=it.get("askPrice"),
66 + price_label=(f"{it['askPrice']:,.0f} $".replace(",", " ")
67 + if it.get("askPrice") else ""),
68 + bedrooms=it.get("nbBedrooms"),
69 + bathrooms=it.get("nbBathrooms"),
70 + powder_rooms=it.get("nbHalfBaths"),
71 + area_sqft=round(living * M2_TO_SQFT) if living else None,
72 + lot_sqft=round(land * M2_TO_SQFT) if land else None,
73 + year_built=it.get("yearBuilt"),
74 + details={k: it.get(k) for k in
75 + ("propertyType", "buildingType", "toBuild") if it.get(k)},
76 + images=images,
77 + lat=it.get("latitude"),
78 + lng=it.get("longitude"),
79 + broker_name="Ubee",
80 + agency="Ubee Québec",
81 + )
82 +
83 + def fetch(self) -> list[PropertyListing]:
84 + out: dict[str, PropertyListing] = {}
85 + for flags in ({"isResidential": True}, {"isCommercial": True}):
86 + body = {"sortBy": "DateDescending", "listingType": "Seller", **flags}
87 + for it in self._search(body):
88 + # Québec seulement (l'API est QC par nature, on double-vérifie)
89 + if (it.get("province") or "QC") != "QC":
90 + continue
91 + lst = self._to_listing(it)
92 + if lst is not None:
93 + out.setdefault(lst.uid, lst)
94 + return list(out.values())
95