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: 9 nouveaux connecteurs immobiliers QC

eXp Realty Quebec (~1000, SSR ?pages=N), Chambre immo Outaouais
(avecuncourtier ~4000), The Agency Montreal (source.immo), Charisma
(~840), GLMC/Bas-St-Laurent, BHHS Quebec, Image/Saguenay, Charlevoix
(imcha), Immeubles Contact/Alma. Tous en source_id *_ag_* (dedup Centris).
Registres sources.json + source_immo_agencies.json mis a jour.

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

10 changed files +1,510 −521

modified data/source_immo_agencies.json +9 −0
@@ -124,5 +124,14 @@
124 124 "api": "6f4339b4-a5eb-4826-a98b-a8d3b85bc92b",
125 125 "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
126 126 "view": "914b348a-48fe-42c5-96a2-52dd5f6c4307"
127 + },
128 + {
129 + "id": "si_ag_theagency",
130 + "name": "The Agency Montréal",
131 + "domain": "theagencymontreal.com",
132 + "account": "dcb0f01e-8f29-4347-84f0-170875281872",
133 + "api": "2941096d-6a2e-418e-9783-5c3d59790bd0",
134 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
135 + "view": "4cf4819e-a0e9-4288-93d2-002cd8f40435"
127 136 }
128 137 ]
\ No newline at end of file
modified data/sources.json +609 −521
@@ -1,523 +1,611 @@
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 − {
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 − {
354 − "id": "vendre_ag_ca",
355 − "name": "Vendre.ca",
356 − "url": "https://www.vendre.ca",
357 − "listing_url": "https://www.vendre.ca/sitemap.xml",
358 − "coverage": "Toute la province — portail hybride courtiers + vendeurs privés, ~9 500 fiches",
359 − "connector": "vendre_ca",
360 − "status": "actif",
361 − "type": "plateforme",
362 − "note": "Énumération via 4 sitemaps (res/mpx/com/lnd, URLs FR : type+ville+adresse+VID). Fiche détail JSON-LD Place+RealEstateListing+Offer (adresse exacte, géoloc, prix, année, courtier, n° MLS) + galerie cloud.vendre.ca _lrg ; enrichissement cache+plafond (IMMOKA_VENDRE_DETAIL_LIMIT). Infixe _ag_ : dédup Centris quand le MLS est connu (fiches de bannières couvertes masquées, vendeurs privés toujours visibles)."
363 − },
364 − {
365 − "id": "publimaison_ag_qc",
366 − "name": "Publimaison",
367 − "url": "https://www.publimaison.ca",
368 − "listing_url": "https://www.publimaison.ca/fr/recherche/",
369 − "coverage": "Toute la province — 2e FSBO du Québec (~5 400 annonces) + transferts Centris de courtiers",
370 − "connector": "publimaison",
371 − "status": "actif",
372 − "type": "fsbo",
373 − "note": "Recherche rendue serveur pilotée par pseudo-hash d'URL (8 cartes/page, ~680 pages, total « sur NNNN », pages promues qui se chevauchent -> arrêt après 3 pages sèches). Cartes riches (réf MLS, type, ville, prix, chambres, adresse, extrait, photo). Fiche détail HTML sans JSON-LD : galerie fr_maison_original, code postal, pièces+dimensions, taxes/évaluation (IMMOKA_PUBLIMAISON_DETAIL_LIMIT). Infixe _ag_ : réf 7-8 chiffres = n° MLS -> dédup Centris."
374 − },
375 − {
376 − "id": "logis_ag_quebec",
377 − "name": "LogisQuébec (à vendre)",
378 − "url": "https://www.logisquebec.com",
379 − "listing_url": "https://www.logisquebec.com/a-vendre",
380 − "coverage": "Toute la province — ~4 200 propriétés à vendre (particuliers + agences syndiquées)",
381 − "connector": "logisquebec",
382 − "status": "actif",
383 − "type": "plateforme",
384 − "note": "Section /a-vendre seulement (pas la location). Pagination /a-vendre/{page} (10 cartes/page, ~420 pages). Fiche détail JSON-LD SingleFamilyResidence+Product (adresse, code postal, géoloc, description, prix) + n° MLS dans le HTML + galerie i.logisquebec.com (IMMOKA_LOGISQC_DETAIL_LIMIT). Infixe _ag_ : dédup Centris pour les annonces d'agences (ex. Proprio Direct syndiqué), particuliers toujours visibles."
385 − },
386 − {
387 − "id": "si_ag_katia_samson",
388 − "name": "Groupe Immobilier Katia Samson",
389 − "url": "https://katiasamson.com",
390 − "coverage": "Grand Montréal / Ouest-de-l'Île — ~36 inscriptions",
391 − "connector": "agences_source_immo",
392 − "status": "actif",
393 − "type": "agence",
394 − "note": "Plateforme source.immo (config publique _configs.json). Infixe _ag_ : co-inscriptions Centris dédupliquées contre les bannières."
395 − },
396 − {
397 − "id": "si_ag_wparwilly",
398 − "name": "W par Willy Forge Agence Immobilière",
399 − "url": "https://wparwilly.com",
400 − "coverage": "Montréal — petite agence indépendante",
401 − "connector": "agences_source_immo",
402 − "status": "actif",
403 − "type": "agence",
404 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
405 − },
406 − {
407 − "id": "si_ag_avenueimmo",
408 − "name": "Avenue Immobilier",
409 − "url": "https://avenueimmo.com",
410 − "coverage": "~65 inscriptions",
411 − "connector": "agences_source_immo",
412 − "status": "actif",
413 − "type": "agence",
414 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
415 − },
416 − {
417 − "id": "si_ag_ferri",
418 − "name": "Services Immobiliers Ferri",
419 − "url": "https://ferri.ca",
420 − "coverage": "~48 inscriptions",
421 − "connector": "agences_source_immo",
422 − "status": "actif",
423 − "type": "agence",
424 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
425 − },
426 − {
427 − "id": "si_ag_jfmorin",
428 − "name": "Équipe Jean-François Morin",
429 − "url": "https://jeanfrancoismorin.ca",
430 − "coverage": "~44 inscriptions",
431 − "connector": "agences_source_immo",
432 − "status": "actif",
433 − "type": "agence",
434 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
435 − },
436 − {
437 − "id": "si_ag_jga",
438 − "name": "JGA Commercial (Équipe Jean-Guy Ayotte)",
439 − "url": "https://equipejeanguyayotte.com",
440 − "coverage": "Mauricie — ~92 inscriptions (Royal LePage, surtout commercial)",
441 − "connector": "agences_source_immo",
442 − "status": "actif",
443 − "type": "agence",
444 − "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)."
445 − },
446 − {
447 − "id": "si_ag_provad",
448 − "name": "Proulx Vadnais & Associés",
449 − "url": "https://provad.ca",
450 − "coverage": "~100+ inscriptions (commercial/multilogement)",
451 − "connector": "agences_source_immo",
452 − "status": "actif",
453 − "type": "agence",
454 − "note": "Plateforme source.immo (provad.ca = proulxvadnais.com, même compte). Généré par agences_source_immo.py."
455 − },
456 − {
457 − "id": "si_ag_amyassaad",
458 − "name": "Équipe Amy Assaad",
459 − "url": "https://amyassaad.ca",
460 − "coverage": "Montréal — ~100+ inscriptions (Royal LePage)",
461 − "connector": "agences_source_immo",
462 − "status": "actif",
463 − "type": "agence",
464 − "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)."
465 − },
466 − {
467 − "id": "si_ag_peladeau",
468 − "name": "Péladeau Agence Immobilière",
469 − "url": "https://epeladeau.com",
470 − "coverage": "~82 inscriptions",
471 − "connector": "agences_source_immo",
472 − "status": "actif",
473 − "type": "agence",
474 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
475 − },
476 − {
477 − "id": "royal_lepage_humania",
478 − "name": "Royal LePage Humania",
479 − "url": "https://royallepagehumania.ca",
480 − "status": "ecarte",
481 − "note": "ÉCARTÉ : son compte source.immo (02f9532b-…) est IDENTIQUE à celui de Domicilia (domicilia.com = portail d'Humania, déjà couvert, 1 427 fiches). L'ajouter doublerait tout l'inventaire."
482 − },
483 − {
484 − "id": "adresz",
485 − "name": "Réseau Immobilier Adresz",
486 − "url": "https://www.adresz.ca",
487 − "listing_url": "https://www.adresz.ca/fr/inscriptions-propriete/",
488 − "coverage": "~55 courtiers, ~770 inscriptions",
489 − "status": "a_faire",
490 − "type": "agence",
491 − "note": "Site custom Symfony rendu serveur, pagination ?page=N&limit=12, photos /uploads/photos/. Pas de source.immo ni sitemap. Vérifier part actifs vs vendus avant d'écrire le connecteur."
492 − },
493 − {
494 − "id": "pmml",
495 − "name": "PMML (multilogement/commercial)",
496 − "url": "https://pmml.ca",
497 − "listing_url": "https://pmml.ca/proprietes",
498 − "coverage": "Leader QC multilogement/commercial — ~500 inscriptions",
499 − "status": "a_faire",
500 − "type": "agence",
501 − "note": "WordPress+Yoast mais tout derrière challenge Cloudflare (403) et fiches chargées en JS via API interne. Nécessite Scrapfly asp+render_js ; forte valeur segment multilogement."
502 − },
503 − {
504 − "id": "vistoo",
505 − "name": "Vistoo (immobilier neuf)",
506 − "url": "https://vistoo.com",
507 − "listing_url": "https://vistoo.com/sitemap.xml",
508 − "coverage": "Projets neufs QC — ~1 900 URLs for-sale au sitemap",
509 − "status": "a_faire",
510 − "type": "plateforme",
511 − "note": "Segment « neuf » (projets + unités) non couvert. React/SSR, sitemap riche, métadonnées propres. Complément : guidehabitation.ca (191 projets)."
512 − },
513 − {
514 − "id": "londono",
515 − "name": "Londono Realty Group",
516 − "url": "https://www.londonogroup.com",
517 − "coverage": "Westmount/Montréal — luxe, ~89 courtiers",
518 − "status": "a_faire",
519 − "type": "luxe",
520 − "note": "Site PHP custom daté (iso-8859-1, rendu serveur), pas de sitemap/robots ni source.immo ; scraper le moteur de recherche PHP directement. Volume ~200."
521 − }
522 − ]
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_ag_qc",
70 + "name": "eXp Agence immobilière (Québec)",
71 + "url": "https://expquebec.com",
72 + "listing_url": "https://expquebec.com/fr/properties/",
73 + "coverage": "Toute la province — agence virtuelle (~1000+ inscriptions)",
74 + "connector": "exp_ag_qc",
75 + "status": "actif",
76 + "type": "agence",
77 + "note": "Site officiel eXp QC (expquebec.com, WordPress + MW Properties). API cliente marketingwebsites.ca bloquée (whitelist IP) MAIS inventaire complet en SSR sur /fr/properties/?pages=N (9/page, param 'pages', stop après 3 pages vides). Carte: data-href mls={Centris}, data-price ($US), data-address, data-pic. Ville via map-property.php?mls={Centris}&lang=fr (enrich requests, plafonné+cache). source_id _ag_ = dédup Centris."
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 + {
354 + "id": "vendre_ag_ca",
355 + "name": "Vendre.ca",
356 + "url": "https://www.vendre.ca",
357 + "listing_url": "https://www.vendre.ca/sitemap.xml",
358 + "coverage": "Toute la province — portail hybride courtiers + vendeurs privés, ~9 500 fiches",
359 + "connector": "vendre_ca",
360 + "status": "actif",
361 + "type": "plateforme",
362 + "note": "Énumération via 4 sitemaps (res/mpx/com/lnd, URLs FR : type+ville+adresse+VID). Fiche détail JSON-LD Place+RealEstateListing+Offer (adresse exacte, géoloc, prix, année, courtier, n° MLS) + galerie cloud.vendre.ca _lrg ; enrichissement cache+plafond (IMMOKA_VENDRE_DETAIL_LIMIT). Infixe _ag_ : dédup Centris quand le MLS est connu (fiches de bannières couvertes masquées, vendeurs privés toujours visibles)."
363 + },
364 + {
365 + "id": "publimaison_ag_qc",
366 + "name": "Publimaison",
367 + "url": "https://www.publimaison.ca",
368 + "listing_url": "https://www.publimaison.ca/fr/recherche/",
369 + "coverage": "Toute la province — 2e FSBO du Québec (~5 400 annonces) + transferts Centris de courtiers",
370 + "connector": "publimaison",
371 + "status": "actif",
372 + "type": "fsbo",
373 + "note": "Recherche rendue serveur pilotée par pseudo-hash d'URL (8 cartes/page, ~680 pages, total « sur NNNN », pages promues qui se chevauchent -> arrêt après 3 pages sèches). Cartes riches (réf MLS, type, ville, prix, chambres, adresse, extrait, photo). Fiche détail HTML sans JSON-LD : galerie fr_maison_original, code postal, pièces+dimensions, taxes/évaluation (IMMOKA_PUBLIMAISON_DETAIL_LIMIT). Infixe _ag_ : réf 7-8 chiffres = n° MLS -> dédup Centris."
374 + },
375 + {
376 + "id": "logis_ag_quebec",
377 + "name": "LogisQuébec (à vendre)",
378 + "url": "https://www.logisquebec.com",
379 + "listing_url": "https://www.logisquebec.com/a-vendre",
380 + "coverage": "Toute la province — ~4 200 propriétés à vendre (particuliers + agences syndiquées)",
381 + "connector": "logisquebec",
382 + "status": "actif",
383 + "type": "plateforme",
384 + "note": "Section /a-vendre seulement (pas la location). Pagination /a-vendre/{page} (10 cartes/page, ~420 pages). Fiche détail JSON-LD SingleFamilyResidence+Product (adresse, code postal, géoloc, description, prix) + n° MLS dans le HTML + galerie i.logisquebec.com (IMMOKA_LOGISQC_DETAIL_LIMIT). Infixe _ag_ : dédup Centris pour les annonces d'agences (ex. Proprio Direct syndiqué), particuliers toujours visibles."
385 + },
386 + {
387 + "id": "si_ag_katia_samson",
388 + "name": "Groupe Immobilier Katia Samson",
389 + "url": "https://katiasamson.com",
390 + "coverage": "Grand Montréal / Ouest-de-l'Île — ~36 inscriptions",
391 + "connector": "agences_source_immo",
392 + "status": "actif",
393 + "type": "agence",
394 + "note": "Plateforme source.immo (config publique _configs.json). Infixe _ag_ : co-inscriptions Centris dédupliquées contre les bannières."
395 + },
396 + {
397 + "id": "si_ag_wparwilly",
398 + "name": "W par Willy Forge Agence Immobilière",
399 + "url": "https://wparwilly.com",
400 + "coverage": "Montréal — petite agence indépendante",
401 + "connector": "agences_source_immo",
402 + "status": "actif",
403 + "type": "agence",
404 + "note": "Plateforme source.immo. Généré par agences_source_immo.py."
405 + },
406 + {
407 + "id": "si_ag_avenueimmo",
408 + "name": "Avenue Immobilier",
409 + "url": "https://avenueimmo.com",
410 + "coverage": "~65 inscriptions",
411 + "connector": "agences_source_immo",
412 + "status": "actif",
413 + "type": "agence",
414 + "note": "Plateforme source.immo. Généré par agences_source_immo.py."
415 + },
416 + {
417 + "id": "si_ag_ferri",
418 + "name": "Services Immobiliers Ferri",
419 + "url": "https://ferri.ca",
420 + "coverage": "~48 inscriptions",
421 + "connector": "agences_source_immo",
422 + "status": "actif",
423 + "type": "agence",
424 + "note": "Plateforme source.immo. Généré par agences_source_immo.py."
425 + },
426 + {
427 + "id": "si_ag_jfmorin",
428 + "name": "Équipe Jean-François Morin",
429 + "url": "https://jeanfrancoismorin.ca",
430 + "coverage": "~44 inscriptions",
431 + "connector": "agences_source_immo",
432 + "status": "actif",
433 + "type": "agence",
434 + "note": "Plateforme source.immo. Généré par agences_source_immo.py."
435 + },
436 + {
437 + "id": "si_ag_jga",
438 + "name": "JGA Commercial (Équipe Jean-Guy Ayotte)",
439 + "url": "https://equipejeanguyayotte.com",
440 + "coverage": "Mauricie — ~92 inscriptions (Royal LePage, surtout commercial)",
441 + "connector": "agences_source_immo",
442 + "status": "actif",
443 + "type": "agence",
444 + "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)."
445 + },
446 + {
447 + "id": "si_ag_provad",
448 + "name": "Proulx Vadnais & Associés",
449 + "url": "https://provad.ca",
450 + "coverage": "~100+ inscriptions (commercial/multilogement)",
451 + "connector": "agences_source_immo",
452 + "status": "actif",
453 + "type": "agence",
454 + "note": "Plateforme source.immo (provad.ca = proulxvadnais.com, même compte). Généré par agences_source_immo.py."
455 + },
456 + {
457 + "id": "si_ag_amyassaad",
458 + "name": "Équipe Amy Assaad",
459 + "url": "https://amyassaad.ca",
460 + "coverage": "Montréal — ~100+ inscriptions (Royal LePage)",
461 + "connector": "agences_source_immo",
462 + "status": "actif",
463 + "type": "agence",
464 + "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)."
465 + },
466 + {
467 + "id": "si_ag_peladeau",
468 + "name": "Péladeau Agence Immobilière",
469 + "url": "https://epeladeau.com",
470 + "coverage": "~82 inscriptions",
471 + "connector": "agences_source_immo",
472 + "status": "actif",
473 + "type": "agence",
474 + "note": "Plateforme source.immo. Généré par agences_source_immo.py."
475 + },
476 + {
477 + "id": "royal_lepage_humania",
478 + "name": "Royal LePage Humania",
479 + "url": "https://royallepagehumania.ca",
480 + "status": "ecarte",
481 + "note": "ÉCARTÉ : son compte source.immo (02f9532b-…) est IDENTIQUE à celui de Domicilia (domicilia.com = portail d'Humania, déjà couvert, 1 427 fiches). L'ajouter doublerait tout l'inventaire."
482 + },
483 + {
484 + "id": "adresz",
485 + "name": "Réseau Immobilier Adresz",
486 + "url": "https://www.adresz.ca",
487 + "listing_url": "https://www.adresz.ca/fr/inscriptions-propriete/",
488 + "coverage": "~55 courtiers, ~770 inscriptions",
489 + "status": "a_faire",
490 + "type": "agence",
491 + "note": "Site custom Symfony rendu serveur, pagination ?page=N&limit=12, photos /uploads/photos/. Pas de source.immo ni sitemap. Vérifier part actifs vs vendus avant d'écrire le connecteur."
492 + },
493 + {
494 + "id": "pmml",
495 + "name": "PMML (multilogement/commercial)",
496 + "url": "https://pmml.ca",
497 + "listing_url": "https://pmml.ca/proprietes",
498 + "coverage": "Leader QC multilogement/commercial — ~500 inscriptions",
499 + "status": "a_faire",
500 + "type": "agence",
501 + "note": "WordPress+Yoast mais tout derrière challenge Cloudflare (403) et fiches chargées en JS via API interne. Nécessite Scrapfly asp+render_js ; forte valeur segment multilogement."
502 + },
503 + {
504 + "id": "vistoo",
505 + "name": "Vistoo (immobilier neuf)",
506 + "url": "https://vistoo.com",
507 + "listing_url": "https://vistoo.com/sitemap.xml",
508 + "coverage": "Projets neufs QC — ~1 900 URLs for-sale au sitemap",
509 + "status": "a_faire",
510 + "type": "plateforme",
511 + "note": "Segment « neuf » (projets + unités) non couvert. React/SSR, sitemap riche, métadonnées propres. Complément : guidehabitation.ca (191 projets)."
512 + },
513 + {
514 + "id": "londono",
515 + "name": "Londono Realty Group",
516 + "url": "https://www.londonogroup.com",
517 + "coverage": "Westmount/Montréal — luxe, ~89 courtiers",
518 + "status": "a_faire",
519 + "type": "luxe",
520 + "note": "Site PHP custom daté (iso-8859-1, rendu serveur), pas de sitemap/robots ni source.immo ; scraper le moteur de recherche PHP directement. Volume ~200."
521 + },
522 + {
523 + "id": "si_ag_theagency",
524 + "name": "The Agency Montréal",
525 + "url": "https://theagencymontreal.com",
526 + "listing_url": "https://theagencymontreal.com/fr/proprietes",
527 + "coverage": "Grand Montréal — boutique luxe (indépendant)",
528 + "connector": "si_ag_theagency",
529 + "status": "actif",
530 + "type": "agence",
531 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). Généré par agences_source_immo.py. source_id _ag_ = dédup Centris (co-listings luxe avec Sotheby's/E&V)."
532 + },
533 + {
534 + "id": "contact_ag_qc",
535 + "name": "Les Immeubles Contact",
536 + "url": "https://www.lesimmeublescontact.com",
537 + "listing_url": "https://www.lesimmeublescontact.com/immeubles_centris.php?page=residentiel",
538 + "coverage": "Alma / Lac-Saint-Jean-Est + Chibougamau-Chapais",
539 + "connector": "immeuble_contact",
540 + "status": "actif",
541 + "type": "agence",
542 + "note": "Site PHP maison (ISO-8859-1), liste SSR immeubles_centris.php (residentiel/revenu/commercial). Carte: data-cost, no_mls Centris, adresse, ville, prix, photo. source_id _ag_ = dédup Centris."
543 + },
544 + {
545 + "id": "cio_ag_outaouais",
546 + "name": "Chambre immobilière de l'Outaouais",
547 + "url": "https://avecuncourtier.com",
548 + "listing_url": "https://avecuncourtier.com/fr/proprietes",
549 + "coverage": "Tout l'Outaouais (Gatineau → Maniwaki/Pontiac) — portail MLS régional",
550 + "connector": "avecuncourtier",
551 + "status": "actif",
552 + "type": "portail",
553 + "note": "Portail de chambre (Concrete5/Tonik). Liste SSR paginée ?page=N (12/page, ~341 pages). Carte: MLS Centris, adresse, ville, type, prix, photo. source_id _ag_ = dédup Centris (agrège bannières couvertes de l'Outaouais)."
554 + },
555 + {
556 + "id": "glmc_ag_qc",
557 + "name": "Les Immeubles GLMC",
558 + "url": "https://www.glmc.ca",
559 + "listing_url": "https://www.glmc.ca/proprietes/",
560 + "coverage": "Rivière-du-Loup / KRTB / Témiscouata / Les Basques (Bas-Saint-Laurent)",
561 + "connector": "glmc_ag_qc",
562 + "status": "actif",
563 + "type": "agence",
564 + "note": "WordPress (thème X), liste SSR paginée ?_paged=N (60/page). Carte: URL /{slug}-{Centris}/, adresse, ville, prix, photo ; détail enrichi (JSON-LD + tableau Centris). source_id _ag_ = dédup Centris."
565 + },
566 + {
567 + "id": "image_ag_qc",
568 + "name": "Image, l'agence immobilière",
569 + "url": "https://agenceimage.ca",
570 + "listing_url": "https://agenceimage.ca/mes-proprietes/",
571 + "coverage": "Saguenay–Lac-Saint-Jean (Saguenay, Jonquière, Roberval, Dolbeau)",
572 + "connector": "image_ag_qc",
573 + "status": "actif",
574 + "type": "agence",
575 + "note": "Plateforme Yoamo (ID-3) + photos Centris. /mes-proprietes/ rend tout en SSR : data-filter-* (prix, ville, genre, chambres/sdb) + data-geo-lat/lng. source_id _ag_ = dédup Centris."
576 + },
577 + {
578 + "id": "charisma_ag_qc",
579 + "name": "Les Immeubles Charisma",
580 + "url": "https://charisma.ca",
581 + "listing_url": "https://charisma.ca/fr/proprietes/",
582 + "coverage": "Laval, Montréal, Montérégie — 3e bannière indépendante du Québec",
583 + "connector": "charisma_ag_qc",
584 + "status": "actif",
585 + "type": "agence",
586 + "note": "WordPress (MW Properties / marketingwebsites.ca), liste SSR paginée ?pages=N (~10/page). Carte box-house: no Centris, prix ($US), ville, chambres/sdb, photo. ⚠ fusion avec L'Expert Immobilier P.M. (2021) → source_id _ag_ = dédup Centris."
587 + },
588 + {
589 + "id": "imcha_ag_qc",
590 + "name": "Les Immeubles Charlevoix",
591 + "url": "https://www.imcha.com",
592 + "listing_url": "https://www.imcha.com/vente-de-proprietes/page-1",
593 + "coverage": "Charlevoix (Baie-Saint-Paul, La Malbaie, Les Éboulements) + Côte-de-Beaupré",
594 + "connector": "imcha_ag_qc",
595 + "status": "actif",
596 + "type": "agence",
597 + "note": "Site custom SSR, liste paginée /vente-de-proprietes/page-N. Carte li.estate-single: type, ville, prix (souvent « Prix sur demande » à Charlevoix), chambres, photo. n° Centris extrait du nom de fichier photo (/uploads/photos/…/{Centris}-…) → dédup. source_id _ag_."
598 + },
599 + {
600 + "id": "bhhs_ag_qc",
601 + "name": "Berkshire Hathaway HomeServices Québec",
602 + "url": "https://bhhsquebec.ca",
603 + "listing_url": "https://bhhsquebec.ca/proprietes/",
604 + "coverage": "Grand Montréal, Laurentides, Montérégie, Outaouais",
605 + "connector": "bhhs_ag_qc",
606 + "status": "actif",
607 + "type": "franchise",
608 + "note": "WordPress (MW Properties / marketingwebsites.ca), liste SSR paginée ?pages=N (~9/page, ~11 pages). Carte col-lg-4: no Centris (URL+image+property-meta), région, adresse, ville, prix ($US), chambres/sdb, photo. source_id _ag_ = dédup Centris."
609 + }
610 + ]
523 611 }
\ No newline at end of file
added immoka/connectors/agenceimage.py +98 −0
@@ -0,0 +1,98 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/agenceimage.py : Image, l'agence immobilière (agenceimage.ca) —
5 +# Saguenay, Jonquière, Roberval, Dolbeau-Mistassini, Lac-Saint-Jean.
6 +# Plateforme Yoamo (ID-3 Innovations) + photos Centris. La page /mes-proprietes/
7 +# rend TOUT l'inventaire côté serveur : chaque carte porte n° Centris (rel/URL),
8 +# type + ville (dans l'URL et l'alt), coordonnées (data-geo-lat/lng), prix et
9 +# photo (yoamo.immo/ALSPicture.axd?propId={Centris}).
10 +#
11 +# source_id « image_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —
12 +# masque les fiches déjà portées par une bannière couverte au Saguenay–LSJ.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import html as _html
17 +import re
18 +
19 +from .base import BaseConnector
20 +from ..normalize import parse_price
21 +from ..schema import PropertyListing
22 +
23 +SITE = "https://agenceimage.ca"
24 +LISTING = SITE + "/mes-proprietes/"
25 +AGENCY = "Image, l'agence immobilière"
26 +
27 +# chaque fiche = un <article ... lid="{Centris}"> dont les data-filter-* portent
28 +# tout (prix, ville, genre, chambres, sdb, coords) — plus fiable que le HTML visible.
29 +_ART_SPLIT = re.compile(r'<article\b', re.I)
30 +_LID_RE = re.compile(r'l(?:id|s)="(\d{6,9})"', re.I)
31 +_REL_RE = re.compile(r'rel="(/proprietes/a-vendre/[a-z0-9-]+/[a-z0-9-]+/\d{6,9}/)"', re.I)
32 +_IMG_RE = re.compile(r'(https://yoamo\.immo/ALSPicture\.axd\?propId=\d+[^"\']*)', re.I)
33 +
34 +
35 +def _attr(blk: str, name: str) -> str:
36 + m = re.search(name + r'="([^"]*)"', blk, re.I)
37 + return _html.unescape(m.group(1)).strip() if m else ""
38 +
39 +
40 +def _deslug(s: str) -> str:
41 + return _html.unescape(s.replace("-", " ")).strip().title()
42 +
43 +
44 +class AgenceImageConnector(BaseConnector):
45 + source_id = "image_ag_qc"
46 + request_delay = 0.5
47 +
48 + def fetch(self) -> list[PropertyListing]:
49 + try:
50 + html = self.get(LISTING).text
51 + except Exception:
52 + return []
53 + by_id: dict[str, PropertyListing] = {}
54 + for blk in _ART_SPLIT.split(html)[1:]:
55 + blk = blk[:3000]
56 + lst = self._card(blk)
57 + if lst:
58 + by_id.setdefault(lst.external_id, lst)
59 + return list(by_id.values())
60 +
61 + def _card(self, blk: str) -> PropertyListing | None:
62 + lm = _LID_RE.search(blk)
63 + if not lm:
64 + return None
65 + mls = lm.group(1)
66 + # écarter locations / vendus / loués
67 + if _attr(blk, "data-filter-rental") == "true" or _attr(blk, "data-filter-sold") == "true":
68 + return None
69 +
70 + rel = _REL_RE.search(blk)
71 + url = f"{SITE}{rel.group(1)}" if rel else f"{SITE}/proprietes/"
72 + genre = _attr(blk, "data-filter-genre")
73 + city = _attr(blk, "data-filter-city")
74 + price = _attr(blk, "data-filter-price")
75 + beds = _attr(blk, "data-filter-bedrooms")
76 + baths = _attr(blk, "data-filter-bathrooms")
77 + lat = _attr(blk, "data-geo-lat")
78 + lng = _attr(blk, "data-geo-lng")
79 + im = _IMG_RE.search(blk)
80 +
81 + return PropertyListing(
82 + source=self.source_id,
83 + external_id=mls,
84 + url=url,
85 + title=f"{genre} à vendre, {city}".strip(" ,") or "Propriété à vendre",
86 + property_type=genre,
87 + city=city,
88 + price=float(price) if price.isdigit() else None,
89 + price_label=(f"{int(price):,} $".replace(",", " ") if price.isdigit() else ""),
90 + bedrooms=int(beds) if beds.isdigit() else None,
91 + bathrooms=int(baths) if baths.isdigit() else None,
92 + mls=mls,
93 + images=[im.group(1).replace("&w=320", "")] if im else [],
94 + lat=float(lat) if lat else None,
95 + lng=float(lng) if lng else None,
96 + agency=AGENCY,
97 + broker_name=AGENCY,
98 + )
added immoka/connectors/avecuncourtier.py +109 −0
@@ -0,0 +1,109 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/avecuncourtier.py : portail « avec un courtier » de la Chambre
5 +# immobilière de l'Outaouais (avecuncourtier.com). Couvre TOUT l'Outaouais
6 +# (Gatineau → Maniwaki/Pontiac) d'un coup — c'est le portail MLS régional de
7 +# la chambre, pas une agence unique. Recherche rendue SERVEUR (plateforme
8 +# Concrete5, Tonik Web Studio), paginée par ?page=N (12 cartes/page, ~341
9 +# pages). Chaque carte porte déjà : n° MLS (Centris), adresse, ville/secteur,
10 +# type, aire habitable, prix et photo.
11 +#
12 +# source_id « cio_ag_outaouais » : l'infixe _ag_ active la dédup Centris
13 +# (db.refresh_dedup, sources %_ag_%). Comme le portail agrège les inscriptions
14 +# des bannières déjà couvertes (RE/MAX, Royal LePage, Via Capitale, Proprio
15 +# Direct…), la dédup masque les fiches dont le n° Centris est déjà porté par
16 +# une source canonique ; seules les inscriptions d'agences NON couvertes de
17 +# l'Outaouais restent visibles. Aucun double-comptage.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import html as _html
22 +import os
23 +import re
24 +
25 +from .base import BaseConnector
26 +from ..normalize import parse_price
27 +from ..schema import PropertyListing
28 +
29 +SITE = "https://avecuncourtier.com"
30 +LISTING = SITE + "/fr/proprietes"
31 +MAX_PAGES = int(os.environ.get("IMMOKA_AUC_MAX_PAGES", "400"))
32 +AGENCY = "Chambre immobilière de l'Outaouais"
33 +
34 +# une carte commence par <div class="photo "> et court jusqu'à la fermeture du
35 +# bloc listing suivant ; on la borne au <div class="photo "> suivant.
36 +_CARD_SPLIT = re.compile(r'<div class="photo\s*">')
37 +_HREF_RE = re.compile(r'/fr/proprietes/details/(\d{6,9})-([a-z0-9-]+)', re.I)
38 +_ALT_RE = re.compile(r'<img[^>]+alt="([^"]*)"', re.I)
39 +_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"', re.I)
40 +_MLS_RE = re.compile(r'MLS[^0-9]{0,3}(\d{6,9})', re.I)
41 +_PRICE_RE = re.compile(r'class="price">\s*([\d\s ,.]+\$[^<]*)<', re.I)
42 +_ADDR_RE = re.compile(r'<address>\s*<span>([^<]*)</span>\s*(?:<br\s*/?>)?\s*([^<]*)</address>', re.S | re.I)
43 +_TYPE_RE = re.compile(r'header">\s*Type\s*</div>\s*<div class="column">\s*([^<]+?)\s*</div>', re.S | re.I)
44 +
45 +
46 +class AvecUnCourtierConnector(BaseConnector):
47 + source_id = "cio_ag_outaouais"
48 + request_delay = 0.4
49 + max_pages = MAX_PAGES
50 +
51 + def fetch(self) -> list[PropertyListing]:
52 + by_id: dict[str, PropertyListing] = {}
53 + dry = 0
54 + for page in range(1, self.max_pages + 1):
55 + url = f"{LISTING}?page={page}"
56 + try:
57 + html = self.get(url).text
58 + except Exception:
59 + break
60 + before = len(by_id)
61 + for blk in _CARD_SPLIT.split(html)[1:]:
62 + blk = blk[:3000]
63 + lst = self._card(blk)
64 + if lst:
65 + by_id.setdefault(lst.external_id, lst)
66 + # fin de pagination : plus aucune carte neuve sur 2 pages de suite
67 + dry = dry + 1 if len(by_id) == before else 0
68 + if dry >= 2:
69 + break
70 + return list(by_id.values())
71 +
72 + def _card(self, blk: str) -> PropertyListing | None:
73 + hm = _HREF_RE.search(blk)
74 + if not hm:
75 + return None
76 + mls = hm.group(1)
77 +
78 + addr, city = "", ""
79 + am = _ADDR_RE.search(blk)
80 + if am:
81 + addr = _html.unescape(am.group(1)).strip()
82 + city = _html.unescape(re.sub(r'\s+', ' ', am.group(2))).strip()
83 + if not addr:
84 + alt = _ALT_RE.search(blk)
85 + addr = _html.unescape(alt.group(1)).strip() if alt else ""
86 +
87 + pm = _PRICE_RE.search(blk)
88 + price_label = _html.unescape(pm.group(1)).replace(" ", " ").strip() if pm else ""
89 + tm = _TYPE_RE.search(blk)
90 + prop_type = _html.unescape(tm.group(1)).strip() if tm else ""
91 + im = _IMG_RE.search(blk)
92 + img = im.group(1) if im else ""
93 + if img and img.startswith("/"):
94 + img = SITE + img
95 +
96 + return PropertyListing(
97 + source=self.source_id,
98 + external_id=mls,
99 + url=f"{SITE}/fr/proprietes/details/{mls}-{hm.group(2)}",
100 + title=addr or f"{prop_type} à vendre",
101 + address=addr,
102 + city=city,
103 + property_type=prop_type,
104 + price=parse_price(price_label),
105 + price_label=price_label,
106 + mls=mls,
107 + images=[img] if img else [],
108 + agency=AGENCY,
109 + )
added immoka/connectors/bhhs_quebec.py +101 −0
@@ -0,0 +1,101 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/bhhs_quebec.py : Berkshire Hathaway HomeServices Québec
5 +# (bhhsquebec.ca) — Grand Montréal, Laurentides, Montérégie, Outaouais.
6 +# Site WordPress (plugin « MW Properties » / realestate.marketingwebsites.ca),
7 +# liste rendue SERVEUR sur /proprietes/, paginée par ?pages=N (~9/page,
8 +# ~11 pages). Chaque carte porte : n° Centris (URL + image + property-meta),
9 +# région, adresse, ville, prix ($US), chambres/sdb et photo.
10 +#
11 +# source_id « bhhs_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup).
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import html as _html
16 +import re
17 +import urllib.parse
18 +
19 +from .base import BaseConnector
20 +from ..normalize import parse_price
21 +from ..schema import PropertyListing
22 +
23 +SITE = "https://bhhsquebec.ca"
24 +LISTING = SITE + "/proprietes/"
25 +AGENCY = "Berkshire Hathaway HomeServices Québec"
26 +
27 +_CARD_SPLIT = re.compile(r'<a href="(https://bhhsquebec\.ca/properties/[^"]*?/(\d{6,9}))"\s+class="list"', re.I)
28 +_IMG_RE = re.compile(r'(https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\']+\.(?:jpg|jpeg|png|webp))', re.I)
29 +_REGION_RE = re.compile(r'<h3>.*?</i>\s*([^<]+)</h3>', re.S | re.I)
30 +_ADDR_RE = re.compile(r'<div class="top-ctn">.*?<p>([^<]+)</p>', re.S | re.I)
31 +_PRICE_RE = re.compile(r'class="price">\s*(\$[\d,]+|[\d\s ]+\$)', re.I)
32 +_AMEN_RE = re.compile(r'(?:bed|hotel)[^>]*></i>\s*(\d+).*?(?:bath|shower)[^>]*></i>\s*(\d+)', re.S | re.I)
33 +
34 +
35 +class BhhsQuebecConnector(BaseConnector):
36 + source_id = "bhhs_ag_qc"
37 + request_delay = 0.4
38 + max_pages = 40
39 +
40 + def fetch(self) -> list[PropertyListing]:
41 + by_id: dict[str, PropertyListing] = {}
42 + dry = 0
43 + for page in range(1, self.max_pages + 1):
44 + url = LISTING if page == 1 else f"{LISTING}?pages={page}"
45 + try:
46 + html = self.get(url).text
47 + except Exception:
48 + break
49 + before = len(by_id)
50 + parts = _CARD_SPLIT.split(html)
51 + # parts = [pre, url1, mls1, blk1, url2, mls2, blk2, …]
52 + for i in range(1, len(parts) - 2, 3):
53 + url_p, mls, blk = parts[i], parts[i + 1], parts[i + 2][:1800]
54 + lst = self._card(url_p, mls, blk)
55 + if lst:
56 + by_id.setdefault(lst.external_id, lst)
57 + dry = dry + 1 if len(by_id) == before else 0
58 + if dry >= 2:
59 + break
60 + return list(by_id.values())
61 +
62 + def _card(self, url_p: str, mls: str, blk: str) -> PropertyListing | None:
63 + addr, city = "", ""
64 + am = _ADDR_RE.search(blk)
65 + if am:
66 + raw = _html.unescape(am.group(1)).strip()
67 + if "," in raw:
68 + addr, city = [x.strip() for x in raw.rsplit(",", 1)]
69 + else:
70 + addr = raw
71 + if not addr:
72 + um = re.search(r'/properties/([^/]+)/\d{6,9}', url_p)
73 + if um:
74 + addr = _html.unescape(urllib.parse.unquote_plus(um.group(1))).strip()
75 +
76 + rm = _REGION_RE.search(blk)
77 + region = _html.unescape(rm.group(1)).strip() if rm else ""
78 + pm = _PRICE_RE.search(blk)
79 + price_label = pm.group(1).strip() if pm else ""
80 + price = parse_price(price_label.replace("$", "").replace(",", "")) \
81 + if price_label.startswith("$") else parse_price(price_label)
82 + amn = _AMEN_RE.search(blk)
83 + im = _IMG_RE.search(blk)
84 +
85 + return PropertyListing(
86 + source=self.source_id,
87 + external_id=mls,
88 + url=url_p,
89 + title=addr or "Propriété à vendre",
90 + address=addr,
91 + city=city,
92 + region=region,
93 + price=price,
94 + price_label=price_label,
95 + bedrooms=int(amn.group(1)) if amn else None,
96 + bathrooms=int(amn.group(2)) if amn else None,
97 + mls=mls,
98 + images=[im.group(1)] if im else [],
99 + agency=AGENCY,
100 + broker_name=AGENCY,
101 + )
added immoka/connectors/charisma.py +115 −0
@@ -0,0 +1,115 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/charisma.py : Les Immeubles Charisma (charisma.ca) — Laval,
5 +# Montréal, Montérégie. 3e bannière indépendante du Québec (200+ courtiers).
6 +# Site WordPress (plugin « MW Properties » / realestate.marketingwebsites.ca),
7 +# liste rendue SERVEUR sur /fr/proprietes/, paginée par ?pages=N (~10/page,
8 +# ~84 pages). Chaque carte (box-house) porte : n° Centris (URL + image), prix,
9 +# ville, chambres/sdb et photo (property-images/{Centris}/).
10 +#
11 +# ⚠ DÉDUP : Charisma a fusionné avec L'Expert Immobilier P.M. (déjà couvert)
12 +# en 2021 → beaucoup de co-inscriptions. source_id « charisma_ag_qc » : l'infixe
13 +# _ag_ (db.refresh_dedup) masque les fiches dont le n° Centris est déjà porté
14 +# par une source couverte ; les inscriptions uniques restent visibles.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import html as _html
19 +import re
20 +import urllib.parse
21 +
22 +from .base import BaseConnector
23 +from ..normalize import parse_price
24 +from ..schema import PropertyListing
25 +
26 +SITE = "https://charisma.ca"
27 +LISTING = SITE + "/fr/proprietes/"
28 +AGENCY = "Les Immeubles Charisma"
29 +IMG_ROOT = "https://realestate.marketingwebsites.ca/property-images"
30 +
31 +_CARD_SPLIT = re.compile(r'<div class="box-house[^"]*">')
32 +_HREF_RE = re.compile(r'href="(https://charisma\.ca/fr/properties/[^"]*?/(\d{6,9}))"', re.I)
33 +_IMG_RE = re.compile(r'(https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\']+\.(?:jpg|jpeg|png|webp))', re.I)
34 +# prix format nord-américain « $545,000 » (parfois « 545 000 $ »)
35 +_PRICE_RE = re.compile(r'class="price">\s*(\$[\d,]+|[\d\s ]+\$)', re.I)
36 +_BEDS_RE = re.compile(r'<span>(\d+)</span>\s*Chambres', re.I)
37 +_BATHS_RE = re.compile(r'<span>(\d+)</span>\s*Bains', re.I)
38 +# « #1605 - 200 Rue André-Prévost<br/> Montréal (Verdun/Île-des-Soeurs) »
39 +_LOC_RE = re.compile(r'class="location[^"]*"[^>]*>(.*?)</p>', re.S | re.I)
40 +_TYPE_RE = re.compile(r'class="title">\s*<a[^>]*>([^<]+)</a>', re.S | re.I)
41 +
42 +
43 +class CharismaConnector(BaseConnector):
44 + source_id = "charisma_ag_qc"
45 + request_delay = 0.4
46 + max_pages = 120 # garde-fou (~10/page)
47 +
48 + def fetch(self) -> list[PropertyListing]:
49 + by_id: dict[str, PropertyListing] = {}
50 + dry = 0
51 + for page in range(1, self.max_pages + 1):
52 + url = LISTING if page == 1 else f"{LISTING}?pages={page}"
53 + try:
54 + html = self.get(url).text
55 + except Exception:
56 + break
57 + before = len(by_id)
58 + for blk in _CARD_SPLIT.split(html)[1:]:
59 + blk = blk[:2500]
60 + lst = self._card(blk)
61 + if lst:
62 + by_id.setdefault(lst.external_id, lst)
63 + dry = dry + 1 if len(by_id) == before else 0
64 + if dry >= 2:
65 + break
66 + return list(by_id.values())
67 +
68 + def _card(self, blk: str) -> PropertyListing | None:
69 + hm = _HREF_RE.search(blk)
70 + im = _IMG_RE.search(blk)
71 + mls = hm.group(2) if hm else (re.search(r'property-images/(\d{6,9})/', blk) or [None, None])[1]
72 + if not mls:
73 + return None
74 + url = hm.group(1) if hm else f"{SITE}/fr/properties/{mls}"
75 +
76 + # localisation : « {adresse}<br/> {ville} » ; adresse aussi dans l'URL
77 + addr, city = "", ""
78 + lm = _LOC_RE.search(blk)
79 + if lm:
80 + loc = re.split(r'<br\s*/?>', lm.group(1))
81 + addr = _html.unescape(re.sub(r'<[^>]+>', ' ', loc[0])).strip()
82 + if len(loc) > 1:
83 + city = _html.unescape(re.sub(r'<[^>]+>', ' ', loc[1])).strip()
84 + if not addr:
85 + am = re.search(r'/fr/properties/([^/]+)/\d{6,9}', url)
86 + if am:
87 + addr = _html.unescape(urllib.parse.unquote_plus(am.group(1))).strip()
88 +
89 + pm = _PRICE_RE.search(blk)
90 + price_label = pm.group(1).strip() if pm else ""
91 + price = None
92 + if price_label:
93 + price = parse_price(price_label.replace("$", "").replace(",", "")) \
94 + if price_label.startswith("$") else parse_price(price_label)
95 + bm = _BEDS_RE.search(blk)
96 + sm = _BATHS_RE.search(blk)
97 + tm = _TYPE_RE.search(blk)
98 +
99 + return PropertyListing(
100 + source=self.source_id,
101 + external_id=mls,
102 + url=url,
103 + title=addr or "Propriété à vendre",
104 + address=addr,
105 + city=city,
106 + property_type=_html.unescape(tm.group(1)).strip() if tm else "",
107 + price=price,
108 + price_label=price_label,
109 + bedrooms=int(bm.group(1)) if bm else None,
110 + bathrooms=int(sm.group(1)) if sm else None,
111 + mls=mls,
112 + images=[im.group(1)] if im else [f"{IMG_ROOT}/{mls}/{mls}-01.jpg"],
113 + agency=AGENCY,
114 + broker_name=AGENCY,
115 + )
added immoka/connectors/exp_quebec.py +145 −0
@@ -0,0 +1,145 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/exp_quebec.py : eXp Realty Québec (expquebec.com) — toute la province.
5 +#
6 +# L'API cliente realestate.marketingwebsites.ca/api.php est verrouillée par
7 +# whitelist IP (impasse, même via proxy). MAIS le serveur WordPress (autorisé
8 +# sur l'API) rend l'inventaire COMPLET côté serveur sur
9 +# /fr/properties/?pages={N} (9 fiches/page, param « pages » uniquement).
10 +# Chaque carte porte data-href /fr/properties/mls/{MLS}, data-price ($US),
11 +# data-address (rue) et data-pic (property-images/{MLS}/). Le n° MLS = n° Centris.
12 +# La VILLE (absente de la liste) vient de l'endpoint SSR interne
13 +# /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr
14 +# (enrichissement requests direct, plafonné + cache).
15 +#
16 +# ⚠ PLAFOND : le SSR reflète le limit=1000 de l'API sous-jacente → au plus
17 +# ~1000 fiches (pages 1..~114, crawl vérifié). C'est l'inventaire pratique
18 +# complet ; si eXp QC dépasse un jour 1000 inscriptions, segmenter par région
19 +# (params MW_region / MW_q du formulaire), chaque segment ayant son propre cap.
20 +#
21 +# source_id « exp_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —
22 +# les fiches co-listées avec une bannière couverte sont masquées.
23 +# -----------------------------------------------------------------------------
24 +from __future__ import annotations
25 +
26 +import html as _html
27 +import os
28 +import re
29 +
30 +from .base import BaseConnector
31 +from ..normalize import parse_price
32 +from ..schema import PropertyListing
33 +
34 +SITE = "https://expquebec.com"
35 +LISTING = SITE + "/fr/properties/"
36 +MAP_PROP = SITE + "/wp-content/themes/canvas/load/map-property.php"
37 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_EXP_DETAIL_LIMIT", "1200"))
38 +AGENCY = "eXp Agence immobilière"
39 +
40 +_CARD_SPLIT = re.compile(r'data-href="/fr/properties/mls/(\d{6,9})"')
41 +_PRICE_RE = re.compile(r'data-price="([^"]*)"', re.I)
42 +_ADDR_RE = re.compile(r'data-address="([^"]*)"', re.I)
43 +_PIC_RE = re.compile(r'data-pic="([^"]*)"', re.I)
44 +
45 +
46 +def _parse_us_price(label: str):
47 + label = (label or "").strip()
48 + if not label:
49 + return None, ""
50 + price = parse_price(label.replace("$", "").replace(",", "")) if label.startswith("$") \
51 + else parse_price(label)
52 + return price, label
53 +
54 +
55 +class ExpQuebecConnector(BaseConnector):
56 + source_id = "exp_ag_qc"
57 + request_delay = 0.4
58 + max_pages = 200 # garde-fou (9/page) ; stop après 3 pages vides
59 +
60 + def fetch(self) -> list[PropertyListing]:
61 + by_id: dict[str, PropertyListing] = {}
62 + empty = 0
63 + for page in range(1, self.max_pages + 1):
64 + url = LISTING if page == 1 else f"{LISTING}?pages={page}"
65 + try:
66 + html = self.get(url).text
67 + except Exception:
68 + break
69 + parts = _CARD_SPLIT.split(html)
70 + # parts = [pre, mls1, blk1, mls2, blk2, …]
71 + found = 0
72 + for i in range(1, len(parts) - 1, 2):
73 + mls, blk = parts[i], parts[i + 1][:1500]
74 + if mls in by_id:
75 + continue
76 + lst = self._card(mls, blk)
77 + if lst:
78 + by_id[mls] = lst
79 + found += 1
80 + empty = empty + 1 if found == 0 else 0
81 + if empty >= 3:
82 + break
83 + listings = list(by_id.values())
84 + self._enrich_cities(listings)
85 + return listings
86 +
87 + def _card(self, mls: str, blk: str) -> PropertyListing | None:
88 + pm = _PRICE_RE.search(blk)
89 + am = _ADDR_RE.search(blk)
90 + pic = _PIC_RE.search(blk)
91 + price, price_label = _parse_us_price(pm.group(1) if pm else "")
92 + addr = _html.unescape(am.group(1)).strip() if am else ""
93 + img = _html.unescape(pic.group(1)).strip() if pic else \
94 + f"https://realestate.marketingwebsites.ca/property-images/{mls}/{mls}-01.jpg"
95 + return PropertyListing(
96 + source=self.source_id,
97 + external_id=mls,
98 + url=f"{SITE}/fr/properties/mls/{mls}",
99 + title=addr or "Propriété à vendre",
100 + address=addr,
101 + price=price,
102 + price_label=price_label,
103 + mls=mls,
104 + images=[img] if img else [],
105 + agency=AGENCY,
106 + broker_name=AGENCY,
107 + )
108 +
109 + def _enrich_cities(self, listings: list[PropertyListing]) -> None:
110 + """Complète la ville (absente de la liste) via map-property.php, avec
111 + cache BD + plafond. La ville est le dernier segment texte de la carte-fiche
112 + (après le prix et l'adresse)."""
113 + if DETAIL_LIMIT <= 0:
114 + return
115 + from .. import db
116 + con = db.connect()
117 + budget = DETAIL_LIMIT
118 + try:
119 + for lst in listings:
120 + cached = db.get_cached_detail(con, self.source_id, lst.external_id, "city")
121 + if cached is None:
122 + if budget <= 0:
123 + continue
124 + cached = self._fetch_city(lst.external_id)
125 + db.put_cached_detail(con, self.source_id, lst.external_id, "city", cached)
126 + budget -= 1
127 + if cached.get("city") and not lst.city:
128 + lst.city = cached["city"]
129 + finally:
130 + con.close()
131 +
132 + def _fetch_city(self, mls: str) -> dict:
133 + try:
134 + html = self.get(f"{MAP_PROP}?mls={mls}&lang=fr").text
135 + except Exception:
136 + return {}
137 + # segments texte : retirer prix ($), adresse (déjà connue) → dernier = ville
138 + segs = [s.strip() for s in re.split(r'<[^>]+>', html) if s.strip()]
139 + segs = [_html.unescape(s) for s in segs if "$" not in s and "-->" not in s]
140 + # la ville est le dernier segment alphabétique non numérique
141 + for s in reversed(segs):
142 + if re.search(r'[A-Za-zÀ-ÿ]{3,}', s) and not re.match(r'^\d', s):
143 + # éviter de reprendre l'adresse (commence souvent par un n° civique)
144 + return {"city": s}
145 + return {}
added immoka/connectors/glmc.py +132 −0
@@ -0,0 +1,132 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/glmc.py : Les Immeubles GLMC (glmc.ca) — Rivière-du-Loup + KRTB /
5 +# Témiscouata / Les Basques (Bas-Saint-Laurent). Plus grosse agence
6 +# indépendante de l'Est du Québec. Site WordPress (thème X/Cornerstone),
7 +# liste rendue SERVEUR sur /proprietes/, paginée par ?_paged=N (60/page).
8 +# Chaque carte : lien /proprietes/{adresse-slug}-{n°Centris}/ (le n° Centris
9 +# termine le slug), adresse+ville, photo. Prix/chambres via la page détail
10 +# (enrichissement plafonné + cache).
11 +#
12 +# source_id « glmc_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —
13 +# si le n° Centris est déjà porté par une bannière couverte, la fiche est
14 +# masquée ; les inscriptions propres de GLMC restent visibles.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import html as _html
19 +import os
20 +import re
21 +
22 +from .base import BaseConnector
23 +from . import _detailutil as du
24 +from ..normalize import parse_price
25 +from ..schema import PropertyListing
26 +
27 +SITE = "https://www.glmc.ca"
28 +LISTING = SITE + "/proprietes/"
29 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_GLMC_DETAIL_LIMIT", "250"))
30 +AGENCY = "Les Immeubles GLMC"
31 +
32 +_CARD_SPLIT = re.compile(r'<a class="x-col[^"]*"\s+href="')
33 +_HREF_RE = re.compile(r'\s*(https://www\.glmc\.ca/proprietes/([a-z0-9-]+?)-(\d{6,9})/)"', re.I)
34 +_IMG_RE = re.compile(r'<img[^>]+src="(https://www\.glmc\.ca/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"', re.I)
35 +_TITLE_RE = re.compile(r'x-text-content-text-primary">([^<]+)</p>', re.I)
36 +_PRICE_RE = re.compile(r'([\d][\d\s ]{2,}\$)', re.I)
37 +
38 +
39 +class GlmcConnector(BaseConnector):
40 + source_id = "glmc_ag_qc"
41 + request_delay = 0.4
42 + max_pages = 40 # garde-fou (60/page)
43 +
44 + def fetch(self) -> list[PropertyListing]:
45 + by_id: dict[str, PropertyListing] = {}
46 + dry = 0
47 + for page in range(1, self.max_pages + 1):
48 + url = LISTING if page == 1 else f"{LISTING}?_paged={page}"
49 + try:
50 + html = self.get(url).text
51 + except Exception:
52 + break
53 + before = len(by_id)
54 + for blk in _CARD_SPLIT.split(html)[1:]:
55 + blk = blk[:2500]
56 + lst = self._card(blk)
57 + if lst:
58 + by_id.setdefault(lst.external_id, lst)
59 + dry = dry + 1 if len(by_id) == before else 0
60 + if dry >= 2:
61 + break
62 + listings = list(by_id.values())
63 + du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1")
64 + return listings
65 +
66 + def _card(self, blk: str) -> PropertyListing | None:
67 + hm = _HREF_RE.match(blk) or _HREF_RE.search(blk[:400])
68 + if not hm:
69 + return None
70 + url, _slug, mls = hm.group(1), hm.group(2), hm.group(3)
71 +
72 + title = ""
73 + tm = _TITLE_RE.search(blk)
74 + if tm:
75 + title = _html.unescape(tm.group(1)).strip()
76 + # dernière virgule = ville (« 37, Rue Lionel-Chalifour, Rivière-du-Loup »)
77 + city = ""
78 + if title.count(",") >= 1:
79 + city = title.rsplit(",", 1)[1].strip()
80 +
81 + im = _IMG_RE.search(blk)
82 + pm = _PRICE_RE.search(blk)
83 + price_label = _html.unescape(pm.group(1)).replace(" ", " ").strip() if pm else ""
84 +
85 + return PropertyListing(
86 + source=self.source_id,
87 + external_id=mls,
88 + url=url,
89 + title=title or "Propriété à vendre",
90 + address=title,
91 + city=city,
92 + price=parse_price(price_label),
93 + price_label=price_label,
94 + mls=mls,
95 + images=[im.group(1)] if im else [],
96 + agency=AGENCY,
97 + broker_name=AGENCY,
98 + )
99 +
100 +
101 +def _parse_detail(html: str) -> dict:
102 + """Fiche détail GLMC : JSON-LD + tableau Centris aplati."""
103 + out: dict = {}
104 + desc = du.ld_description(html)
105 + if desc:
106 + out["description"] = desc
107 + text = du.flatten(html)
108 + det = du.centris_details(text)
109 + if det:
110 + out["details"] = det
111 + # prix « 349 900 $ »
112 + mp = re.search(r'([\d][\d\s ]{2,}\$)', html)
113 + if mp:
114 + p = parse_price(mp.group(1))
115 + if p:
116 + out["price"] = p
117 + out["price_label"] = mp.group(1).replace(" ", " ").strip()
118 + my = re.search(r"Année de construction\s*\|\s*(\d{4})", text)
119 + if my:
120 + out["year_built"] = int(my.group(1))
121 + co = du.gmaps_coords(html)
122 + if co:
123 + out["lat"], out["lng"] = co
124 + imgs = re.findall(r'https://www\.glmc\.ca/wp-content/uploads/[^"\']+\.(?:jpg|jpeg|png|webp)', html)
125 + seen, uniq = set(), []
126 + for u in imgs:
127 + if u not in seen:
128 + seen.add(u)
129 + uniq.append(u)
130 + if len(uniq) > 1:
131 + out["images"] = uniq
132 + return out
added immoka/connectors/imcha.py +101 −0
@@ -0,0 +1,101 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/imcha.py : Les Immeubles Charlevoix (imcha.com) — Baie-Saint-Paul,
5 +# La Malbaie, Les Éboulements, Petite-Rivière-Saint-François + Vieux-Québec /
6 +# Côte-de-Beaupré. Seule agence indépendante crédible de Charlevoix. Site
7 +# custom rendu SERVEUR, liste paginée /vente-de-proprietes/page-N. Chaque carte
8 +# (li.estate-single) porte : code interne, type, ville, prix, chambres et
9 +# photo. Le n° Centris est encodé dans le nom de fichier photo
10 +# (/uploads/photos/max/{Centris}-…) → clé de déduplication.
11 +#
12 +# source_id « imcha_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import html as _html
17 +import re
18 +
19 +from .base import BaseConnector
20 +from ..normalize import parse_price
21 +from ..schema import PropertyListing
22 +
23 +SITE = "https://www.imcha.com"
24 +AGENCY = "Les Immeubles Charlevoix"
25 +
26 +_ITEM_SPLIT = re.compile(r'<li class="estate-single"')
27 +_HREF_RE = re.compile(r'href="(/(?:maisons-a-vendre|terrains-et-terres-a-vendre|[a-z-]+-a-vendre)/[a-z0-9_-]+)"', re.I)
28 +_CODE_RE = re.compile(r'data-code="([A-Z0-9]+)"', re.I)
29 +_CENTRIS_RE = re.compile(r'/uploads/photos/[a-z]+/(\d{6,9})-', re.I)
30 +_IMG_RE = re.compile(r'src="(/resize\?src=[^"]+|/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"', re.I)
31 +_TYPE_RE = re.compile(r'estate-single__status">([^<]+)<', re.I)
32 +_CITY_RE = re.compile(r'estate-single__city">([^<]+)<', re.I)
33 +_PRICE_RE = re.compile(r'estate-single__price">([^<]+)<', re.I)
34 +_BEDS_RE = re.compile(r'estate-spec__count">(\d+)<', re.I)
35 +
36 +
37 +class ImchaConnector(BaseConnector):
38 + source_id = "imcha_ag_qc"
39 + request_delay = 0.4
40 + max_pages = 30
41 +
42 + def fetch(self) -> list[PropertyListing]:
43 + by_id: dict[str, PropertyListing] = {}
44 + dry = 0
45 + for page in range(1, self.max_pages + 1):
46 + url = f"{SITE}/vente-de-proprietes/page-{page}"
47 + try:
48 + resp = self.get(url)
49 + resp.encoding = "utf-8"
50 + html = resp.text
51 + except Exception:
52 + break
53 + before = len(by_id)
54 + for blk in _ITEM_SPLIT.split(html)[1:]:
55 + blk = blk[:4000]
56 + lst = self._card(blk)
57 + if lst:
58 + by_id.setdefault(lst.external_id, lst)
59 + dry = dry + 1 if len(by_id) == before else 0
60 + if dry >= 2:
61 + break
62 + return list(by_id.values())
63 +
64 + def _card(self, blk: str) -> PropertyListing | None:
65 + hm = _HREF_RE.search(blk)
66 + if not hm:
67 + return None
68 + cm = _CENTRIS_RE.search(blk)
69 + code = _CODE_RE.search(blk)
70 + # n° Centris (photo) = clé de dédup ; sinon code interne (pas de dédup)
71 + mls = cm.group(1) if cm else ""
72 + external_id = mls or (code.group(1) if code else None)
73 + if not external_id:
74 + return None
75 +
76 + tm = _TYPE_RE.search(blk)
77 + cy = _CITY_RE.search(blk)
78 + pm = _PRICE_RE.search(blk)
79 + bm = _BEDS_RE.search(blk)
80 + im = _IMG_RE.search(blk)
81 + img = im.group(1) if im else ""
82 + if img.startswith("/"):
83 + img = SITE + img
84 + price_label = _html.unescape(pm.group(1)).strip() if pm else ""
85 + city = _html.unescape(cy.group(1)).strip() if cy else ""
86 +
87 + return PropertyListing(
88 + source=self.source_id,
89 + external_id=external_id,
90 + url=SITE + hm.group(1),
91 + title=f"{_html.unescape(tm.group(1)).strip() if tm else 'Propriété'} à vendre — {city}".strip(" —"),
92 + city=city,
93 + property_type=_html.unescape(tm.group(1)).strip() if tm else "",
94 + price=parse_price(price_label),
95 + price_label=price_label,
96 + bedrooms=int(bm.group(1)) if bm else None,
97 + mls=mls,
98 + images=[img] if img else [],
99 + agency=AGENCY,
100 + broker_name=AGENCY,
101 + )
added immoka/connectors/immeuble_contact.py +91 −0
@@ -0,0 +1,91 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/immeuble_contact.py : Les Immeubles Contact (Alma / Lac-Saint-Jean-Est
5 +# + Chibougamau-Chapais). Agence indépendante, site PHP maison (Solution Globale
6 +# Informatique). La liste est rendue SERVEUR sur immeubles_centris.php?page=…
7 +# (résidentiel + revenu), encodage ISO-8859-1. Chaque carte porte déjà tout :
8 +# n° MLS (Centris), type, adresse, ville, prix (data-cost) et photo Centris.
9 +#
10 +# source_id « contact_ag_qc » : l'infixe _ag_ active la dédup Centris (db.refresh_dedup,
11 +# sources %_ag_%) — si le n° Centris est déjà porté par une bannière couverte
12 +# (RE/MAX, Via Capitale…), la fiche est masquée ; les inscriptions propres de
13 +# l'agence restent visibles. Aucun double-comptage.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import html as _html
18 +import re
19 +
20 +from .base import BaseConnector
21 +from ..normalize import parse_price
22 +from ..schema import PropertyListing
23 +
24 +SITE = "https://www.lesimmeublescontact.com"
25 +PAGES = ["residentiel", "revenu", "commercial"]
26 +AGENCY = "Les Immeubles Contact"
27 +
28 +# une carte = <article class="immeuble_row" data-cost="NNN"> … </article>
29 +_ROW_RE = re.compile(r'<article class="immeuble_row"\s+data-cost="(\d+)"(.*?)</article>', re.S | re.I)
30 +_TYPE_RE = re.compile(r'class="type"[^>]*>.*?<span>([^<]+)</span>', re.S | re.I)
31 +_ADDR_RE = re.compile(r'class="address">(.*?)</p>', re.S | re.I)
32 +_MLS_RE = re.compile(r'no_mls=(\d{6,9})', re.I)
33 +_PRICE_RE = re.compile(r'class="price"><p>([^<]+)</p>', re.S | re.I)
34 +
35 +
36 +class ImmeubleContactConnector(BaseConnector):
37 + source_id = "contact_ag_qc"
38 + request_delay = 0.4
39 +
40 + def fetch(self) -> list[PropertyListing]:
41 + by_id: dict[str, PropertyListing] = {}
42 + for page in PAGES:
43 + url = f"{SITE}/immeubles_centris.php?page={page}"
44 + try:
45 + resp = self.get(url)
46 + resp.encoding = "iso-8859-1"
47 + html = resp.text
48 + except Exception:
49 + continue
50 + for cost, blk in _ROW_RE.findall(html):
51 + lst = self._card(cost, blk, page)
52 + if lst:
53 + by_id.setdefault(lst.external_id, lst)
54 + return list(by_id.values())
55 +
56 + def _card(self, cost: str, blk: str, page: str) -> PropertyListing | None:
57 + mm = _MLS_RE.search(blk)
58 + if not mm:
59 + return None
60 + mls = mm.group(1)
61 +
62 + addr_raw = _ADDR_RE.search(blk)
63 + addr_txt = ""
64 + city = ""
65 + if addr_raw:
66 + parts = re.split(r'<br\s*/?>', addr_raw.group(1))
67 + addr_txt = _html.unescape(re.sub(r'<[^>]+>', ' ', parts[0])).strip().rstrip(",").strip()
68 + if len(parts) > 1:
69 + city = _html.unescape(re.sub(r'<[^>]+>', ' ', parts[1])).strip()
70 +
71 + tm = _TYPE_RE.search(blk)
72 + prop_type = _html.unescape(tm.group(1)).strip() if tm else ""
73 + pm = _PRICE_RE.search(blk)
74 + price_label = _html.unescape(pm.group(1)).strip() if pm else ""
75 + price = parse_price(price_label) or (float(cost) if cost and cost != "0" else None)
76 +
77 + return PropertyListing(
78 + source=self.source_id,
79 + external_id=mls,
80 + url=f"{SITE}/immeubles_centris_fiche.php?no_mls={mls}",
81 + title=addr_txt or f"{prop_type} à vendre",
82 + address=addr_txt,
83 + city=city,
84 + property_type=prop_type,
85 + price=price,
86 + price_label=price_label,
87 + mls=mls,
88 + images=[f"{SITE}/mesimages/photos_centris/_{mls}_1.jpg"],
89 + agency=AGENCY,
90 + broker_name=AGENCY,
91 + )
92