SPB Git

spb/immo-ka Public

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

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%

feat: thème rouge, sous-agences, quartier StatCan, estimation Vrai-Prix, géocodage

- Palette rouge (distincte de Lou-Ka), pagination 12/page, page Sources par sous-agence
- 74 connecteurs (DuProprio, Via Capitale portail, Royal LePage par bureau, C21 sous-agences…)
- Bloc quartier (recensement 2021, crime, chaleur, proximité — données StatCan/INSPQ)
- Estimation Vrai-Prix par fiche (valeur + fourchette + confiance + lien analyse)
- Géocodage Adresses Québec + Nominatim ; dédup pré-calculée (perf) ; SQLite WAL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 h ago (Aug 12, 2026) parent c69d1bd

Showing 27 changed files with +1,774 and −138

deleted .claude/scheduled_tasks.lock +0 −1
@@ -1 +0,0 @@
1 {"sessionId":"c9b9d228-3b87-491a-943d-e0458a7449fb","pid":46756,"procStart":"Wed Aug 12 10:21:44 2026","acquiredAt":1786533230546}
\ No newline at end of file
modified README.md +10 −5
@@ -12,11 +12,16 @@
12 12 ![MapLibre](https://img.shields.io/badge/MapLibre-carte-141814?style=for-the-badge&logo=maplibre&logoColor=c7f230)
13 13 ![SQLite](https://img.shields.io/badge/SQLite-WAL-141814?style=for-the-badge&logo=sqlite&logoColor=c7f230)
14 14
15 ![Propriétés](https://img.shields.io/badge/propri%C3%A9t%C3%A9s_agr%C3%A9g%C3%A9es-40_000%2B-1c5c41?style=flat-square)
16 ![Connecteurs](https://img.shields.io/badge/connecteurs-73-1c5c41?style=flat-square)
17 ![Agences](https://img.shields.io/badge/agences_actives-21-1c5c41?style=flat-square)
18 ![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-1c5c41?style=flat-square)
19 ![MàJ](https://img.shields.io/badge/mise_%C3%A0_jour-automatique-1c5c41?style=flat-square)
15 +![Propriétés](https://img.shields.io/badge/propri%C3%A9t%C3%A9s_agr%C3%A9g%C3%A9es-54_800%2B-e23744?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs-74-e23744?style=flat-square)
17 +![Bannières](https://img.shields.io/badge/banni%C3%A8res-15-e23744?style=flat-square)
18 +![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-e23744?style=flat-square)
19 +![MàJ](https://img.shields.io/badge/mise_%C3%A0_jour-automatique-e23744?style=flat-square)
20 +
21 +**Fiches enrichies** : galerie photo · caractéristiques Centris complètes · pièces &
22 +dimensions · **estimation de valeur Vrai-Prix** (fourchette + confiance + lien
23 +analyse) · **quartier** (recensement StatCan, criminalité, îlot de chaleur,
24 +proximité) · carte MapLibre · découpage **par sous-agence** (bureau).
20 25
21 26 *Agrégateur indépendant des maisons, condos et terrains à vendre au Québec —
22 27 un connecteur dédié par agence de courtage, chaque propriété normalisée vers un
modified data/sources.json +12 −1
@@ -74,7 +74,7 @@
74 74 "connector": "exp_quebec",
75 75 "status": "a_faire",
76 76 "type": "agence",
77 "note": "Next.js (exprealty.ca/fr-CA/search) : inscriptions chargées côté client via backend BoldTrail/kvCORE ; absentes de __NEXT_DATA__ et des chunks JS ; Firecrawl proxy=stealth rend la page mais pas les fiches (fetch client non déclenché). Aucun endpoint API découvrable (seul images.expcloud.com exposé). Reste : capturer l'XHR live de recherche BoldTrail (state=QC) via un navigateur instrumenté, puis rejouer l'API JSON."
77 + "note": "Next.js. Scrapfly ASP+render_js rend le shell mais les inscriptions sont hydratees cote client via BoldTrail/kvCORE (2-4 cartes seulement rendues ; virtualisation). La route _next/data/<buildId>/fr-CA/search.json ne contient PAS les listings. Reste : capturer l'XHR BoldTrail live (js_scenario/scroll Scrapfly ou navigateur instrumente) ou l'API kvCORE authentifiee."
78 78 },
79 79 {
80 80 "id": "kw_urbain",
@@ -240,6 +240,17 @@
240 240 "role": "sous-agence",
241 241 "platform": "source.immo",
242 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)'."
243 254 }
244 255 ]
245 256 }
\ No newline at end of file
modified docs/screenshots/home.png +0 −0

Binary file not shown.

modified docs/screenshots/listing.png +0 −0

Binary file not shown.

modified frontend/index.html +2 −2
@@ -9,14 +9,14 @@
9 9 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 10 <title>Immo-Ka — Propriétés à vendre au Québec</title>
11 11 <meta name="description" content="Immo-Ka agrège les propriétés à vendre affichées par les agences de courtage immobilier du Québec (RE/MAX, Sutton, Via Capitale, Proprio Direct…), toujours à jour." />
12 <meta name="theme-color" content="#f5f3ee" />
12 + <meta name="theme-color" content="#1a1214" />
13 13 <meta name="mobile-web-app-capable" content="yes" />
14 14 <meta name="apple-mobile-web-app-capable" content="yes" />
15 15 <meta name="apple-mobile-web-app-title" content="Immo-Ka" />
16 16 <link rel="preconnect" href="https://fonts.googleapis.com" />
17 17 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18 18 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
19 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23121712'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='30' font-weight='900' fill='%23d9f26b' text-anchor='middle'%3EIK%3C/text%3E%3C/svg%3E" />
19 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%231a1214'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='30' font-weight='900' fill='%23e23744' text-anchor='middle'%3EIK%3C/text%3E%3C/svg%3E" />
20 20 </head>
21 21 <body>
22 22 <div id="root"></div>
modified frontend/src/api.ts +41 −0
@@ -49,6 +49,9 @@ export interface Listing {
49 49 lat: number | null;
50 50 lng: number | null;
51 51 price_history?: { ts: number; price: number | null }[];
52 + poi?: Poi[]; // commodités de proximité (fiche seulement)
53 + quartier?: Quartier | null; // stats de quartier (fiche seulement)
54 + vraiprix?: VraiPrix | null; // estimation de valeur marchande (Vrai-Prix)
52 55 first_seen?: number;
53 56 last_seen?: number;
54 57 updated_at?: number;
@@ -149,6 +152,16 @@ export const fetchFacets = (city?: string) =>
149 152 export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
150 153 export const fetchStats = () => get<Stats>("/api/stats");
151 154
155 +export interface SubAgency { name: string; count: number; sources: string[] }
156 +export interface Franchise {
157 + franchise: string;
158 + total: number;
159 + sub_agencies: number;
160 + agencies: SubAgency[];
161 +}
162 +export const fetchAgencies = () =>
163 + get<{ franchises: Franchise[] }>("/api/agencies");
164 +
152 165 // --- Formatage --------------------------------------------------------------
153 166 export const fmtPrice = (p: number | null, label?: string) =>
154 167 p != null
@@ -162,3 +175,31 @@ export const fmtDate = (ts: number): string =>
162 175 new Date(ts * 1000).toLocaleDateString("fr-CA", {
163 176 day: "numeric", month: "long", year: "numeric",
164 177 });
178 +
179 +// --- Vrai-Prix : estimation de valeur marchande ------------------------------
180 +export interface VraiPrix {
181 + id: string;
182 + value: number | null;
183 + low: number | null;
184 + high: number | null;
185 + confidence_pct: number | null;
186 + confidence: string | null; // A | B | C | D
187 + url: string; // page d'analyse détaillée
188 +}
189 +
190 +// --- Quartier (recensement 2021, proximité StatCan, chaleur INSPQ, crime) ----
191 +export interface Poi { cat: string; name: string; dist_m: number }
192 +export interface Quartier {
193 + dauid?: string | null;
194 + demographie?: {
195 + population: number | null; densite: number | null; age_median: number | null;
196 + revenu_median: number | null; pct_locataires: number | null;
197 + loyer_moyen: number | null; pct_francais: number | null; pct_univ: number | null;
198 + };
199 + proximite?: Record<string, number>;
200 + defavorisation?: { quintile_materiel: number | null; quintile_social: number | null };
201 + chaleur?: { classe: number; ecart: number | null };
202 + crime?:
203 + | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number }
204 + | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null };
205 +}
added frontend/src/components/QuartierBlock.tsx +135 −0
@@ -0,0 +1,135 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/QuartierBlock.tsx : section « Le quartier » de la fiche —
5 +// démographie du recensement 2021 (aire de diffusion ~500 hab.), scores de
6 +// proximité StatCan, îlot de chaleur/fraîcheur INSPQ, criminalité.
7 +// -----------------------------------------------------------------------------
8 +import { Quartier } from "../api";
9 +
10 +const fmtMoney = (v: number | null | undefined) =>
11 + v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";
12 +const fmtPct = (v: number | null | undefined) =>
13 + v == null ? null : Math.round(v) + " %";
14 +
15 +// scores PMD affichés (clé backend -> libellé)
16 +const PROX_LABELS: [string, string][] = [
17 + ["prox_epicerie", "Épiceries"],
18 + ["prox_transport", "Transport en commun"],
19 + ["prox_parc", "Parcs"],
20 + ["prox_ecole_prim", "Écoles primaires"],
21 + ["prox_sante", "Soins de santé"],
22 + ["prox_pharmacie", "Pharmacies"],
23 +];
24 +
25 +function chaleurBadge(classe: number, ecart: number | null) {
26 + if (classe <= 3)
27 + return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "🌿" };
28 + if (classe >= 7)
29 + return {
30 + txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`,
31 + cls: "q-badge hot", ico: "🌡",
32 + };
33 + return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "🌤" };
34 +}
35 +
36 +export default function QuartierBlock({ q }: { q: Quartier }) {
37 + const d = q.demographie;
38 + const stats: [string, string | null][] = d
39 + ? [
40 + ["Revenu médian des ménages", fmtMoney(d.revenu_median)],
41 + ["Ménages locataires", fmtPct(d.pct_locataires)],
42 + ["Loyer moyen du secteur", fmtMoney(d.loyer_moyen)],
43 + ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],
44 + ["Français à la maison", fmtPct(d.pct_francais)],
45 + ["Diplôme universitaire", fmtPct(d.pct_univ)],
46 + ]
47 + : [];
48 + const statsOk = stats.filter(([, v]) => v != null) as [string, string][];
49 + const prox = q.proximite ?? {};
50 + const proxOk = PROX_LABELS.filter(([k]) => typeof prox[k] === "number");
51 +
52 + if (statsOk.length === 0 && proxOk.length === 0 && !q.chaleur && !q.crime)
53 + return null;
54 +
55 + return (
56 + <section className="quartier">
57 + <h2>Le quartier</h2>
58 + <p className="q-sub">
59 + Secteur immédiat de l'immeuble (aire de diffusion du recensement, ± 500 habitants).
60 + </p>
61 +
62 + {statsOk.length > 0 && (
63 + <div className="q-grid">
64 + {statsOk.map(([label, val]) => (
65 + <div className="q-cell" key={label}>
66 + <div className="q-val">{val}</div>
67 + <div className="q-label">{label}</div>
68 + </div>
69 + ))}
70 + </div>
71 + )}
72 +
73 + {proxOk.length > 0 && (
74 + <div className="q-prox">
75 + {proxOk.map(([k, label]) => {
76 + const v = Math.max(0, Math.min(1, prox[k]));
77 + return (
78 + <div className="q-bar" key={k}>
79 + <span className="q-bar-label">{label}</span>
80 + <span className="q-bar-track">
81 + <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} />
82 + </span>
83 + <span className="q-bar-num">{Math.round(v * 100)}</span>
84 + </div>
85 + );
86 + })}
87 + <div className="fine">Accessibilité 0–100 — mesures de proximité de Statistique Canada.</div>
88 + </div>
89 + )}
90 +
91 + <div className="q-badges">
92 + {q.chaleur && (() => {
93 + const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart);
94 + return <span className={b.cls}>{b.ico} {b.txt}</span>;
95 + })()}
96 + {q.crime?.type === "points" && (
97 + <span className="q-badge neutral">
98 + 🛡 {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à
99 + moins de 500 m (12 mois)
100 + {q.crime.douze_mois_precedents > 0 && (
101 + q.crime.douze_mois <= q.crime.douze_mois_precedents
102 + ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)`
103 + : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)`
104 + )}
105 + </span>
106 + )}
107 + {q.crime?.type === "igc" && (() => {
108 + const c = q.crime;
109 + if (c.indice_canada != null && c.indice_canada > 0) {
110 + const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);
111 + const sous = delta <= 0;
112 + return (
113 + <span className={`q-badge ${sous ? "cool" : "neutral"}`}>
114 + 🛡 Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la
115 + moyenne canadienne{sous ? " ✅" : ""}
116 + <small className="q-badge-sub">({c.ville} {c.annee} : {c.indice} · Canada : {c.indice_canada})</small>
117 + </span>
118 + );
119 + }
120 + return (
121 + <span className="q-badge neutral">
122 + 🛡 Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b>
123 + </span>
124 + );
125 + })()}
126 + </div>
127 +
128 + <div className="fine">
129 + Sources : Statistique Canada (Recensement 2021, licence ouverte), INSPQ
130 + (CC-BY 4.0){q.crime?.type === "points" ? ", Ville de Montréal (CC-BY 4.0)" : ""}.
131 + Statistiques du secteur, pas de l'immeuble.
132 + </div>
133 + </section>
134 + );
135 +}
modified frontend/src/pages/Home.tsx +39 −20
@@ -16,16 +16,27 @@ const MapView = lazy(() => import("../components/MapView"));
16 16
17 17 const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000];
18 18 const AREA_STEPS = [800, 1000, 1500, 2000, 3000];
19 const PAGE = 60;
19 +const PAGE = 12;
20 20
21 21 const fmtK = (n: number) =>
22 22 n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`;
23 23
24 +// fenêtre de pagination : 1 … (p-1) p (p+1) … N
25 +function pageNumbers(p: number, n: number): (number | "…")[] {
26 + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);
27 + const out: (number | "…")[] = [1];
28 + const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);
29 + if (lo > 2) out.push("…");
30 + for (let i = lo; i <= hi; i++) out.push(i);
31 + if (hi < n - 1) out.push("…");
32 + out.push(n);
33 + return out;
34 +}
35 +
24 36 export default function Home() {
25 37 const [listings, setListings] = useState<Listing[] | null>(null);
26 38 const [total, setTotal] = useState(0);
27 const [offset, setOffset] = useState(0);
28 const [loadingMore, setLoadingMore] = useState(false);
39 + const [page, setPage] = useState(1); // pagination 12/page
29 40 const [facets, setFacets] = useState<Facets | null>(null);
30 41 const [sectors, setSectors] = useState<string[]>([]);
31 42 const [stats, setStats] = useState<Stats | null>(null);
@@ -68,21 +79,23 @@ export default function Home() {
68 79 fetchFacets(city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));
69 80 }, [city]);
70 81
82 + // revenir à la page 1 quand les filtres changent
83 + useEffect(() => { setPage(1); }, [filters]);
84 +
85 + // charger la page courante (12 annonces) — remplace la grille
71 86 useEffect(() => {
72 87 let cancelled = false;
73 setListings(null); setError(null); setOffset(0);
74 fetchListings(filters, PAGE, 0)
75 .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); setOffset(r.listings.length); } })
88 + setListings(null); setError(null);
89 + fetchListings(filters, PAGE, (page - 1) * PAGE)
90 + .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })
76 91 .catch((e) => !cancelled && setError(String(e)));
77 92 return () => { cancelled = true; };
78 }, [filters]);
93 + }, [filters, page]);
79 94
80 const loadMore = () => {
81 setLoadingMore(true);
82 fetchListings(filters, PAGE, offset)
83 .then((r) => { setListings((cur) => [...(cur ?? []), ...r.listings]); setOffset((o) => o + r.listings.length); })
84 .catch(() => {})
85 .finally(() => setLoadingMore(false));
95 + const totalPages = Math.max(1, Math.ceil(total / PAGE));
96 + const gotoPage = (p: number) => {
97 + setPage(Math.min(Math.max(1, p), totalPages));
98 + document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
86 99 };
87 100
88 101 const resetAll = () => {
@@ -263,7 +276,7 @@ export default function Home() {
263 276 </div>
264 277 )}
265 278
266 <div className="results-head">
279 + <div className="results-head" id="results-top">
267 280 <h2>Propriétés à vendre</h2>
268 281 <div className="results-tools">
269 282 {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>}
@@ -323,12 +336,18 @@ export default function Home() {
323 336 <div className="grid">
324 337 {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
325 338 </div>
326 {offset < total && (
327 <div className="more-wrap">
328 <button className="btn btn-primary" onClick={loadMore} disabled={loadingMore}>
329 {loadingMore ? "Chargement…" : `Voir plus (${(total - offset).toLocaleString("fr-CA")} restantes)`}
330 </button>
331 </div>
339 + {totalPages > 1 && (
340 + <nav className="pager" aria-label="Pagination">
341 + <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Préc.</button>
342 + {pageNumbers(page, totalPages).map((p, i) =>
343 + p === "…"
344 + ? <span key={`e${i}`} className="pager-gap">…</span>
345 + : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}
346 + onClick={() => gotoPage(p as number)}>{p}</button>
347 + )}
348 + <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Suiv. ›</button>
349 + <span className="pager-info">Page {page} / {totalPages}</span>
350 + </nav>
332 351 )}
333 352 </>
334 353 )}
modified frontend/src/pages/Listing.tsx +44 −0
@@ -13,6 +13,7 @@ import {
13 13 } from "../api";
14 14
15 15 const MiniMap = lazy(() => import("../components/MapView"));
16 +import QuartierBlock from "../components/QuartierBlock";
16 17
17 18 // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
18 19 function Galerie({ images, titre }: { images: string[]; titre: string }) {
@@ -201,6 +202,13 @@ export default function ListingPage() {
201 202 </div>
202 203 </section>
203 204 )}
205 +
206 + {l.quartier && (
207 + <section className="f-bloc" id="quartier">
208 + <h2>Le quartier</h2>
209 + <QuartierBlock q={l.quartier} />
210 + </section>
211 + )}
204 212 </div>
205 213
206 214 {/* -------- colonne droite : synthèse, specs, courtier ---------------- */}
@@ -223,6 +231,42 @@ export default function ListingPage() {
223 231 </div>
224 232 )}
225 233
234 + {l.vraiprix && l.vraiprix.value != null && (
235 + <div className="vraiprix">
236 + <div className="vp-head">
237 + <span className="vp-logo">Vrai‑Prix</span>
238 + {l.vraiprix.confidence && (
239 + <span className={`vp-conf vp-conf-${l.vraiprix.confidence}`}>
240 + confiance {l.vraiprix.confidence}
241 + </span>
242 + )}
243 + </div>
244 + <div className="vp-k">Valeur marchande estimée</div>
245 + <div className="vp-value">{fmtPrice(l.vraiprix.value)}</div>
246 + {l.vraiprix.low != null && l.vraiprix.high != null && (
247 + <div className="vp-range">
248 + fourchette {fmtPrice(l.vraiprix.low)} – {fmtPrice(l.vraiprix.high)}
249 + </div>
250 + )}
251 + {l.price != null && (
252 + (() => {
253 + const diff = l.price - (l.vraiprix!.value as number);
254 + const pct = Math.round((diff / (l.vraiprix!.value as number)) * 100);
255 + const cls = diff > 0 ? "over" : "under";
256 + const txt = diff > 0
257 + ? `Prix demandé ${pct}% au-dessus de l'estimation`
258 + : `Prix demandé ${Math.abs(pct)}% sous l'estimation`;
259 + return Math.abs(pct) >= 1
260 + ? <div className={`vp-delta vp-${cls}`}>{diff > 0 ? "▲" : "▼"} {txt}</div>
261 + : <div className="vp-delta vp-fair">≈ Prix aligné sur l'estimation</div>;
262 + })()
263 + )}
264 + <a className="vp-link" href={l.vraiprix.url} target="_blank" rel="noopener noreferrer">
265 + Voir l'analyse détaillée ↗
266 + </a>
267 + </div>
268 + )}
269 +
226 270 {(l.broker_name || l.broker_phone) && (
227 271 <div className="broker">
228 272 <div className="broker-k">Courtier</div>
modified frontend/src/pages/Sources.tsx +60 −48
@@ -1,75 +1,87 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 // pages/Sources.tsx : registre des agences de courtage agrégées
4 +// pages/Sources.tsx : registre des agences — éclaté par bannière → sous-agence
5 5 // -----------------------------------------------------------------------------
6 6 import { useEffect, useState } from "react";
7 7 import { Link } from "react-router-dom";
8 import { Source, fetchSources } from "../api";
8 +import { Franchise, fetchAgencies } from "../api";
9 9
10 10 export default function SourcesPage() {
11 const [sources, setSources] = useState<Source[] | null>(null);
11 + const [data, setData] = useState<Franchise[] | null>(null);
12 12 const [error, setError] = useState<string | null>(null);
13 + const [open, setOpen] = useState<Record<string, boolean>>({});
13 14
14 15 useEffect(() => {
15 fetchSources().then((r) => setSources(r.sources)).catch((e) => setError(String(e)));
16 + fetchAgencies().then((r) => setData(r.franchises)).catch((e) => setError(String(e)));
16 17 }, []);
17 18
18 const active = (sources ?? []).filter((s) => s.status === "actif" || s.active_listings > 0);
19 const todo = (sources ?? []).filter((s) => !(s.status === "actif" || s.active_listings > 0));
19 + const totalProps = (data ?? []).reduce((s, f) => s + f.total, 0);
20 + const totalSub = (data ?? []).reduce((s, f) => s + f.sub_agencies, 0);
20 21
21 22 return (
22 23 <div className="container sources">
23 <span className="kicker">Registre — agences de courtage</span>
24 <h1>Agences agrégées</h1>
24 + <span className="kicker">Registre — agences & sous-agences</span>
25 + <h1>Sources par sous-agence</h1>
25 26 <p className="sub">
26 Bannières et agences immobilières du Québec recensées par Immo-Ka. Chaque agence
27 « active » est synchronisée périodiquement par un connecteur dédié ; les autres sont
28 en attente de connecteur.
27 + Chaque bannière immobilière du Québec est éclatée par sous-agence (bureau).
28 + Chaque annonce est synchronisée par un connecteur dédié et dédoublonnée par
29 + numéro Centris. Cliquez un compte pour voir les propriétés.
29 30 </p>
30 31
31 32 {error && <div className="notice">⚠️ {error}</div>}
32 {!sources && !error && <div className="notice">Chargement…</div>}
33 + {!data && !error && <div className="notice">Chargement…</div>}
33 34
34 {sources && (
35 + {data && (
35 36 <>
36 <div className="src-wrap">
37 <table className="src-table">
38 <thead>
39 <tr>
40 <th>Agence</th>
41 <th>Couverture</th>
42 <th>Type</th>
43 <th>Statut</th>
44 <th style={{ textAlign: "right" }}>Propriétés</th>
45 </tr>
46 </thead>
47 <tbody>
48 {[...active, ...todo].map((s) => (
49 <tr key={s.id}>
50 <td>
51 <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a>
52 </td>
53 <td style={{ color: "var(--ink-2)", maxWidth: 420 }}>{s.coverage ?? "—"}</td>
54 <td style={{ color: "var(--ink-3)" }}>{s.type ?? "—"}</td>
55 <td>
56 {s.active_listings > 0 || s.status === "actif"
57 ? <span className="pill ok">actif</span>
58 : <span className="pill todo">à venir</span>}
59 </td>
60 <td style={{ textAlign: "right" }}>
61 {s.active_listings
62 ? <Link className="count-pill" to={`/?source=${encodeURIComponent(s.id)}`}>{s.active_listings.toLocaleString("fr-CA")}</Link>
63 : "—"}
64 </td>
65 </tr>
66 ))}
67 </tbody>
68 </table>
37 + <p className="stats-foot" style={{ marginTop: 0 }}>
38 + <b>{totalProps.toLocaleString("fr-CA")}</b> propriétés ·{" "}
39 + <b>{data.length}</b> bannières ·{" "}
40 + <b>{totalSub.toLocaleString("fr-CA")}</b> sous-agences
41 + </p>
42 +
43 + <div className="franchise-list">
44 + {data.map((f) => {
45 + const isOpen = open[f.franchise] ?? false;
46 + return (
47 + <div className="franchise" key={f.franchise}>
48 + <button
49 + className="franchise-head"
50 + onClick={() => setOpen({ ...open, [f.franchise]: !isOpen })}
51 + aria-expanded={isOpen}
52 + >
53 + <span className="fh-caret">{isOpen ? "▾" : "▸"}</span>
54 + <span className="fh-name">{f.franchise}</span>
55 + <span className="fh-sub">
56 + {f.sub_agencies} sous-agence{f.sub_agencies > 1 ? "s" : ""}
57 + </span>
58 + <span className="fh-count">{f.total.toLocaleString("fr-CA")}</span>
59 + </button>
60 +
61 + {isOpen && (
62 + <ul className="subagency-list">
63 + {f.agencies.map((a) => (
64 + <li key={a.name}>
65 + <span className="sa-name">{a.name}</span>
66 + <Link
67 + className="count-pill"
68 + to={`/?source=${encodeURIComponent(a.sources[0])}`}
69 + >
70 + {a.count.toLocaleString("fr-CA")}
71 + </Link>
72 + </li>
73 + ))}
74 + </ul>
75 + )}
76 + </div>
77 + );
78 + })}
69 79 </div>
80 +
70 81 <p className="stats-foot">
71 {active.length} agence{active.length > 1 ? "s" : ""} active{active.length > 1 ? "s" : ""} sur {sources.length} recensées.
72 Les sous-agences RE/MAX servent de source de secours (dédoublonnées par n° Centris).
82 + Les sous-agences des grandes bannières (RE/MAX, Via Capitale, Century 21…)
83 + servent aussi de source de secours, dédoublonnées par n° Centris — aucun
84 + double-comptage.
73 85 </p>
74 86 </>
75 87 )}
modified frontend/src/styles.css +151 −43
@@ -4,29 +4,31 @@
4 4 styles.css : système de design « éditorial sharp » — thème clair
5 5 · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 6 · Bordures encre + ombres décalées (néo-brutalisme raffiné)
7 · Accent électrique lime sur encre verte profonde · 100 % adaptatif mobile
7 + · Accent rouge vif sur encre chocolat · 100 % adaptatif mobile
8 8 ----------------------------------------------------------------------------- */
9 9 :root {
10 --paper: #f5f3ee;
10 + /* Palette ROUGE — distincte de Lou-Ka (lime/vert). Noms de tokens conservés :
11 + `--lime` = accent vif (rouge), `--green` = rouge profond (secondaire). */
12 + --paper: #f7f1ef;
11 13 --surface: #ffffff;
12 --surface-2: #faf9f5;
13 --ink: #141814;
14 --ink-2: #4d5551;
15 --ink-3: #8b928c;
16 --line: rgba(20, 24, 20, 0.14);
17 --line-strong: rgba(20, 24, 20, 0.85);
18 --green: #1c5c41;
19 --green-deep: #123f2e;
20 --lime: #d9f26b;
21 --lime-soft: #f0f9d2;
14 + --surface-2: #fdf6f5;
15 + --ink: #1a1214; /* encre chocolat/near-black chaud */
16 + --ink-2: #5a4d50;
17 + --ink-3: #9b8b8e;
18 + --line: rgba(26, 18, 20, 0.14);
19 + --line-strong: rgba(26, 18, 20, 0.85);
20 + --green: #b3202b; /* rouge profond (accent secondaire / kicker) */
21 + --green-deep: #7f1620;
22 + --lime: #e23744; /* rouge vif (accent principal) */
23 + --lime-soft: #fbe0e2;
22 24 --amber: #e8a33d;
23 25 --amber-soft: #fdf3e2;
24 --danger: #b3423a;
25 --r-card: 10px;
26 --r-ctl: 6px;
27 --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
26 + --danger: #b3202b;
27 + --r-card: 14px;
28 + --r-ctl: 8px;
29 + --shadow-flat: 0 1px 2px rgba(26, 18, 20, 0.06);
28 30 --shadow-off: 6px 6px 0 var(--ink);
29 --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
31 + --shadow-off-soft: 8px 8px 0 rgba(226, 55, 68, 0.14);
30 32 --font-display: "Space Grotesk", system-ui, sans-serif;
31 33 --font-body: "Inter", system-ui, sans-serif;
32 34 --font-mono: "JetBrains Mono", ui-monospace, monospace;
@@ -49,7 +51,7 @@ h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; marg
49 51 a { color: inherit; text-decoration: none; }
50 52 button { font-family: inherit; }
51 53 img { display: block; }
52 ::selection { background: var(--lime); color: var(--ink); }
54 +::selection { background: var(--lime); color: #fff; }
53 55
54 56 .mono { font-family: var(--font-mono); }
55 57 .kicker {
@@ -65,7 +67,7 @@ img { display: block; }
65 67 /* ================= Header ================= */
66 68 .header {
67 69 position: sticky; top: 0; z-index: 50;
68 background: rgba(245, 243, 238, 0.88);
70 + background: rgba(247, 241, 239, 0.88);
69 71 backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
70 72 border-bottom: 2px solid var(--ink);
71 73 }
@@ -81,7 +83,7 @@ img { display: block; }
81 83 .nav a.active { background: var(--ink); color: var(--lime); }
82 84
83 85 /* ================= Ticker ================= */
84 .ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(217, 242, 107, 0.25); }
86 +.ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(226, 55, 68, 0.25); }
85 87 .ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; }
86 88 .ticker span { padding: 0 26px; position: relative; }
87 89 .ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
@@ -93,13 +95,13 @@ img { display: block; }
93 95 @media (max-width: 640px) { .hero { padding: 34px 0 14px; } }
94 96 .hero h1 { font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98; text-transform: uppercase; letter-spacing: -0.035em; max-width: 940px; margin-top: 14px; }
95 97 .hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); }
96 .hero h1 .hl { background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
98 +.hero h1 .hl { background: var(--lime); color: #fff; padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
97 99 .hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; }
98 100 .stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
99 .stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); }
101 +.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.12); }
100 102 .stat-chip b { color: var(--ink); font-weight: 700; }
101 103 .stat-chip .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; }
102 @keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(217, 242, 107, 0.4); } }
104 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(226, 55, 68, 0.4); } }
103 105
104 106 /* ================= Filter bar ================= */
105 107 .filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; }
@@ -117,7 +119,7 @@ img { display: block; }
117 119 .range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
118 120 .f-more { flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; transition: all 0.13s ease; }
119 121 .f-more:hover { background: var(--lime-soft); }
120 .f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.22); }
122 +.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(26, 18, 20,0.22); }
121 123 .f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0; animation: adv-in 0.18s ease; }
122 124 @keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
123 125 .f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
@@ -135,7 +137,7 @@ img { display: block; }
135 137
136 138 .pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
137 139 .pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease; }
138 .pill:hover { background: var(--lime); }
140 +.pill:hover { background: var(--lime); color: #fff; }
139 141 .pill-x { font-size: 10px; opacity: 0.65; }
140 142 .pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
141 143 .pill-clear:hover { background: var(--danger); color: #fff; }
@@ -143,17 +145,17 @@ img { display: block; }
143 145
144 146 .btn { border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; }
145 147 .btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
146 .btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
148 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); }
147 149 .btn-primary:hover { background: var(--green-deep); }
148 150 .btn-ghost { background: transparent; color: var(--ink); }
149 .btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); }
151 +.btn-ghost:hover { background: var(--lime); color: #fff; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.2); }
150 152
151 153 /* ================= Chips ================= */
152 154 .chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
153 155 .chips::-webkit-scrollbar { display: none; }
154 156 .chip { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px; transition: all 0.13s ease; }
155 157 .chip:hover { background: var(--lime-soft); transform: translateY(-1px); }
156 .chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.2); }
158 +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(26, 18, 20,0.2); }
157 159
158 160 /* ================= Results ================= */
159 161 .results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; }
@@ -229,7 +231,7 @@ img { display: block; }
229 231 .carousel-track::-webkit-scrollbar { display: none; }
230 232 .carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; }
231 233 .carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; }
232 .carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(20, 24, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; }
234 +.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(26, 18, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; }
233 235 .carousel-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; }
234 236 .carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
235 237 @media (max-width: 640px) { .carousel-nav { display: none; } }
@@ -266,8 +268,8 @@ img { display: block; }
266 268 .broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; }
267 269 .broker-tel { display: inline-block; margin-top: 6px; color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
268 270
269 .cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(20,24,20,0.25); transition: all 0.14s ease; margin-top: 6px; }
270 .cta:hover { background: var(--lime); color: var(--ink); }
271 +.cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); transition: all 0.14s ease; margin-top: 6px; }
272 +.cta:hover { background: var(--lime); color: #fff; }
271 273 .cta:active { transform: translate(2px, 2px); box-shadow: none; }
272 274 .f-hero .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; }
273 275
@@ -295,7 +297,7 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
295 297 .mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; }
296 298 .mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; }
297 299
298 .cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(245, 243, 238, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
300 +.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(247, 241, 239, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
299 301 .cta-sticky .cta { flex: 1; margin: 0; }
300 302 .cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
301 303 @media (min-width: 900px) { .cta-sticky { display: none; } }
@@ -314,7 +316,7 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
314 316 .src-table tr:last-child td { border-bottom: none; }
315 317 .src-table tr:hover td { background: var(--surface-2); }
316 318 .src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
317 .pill.ok { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
319 +.pill.ok { background: var(--lime); color: #fff; border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
318 320 .pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
319 321 .count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; border-bottom: 1.5px solid var(--lime); }
320 322
@@ -323,11 +325,11 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
323 325 .stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
324 326 .stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; }
325 327 .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
326 .tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); }
328 +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1); }
327 329 .tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; }
328 330 .tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
329 331 .hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); }
330 .hero-tile .tile-k { color: rgba(217, 242, 107, 0.7); }
332 +.hero-tile .tile-k { color: rgba(226, 55, 68, 0.7); }
331 333 .viz-card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; }
332 334 .viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
333 335 .viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
@@ -357,7 +359,7 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
357 359 .map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
358 360 @media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } }
359 361 .maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); }
360 .maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(20, 24, 20, 0.8); }
362 +.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(26, 18, 20, 0.8); }
361 363 .mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
362 364 .mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); }
363 365 .mv-pop-body { padding: 10px 12px 12px; }
@@ -369,13 +371,13 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
369 371 .mv-pop-cta:hover { background: var(--green-deep); }
370 372
371 373 /* ================= Menu mobile ================= */
372 .menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
373 .menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(20,24,20,0.15); }
374 +.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
375 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(26, 18, 20,0.15); }
374 376 .menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; }
375 377 .menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
376 378 .menu-btn.open span:nth-child(2) { opacity: 0; }
377 379 .menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
378 .mobile-menu { display: none; position: absolute; left: 0; right: 0; top: 100%; background: var(--paper); border-bottom: 2px solid var(--ink); box-shadow: 0 18px 30px rgba(20, 24, 20, 0.18); padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.22s ease; }
380 +.mobile-menu { display: none; position: absolute; left: 0; right: 0; top: 100%; background: var(--paper); border-bottom: 2px solid var(--ink); box-shadow: 0 18px 30px rgba(26, 18, 20, 0.18); padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.22s ease; }
379 381 .mobile-menu.open { max-height: 480px; opacity: 1; }
380 382 .mm-link { display: flex; align-items: center; gap: 12px; padding: 15px 10px; min-height: 52px; font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); opacity: 0; transform: translateY(-8px); transition: opacity 0.25s ease, transform 0.25s ease; }
381 383 .mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
@@ -384,16 +386,16 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
384 386 .mm-arrow { margin-left: auto; opacity: 0.25; }
385 387 .mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
386 388 .mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; }
387 .mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px); }
389 +.mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); }
388 390 @media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } .mobile-menu { display: block; } }
389 391
390 392 /* ================= Footer ================= */
391 .footer { background: var(--ink); color: rgba(245, 243, 238, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; }
393 +.footer { background: var(--ink); color: rgba(247, 241, 239, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; }
392 394 .footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; }
393 395 .footer .fbrand .ka { color: var(--lime); }
394 396 .footer b { color: var(--paper); }
395 397 .footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 760px; }
396 .footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(245,243,238,0.45); margin-top: 18px; }
398 +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(247, 241, 239,0.45); margin-top: 18px; }
397 399
398 400 /* ================= Mobile : feuille de filtres + FAB ================= */
399 401 .sheet-head { display: none; }
@@ -418,10 +420,116 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
418 420 .sheet-close { border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; }
419 421 .filterbar.open .sheet-apply { display: block; width: 100%; }
420 422 .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); }
421 .fab { display: flex; align-items: center; gap: 6px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(20, 24, 20, 0.25); }
423 + .fab { display: flex; align-items: center; gap: 6px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(26, 18, 20, 0.25); }
422 424 .fab:active { transform: translateX(-50%) scale(0.97); }
423 425 .results-head { margin-top: 20px; }
424 426 .chips { margin-right: -16px; padding-right: 16px; }
425 427 .notice { padding: 48px 16px; }
426 428 }
427 429 @media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } }
430 +
431 +/* --- Sources : bannières → sous-agences -------------------------------- */
432 +.franchise-list { display: flex; flex-direction: column; gap: 10px; margin: 18px 0; }
433 +.franchise { border: 2px solid var(--ink); background: var(--surface); box-shadow: var(--shadow-off-soft); }
434 +.franchise-head {
435 + width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
436 + background: none; border: 0; cursor: pointer; font-family: var(--font-display);
437 + font-size: 16px; font-weight: 600; color: var(--ink); text-align: left;
438 +}
439 +.franchise-head:hover { background: var(--surface-2); }
440 +.fh-caret { color: var(--green); width: 14px; }
441 +.fh-name { flex: 1; }
442 +.fh-sub { font-size: 12px; font-weight: 500; color: var(--ink-3); font-family: var(--font-mono); }
443 +.fh-count {
444 + font-family: var(--font-mono); font-weight: 700; background: var(--ink);
445 + color: var(--lime); padding: 3px 10px; min-width: 64px; text-align: right;
446 +}
447 +.subagency-list { list-style: none; margin: 0; padding: 0 16px 10px 40px; border-top: 1px solid var(--line); }
448 +.subagency-list li {
449 + display: flex; align-items: center; justify-content: space-between; gap: 12px;
450 + padding: 8px 0; border-bottom: 1px dotted var(--line); font-size: 14px;
451 +}
452 +.subagency-list li:last-child { border-bottom: 0; }
453 +.sa-name { color: var(--ink-2); }
454 +
455 +/* --- Pagination (accueil) ---------------------------------------------- */
456 +.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
457 + gap: 6px; margin: 32px 0 8px; }
458 +.pager-btn {
459 + font-family: var(--font-mono); font-size: 14px; min-width: 40px; padding: 8px 12px;
460 + border: 2px solid var(--ink); background: var(--surface); color: var(--ink);
461 + cursor: pointer; box-shadow: var(--shadow-off-soft);
462 +}
463 +.pager-btn:hover:not(:disabled) { background: var(--lime-soft); }
464 +.pager-btn.on { background: var(--ink); color: var(--lime); font-weight: 700; }
465 +.pager-btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; }
466 +.pager-gap { padding: 0 4px; color: var(--ink-3); }
467 +.pager-info { width: 100%; text-align: center; margin-top: 8px; font-size: 12px;
468 + color: var(--ink-3); font-family: var(--font-mono); }
469 +
470 +
471 +
472 +/* --- Le quartier (census / proximité / chaleur / crime) — porté de Lou-Ka --- */
473 +/* --- Section « Le quartier » (fiche) --------------------------------------- */
474 +.quartier { margin-top: 34px; }
475 +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; }
476 +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; }
477 +.q-grid {
478 + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
479 +}
480 +.q-grid { grid-template-columns: repeat(2, 1fr); }
481 +.q-cell {
482 + background: var(--surface); border: 1.5px solid var(--line-strong);
483 + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat);
484 +}
485 +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
486 +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
487 + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; }
488 +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; }
489 +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
490 +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); }
491 +.q-bar-label { flex-basis: 120px; font-size: 12px; }
492 +.q-bar-track {
493 + flex: 1; height: 10px; background: var(--surface-2);
494 + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden;
495 +}
496 +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; }
497 +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono);
498 + font-size: 11.5px; font-weight: 700; }
499 +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
500 +.q-badge {
501 + display: inline-flex; align-items: center; gap: 6px;
502 + border: 1.5px solid var(--line-strong); border-radius: 999px;
503 + padding: 7px 13px; font-size: 12.5px; background: var(--surface);
504 +}
505 +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
506 +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
507 +.quartier .fine { margin-top: 10px; }
508 +.q-cell { padding: 9px 11px; }
509 +.q-val { font-size: 16px; }
510 +.q-bar { gap: 8px; font-size: 12px; }
511 +.q-bar-track { height: 8px; }
512 +.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; }
513 +
514 +/* --- Vrai-Prix : estimation de valeur ---------------------------------- */
515 +.vraiprix { border: 2px solid var(--ink); background: var(--surface-2);
516 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 14px 0; }
517 +.vp-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
518 +.vp-logo { font-family: var(--font-display); font-weight: 700; color: var(--green-deep);
519 + letter-spacing: -.3px; }
520 +.vp-conf { font-family: var(--font-mono); font-size: 11px; padding: 2px 7px; border: 1px solid var(--ink); }
521 +.vp-conf-A { background: var(--lime); color: #fff; } .vp-conf-B { background: var(--lime-soft); }
522 +.vp-conf-C { background: var(--amber-soft); } .vp-conf-D { background: #f0d9d9; }
523 +.vp-k { font-size: 11px; color: var(--ink-3); text-transform: uppercase;
524 + letter-spacing: .5px; margin-top: 8px; }
525 +.vp-value { font-family: var(--font-display); font-size: 26px; font-weight: 700;
526 + letter-spacing: -1px; color: var(--ink); }
527 +.vp-range { font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); }
528 +.vp-delta { font-size: 13px; margin-top: 8px; padding: 6px 8px; border-radius: var(--r-ctl); }
529 +.vp-over { background: #f6e2e2; color: #8a2b2b; }
530 +.vp-under { background: #e2f0e6; color: #1c5c41; }
531 +.vp-fair { background: var(--surface); color: var(--ink-2); }
532 +.vp-link { display: block; text-align: center; margin-top: 10px; font-weight: 700;
533 + font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff;
534 + text-decoration: none; }
535 +.vp-link:hover { background: var(--green); }
modified immoka/connectors/base.py +33 −0
@@ -18,6 +18,7 @@ USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
18 18 "ImmoKaBot/1.0 (+https://www.immo-ka.com/bot; contact@spboucher.ai)")
19 19
20 20 FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
21 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
21 22
22 23
23 24 class BaseConnector:
@@ -86,6 +87,38 @@ class BaseConnector:
86 87 data = resp.json()
87 88 return (data.get("data") or {}).get("html", "")
88 89
90 + def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
91 + rendering_wait: int = 0, country: str = "ca",
92 + wait_for_selector: str | None = None) -> str:
93 + """HTML rendu via Scrapfly (ASP = bypass anti-bot avancé + rendu JS).
94 +
95 + Nécessite SCRAPFLY_KEY dans l'environnement (.env). Meilleur que Firecrawl
96 + sur Cloudflare avancé et sur les SPA chargées via XHR (BoldTrail, etc.).
97 + Retourne le HTML (result.content) ou "" en cas d'échec ASP.
98 + """
99 + key = os.environ.get("SCRAPFLY_KEY")
100 + if not key:
101 + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
102 + params: dict = {"key": key, "url": url, "country": country}
103 + if asp:
104 + params["asp"] = "true"
105 + if render_js:
106 + params["render_js"] = "true"
107 + if rendering_wait:
108 + params["rendering_wait"] = rendering_wait
109 + if wait_for_selector:
110 + params["wait_for_selector"] = wait_for_selector
111 + wait = self.request_delay - (time.time() - self._last_request)
112 + if wait > 0:
113 + time.sleep(wait)
114 + resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
115 + self._last_request = time.time()
116 + try:
117 + data = resp.json()
118 + except ValueError:
119 + return ""
120 + return ((data.get("result") or {}).get("content")) or ""
121 +
89 122 def detail(self, external_id: str, key: str, fetch_fn) -> dict:
90 123 """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
91 124 la propriété est nouvelle ou si sa clé (hash du contenu liste) a changé.
added immoka/connectors/duproprio.py +168 −0
@@ -0,0 +1,168 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/duproprio.py : DuProprio (FSBO — vente sans courtier)
5 +#
6 +# DuProprio est la grande plateforme québécoise de vente « à vendre par le
7 +# propriétaire » (sans agence). Segment complémentaire absent de toutes les
8 +# bannières de courtage. Énumération EXHAUSTIVE via les sitemaps FR par région
9 +# du Québec (`/sitemaps/fr/<region>-listings.xml.gz`) : l'URL encode déjà
10 +# région, ville, type et l'id DuProprio (= external_id). La fiche détail
11 +# (JSON-LD Product) fournit prix, description et galerie ; enrichissement
12 +# plafonné + cache (comme les autres connecteurs). Scrapfly en secours si le
13 +# fetch direct est bloqué.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import gzip
18 +import io
19 +import os
20 +import re
21 +
22 +from .base import BaseConnector
23 +from . import _detailutil as du
24 +from ..normalize import normalize_property_type, parse_price
25 +from ..schema import PropertyListing
26 +
27 +SITEMAP_INDEX = "https://duproprio.com/sitemaps/fr/index.xml.gz"
28 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_DUPROPRIO_DETAIL_LIMIT", "500"))
29 +AGENCY = "DuProprio (sans courtier)"
30 +
31 +_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")
32 +# /fr/<region>/<ville>/<type>-a-vendre/<slug>-<id>
33 +_URL_RE = re.compile(
34 + r"https://duproprio\.com/fr/([a-z0-9-]+)/([a-z0-9-]+)/([a-z0-9-]+?)-a-vendre/([a-z0-9-]+?)-(\d{5,})/?$",
35 + re.I)
36 +_PHOTO_RE = re.compile(
37 + r"https://photos\.duproprio\.com/photos/public/for_sale/\d+/\d+/[^\"'\\ ]+?\.jpg", re.I)
38 +_LDPROD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)
39 +
40 +
41 +class DuProprioConnector(BaseConnector):
42 + source_id = "duproprio"
43 + request_delay = 0.4
44 +
45 + def fetch(self) -> list[PropertyListing]:
46 + by_id: dict[str, PropertyListing] = {}
47 + for sm in self._region_listing_sitemaps():
48 + for url in self._sitemap_locs(sm):
49 + lst = self._to_listing(url)
50 + if lst and lst.external_id not in by_id:
51 + by_id[lst.external_id] = lst
52 + listings = list(by_id.values())
53 + du.enrich(self, listings, DETAIL_LIMIT, _parse_dp_detail,
54 + key="v1", fetch_html=self._fetch_detail)
55 + return listings
56 +
57 + # -- sitemaps --------------------------------------------------------------
58 + def _region_listing_sitemaps(self) -> list[str]:
59 + xml = self._get_gz(SITEMAP_INDEX)
60 + # uniquement les sitemaps d'INSCRIPTIONS par région (Québec)
61 + return [u for u in _LOC_RE.findall(xml) if u.endswith("-listings.xml.gz")]
62 +
63 + def _sitemap_locs(self, url: str) -> list[str]:
64 + return [u for u in _LOC_RE.findall(self._get_gz(url)) if "/fr/" in u]
65 +
66 + def _get_gz(self, url: str) -> str:
67 + try:
68 + raw = self.get(url).content
69 + except Exception:
70 + return ""
71 + try:
72 + return gzip.GzipFile(fileobj=io.BytesIO(raw)).read().decode("utf-8", "ignore")
73 + except OSError:
74 + return raw.decode("utf-8", "ignore") # déjà décompressé
75 +
76 + # -- mapping ---------------------------------------------------------------
77 + def _to_listing(self, url: str) -> PropertyListing | None:
78 + m = _URL_RE.match(url.strip())
79 + if not m:
80 + return None
81 + region_s, city_s, type_s, addr_s, did = m.groups()
82 + return PropertyListing(
83 + source=self.source_id, external_id=did, url=url,
84 + title=_deslug(addr_s),
85 + address=_deslug(re.sub(r"^(hab|com|ter|multi|imm)-", "", addr_s)),
86 + city=_deslug(city_s),
87 + region=_region(region_s),
88 + property_type=normalize_property_type(type_s.replace("-", " ")),
89 + agency=AGENCY, broker_name="",
90 + )
91 +
92 + def _fetch_detail(self, url: str) -> str:
93 + html = ""
94 + try:
95 + html = self.get(url).text
96 + except Exception:
97 + html = ""
98 + if "application/ld+json" not in html: # bloqué -> Scrapfly (ASP)
99 + try:
100 + html = self.get_scrapfly(url, render_js=False, asp=True)
101 + except Exception:
102 + pass
103 + return html
104 +
105 +
106 +# ---------------------------------------------------------------------------
107 +def _parse_dp_detail(html: str) -> dict:
108 + import json, html as _h
109 + out: dict = {}
110 + for block in _LDPROD_RE.findall(html):
111 + try:
112 + d = json.loads(block)
113 + except ValueError:
114 + continue
115 + if isinstance(d, dict) and d.get("@type") == "Product":
116 + off = d.get("offers") or {}
117 + p = parse_price(str(off.get("price") or "")) if off.get("price") else None
118 + if p:
119 + out["price"] = p
120 + out["price_label"] = f"{p:,.0f} $".replace(",", " ")
121 + if d.get("description"):
122 + out["description"] = _h.unescape(str(d["description"])).strip()[:4000]
123 + break
124 + # galerie COMPLÈTE : le JSON « "photos":[{...}] » de la page liste toutes les
125 + # photos (le JSON-LD/HTML n'en montre qu'une). On prend la plus haute résolution.
126 + imgs, seen = [], set()
127 + mgal = re.search(r'"photos"\s*:\s*(\[\{.*?\}\])', html, re.S)
128 + if mgal:
129 + try:
130 + for ph in json.loads(mgal.group(1)):
131 + fmt = ph.get("formats") or {}
132 + rel = fmt.get("1600") or fmt.get("1024") or fmt.get("600")
133 + if rel:
134 + u = rel if rel.startswith("http") else f"https://photos.duproprio.com/{rel.lstrip('/')}"
135 + if u not in seen:
136 + seen.add(u); imgs.append(u)
137 + except ValueError:
138 + pass
139 + if not imgs: # repli : URLs photos dans le HTML brut
140 + for u in _PHOTO_RE.findall(html):
141 + if u not in seen:
142 + seen.add(u); imgs.append(u)
143 + if imgs:
144 + out["images"] = imgs
145 + text = re.sub(r"\s+", " ", _h_unescape(html))
146 + mb = re.search(r"(\d+)\s*chambre", text, re.I)
147 + if mb:
148 + out["bedrooms"] = int(mb.group(1))
149 + ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", text, re.I)
150 + if ms:
151 + out["bathrooms"] = int(ms.group(1))
152 + return out
153 +
154 +
155 +def _h_unescape(html: str) -> str:
156 + import html as _h
157 + return _h.unescape(re.sub(r"<[^>]+>", " ", html))
158 +
159 +
160 +def _deslug(s: str) -> str:
161 + return re.sub(r"\s+", " ", s.replace("-", " ").replace("_", " ")).strip().title()
162 +
163 +
164 +def _region(slug: str) -> str:
165 + r = _deslug(slug)
166 + return (r.replace("Quebec", "Québec").replace("Montreal", "Montréal")
167 + .replace("Monteregie", "Montérégie").replace("Laurentides", "Laurentides")
168 + .replace("Mauricie", "Mauricie").replace("Estrie", "Estrie"))
modified immoka/connectors/remax_quebec.py +10 −1
@@ -61,7 +61,7 @@ class RemaxQuebecConnector(BaseConnector):
61 61 con = db.connect()
62 62 budget = DETAIL_LIMIT
63 63 for lst in listings:
64 key = f"v2|{lst.price_label or '-'}" # v2 = parser enrichi ; refetch si prix change
64 + key = f"v3|{lst.price_label or '-'}" # v3 = + agence/bureau ; refetch si prix change
65 65 cached = db.get_cached_detail(con, self.source_id, lst.external_id, key)
66 66 if cached is None:
67 67 if budget <= 0:
@@ -269,6 +269,12 @@ def parse_remax_detail(html: str) -> dict:
269 269 if mp:
270 270 out["broker_phone"] = mp.group(0)
271 271
272 + # agence / bureau (« RE/MAX ALLIANCE INC. ») — pour les Sources par sous-agence
273 + ma = re.search(r'RE/MAX[^<,|"\n]{1,40}\bINC\.?', html)
274 + if ma:
275 + out["agency"] = _html.unescape(re.sub(r'\s+', ' ', ma.group(0))).strip().title() \
276 + .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.")
277 +
272 278 text = _flatten(html)
273 279
274 280 # champs numériques principaux
@@ -352,6 +358,9 @@ def _apply_detail(lst: PropertyListing, d: dict) -> None:
352 358 # le nom réel du courtier (page détail) remplace le placeholder « RE/MAX Québec »
353 359 if d.get("broker_name"):
354 360 lst.broker_name = d["broker_name"]
361 + # agence/bureau -> sous-agence pour les Sources
362 + if d.get("agency"):
363 + lst.agency = d["agency"]
355 364 for f in ("description", "bedrooms", "bathrooms", "powder_rooms",
356 365 "year_built", "area_sqft", "lot_sqft", "lat", "lng",
357 366 "broker_phone"):
modified immoka/connectors/royal_lepage.py +25 −1
@@ -15,14 +15,22 @@
15 15 from __future__ import annotations
16 16
17 17 import html as _html
18 +import os
18 19 import re
19 20
20 21 from .base import BaseConnector
22 +from . import _detailutil as du
21 23 from ..normalize import parse_price
22 24 from ..schema import PropertyListing
23 25
24 26 BASE = "https://www.royallepage.ca/fr/search/homes"
25 27 MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250)
28 +# Le nom du BUREAU (« Royal LePage Humania »…) n'est pas sur la carte : il vient
29 +# de la fiche détail. Enrichissement plafonné + cache (agency se remplit au fil
30 +# des cycles). = affichage des Sources par sous-agence.
31 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_RLP_DETAIL_LIMIT", "500"))
32 +_OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)',
33 + re.S | re.I)
26 34
27 35 # Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches
28 36 # + dédoublonnage MLS couvre la province.
@@ -65,7 +73,14 @@ class RoyalLepageConnector(BaseConnector):
65 73 by_id: dict[str, PropertyListing] = {}
66 74 for name, lat, lng in ANCHORS:
67 75 self._search_anchor(name, lat, lng, by_id)
68 return list(by_id.values())
76 + listings = list(by_id.values())
77 + # bureau (agency) via la fiche détail — plafonné, cache accumulé
78 + du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1")
79 + for lst in listings:
80 + office = lst.details.pop("__agency", "")
81 + if office:
82 + lst.agency = office
83 + return listings
69 84
70 85 def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None:
71 86 empty_streak = 0
@@ -130,3 +145,12 @@ class RoyalLepageConnector(BaseConnector):
130 145 broker_name="Royal LePage",
131 146 ))
132 147 return out
148 +
149 +
150 +def _parse_rlp_office(html: str) -> dict:
151 + """Extrait le bureau/brokerage de la fiche détail Royal LePage."""
152 + m = _OFFICE_RE.search(html)
153 + if not m:
154 + return {}
155 + office = _html.unescape(m.group(1)).strip()
156 + return {"details": {"__agency": office}} if office else {}
modified immoka/connectors/sutton.py +9 −1
@@ -69,7 +69,10 @@ class SuttonConnector(BaseConnector):
69 69 # fiche détail : prix, chambres/sdb, galerie complète (~80 photos),
70 70 # description, pièces, inclusions, caractéristiques — plafonné, cache
71 71 # accumulé entre cycles (tout le parc finit enrichi au fil des syncs).
72 du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v3")
72 + du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v4")
73 + for lst in listings:
74 + office = lst.details.pop("__agency", "")
75 + lst.agency = office or "Groupe Sutton Québec"
73 76 return listings
74 77
75 78 def _sitemap_listings(self):
@@ -301,4 +304,9 @@ def parse_sutton_detail(html: str) -> dict:
301 304 _det = du.centris_details(du.flatten(html))
302 305 if _det:
303 306 out.setdefault("details", {}).update(_det)
307 +
308 + # sous-agence (bureau) : lien /agence-immobiliere/NNN-<slug>.html
309 + ma = re.search(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', html, re.I)
310 + if ma:
311 + out.setdefault("details", {})["__agency"] = _deslug(ma.group(1))
304 312 return out
modified immoka/connectors/via_capitale.py +4 −0
@@ -78,6 +78,10 @@ class ViaCapitaleConnector(BaseConnector):
78 78 cards = []
79 79 for attempt in range(2):
80 80 try:
81 + # Firecrawl (proxy stealth) : c'est LUI qui rend les cartes de
82 + # Via Capitale. Testé : Scrapfly ASP franchit Cloudflare mais
83 + # ne déclenche pas le rendu des inscriptions (0 carte) ; on
84 + # garde donc Firecrawl, qui ramène bien les ~5 300 propriétés.
81 85 html = self.get_rendered(url, wait_for=6000, proxy="stealth")
82 86 except Exception:
83 87 html = ""
modified immoka/db.py +71 −8
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS listings (
51 51 status TEXT DEFAULT 'a-vendre',
52 52 broker_name TEXT,
53 53 broker_phone TEXT,
54 + agency TEXT,
54 55 description TEXT,
55 56 features TEXT, -- JSON (liste de textes source)
56 57 details TEXT, -- JSON (champs structurés)
@@ -63,7 +64,8 @@ CREATE TABLE IF NOT EXISTS listings (
63 64 last_seen REAL,
64 65 updated_at REAL,
65 66 miss_count INTEGER DEFAULT 0,
66 active INTEGER DEFAULT 1
67 + active INTEGER DEFAULT 1,
68 + dup_hidden INTEGER DEFAULT 0 -- 1 = doublon de sous-agence masqué (dédup Centris)
67 69 );
68 70 CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source);
69 71 CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city);
@@ -108,20 +110,80 @@ CREATE TABLE IF NOT EXISTS geocode_cache (
108 110 failed INTEGER DEFAULT 0,
109 111 ts REAL
110 112 );
113 +
114 +CREATE TABLE IF NOT EXISTS poi_cache (
115 + coord_key TEXT PRIMARY KEY, -- "lat,lng" arrondi à 4 décimales (~11 m)
116 + lat REAL,
117 + lng REAL,
118 + pois TEXT, -- JSON : [{cat, name, dist_m}] (plus proche/catégorie)
119 + ts REAL
120 +);
111 121 """
112 122
113 123
124 +_SCHEMA_READY = False # le schéma/migration ne s'exécute qu'UNE fois par process
125 +
126 +
127 +def _init_schema(con: sqlite3.Connection) -> None:
128 + """Création de schéma + migration — coûteuse (write-lock). À faire une seule
129 + fois par process : l'exécuter à chaque connexion sérialisait les requêtes du
130 + web sur un verrou d'écriture (deadlock/starvation sous charge)."""
131 + con.executescript(_SCHEMA)
132 + cols = {r["name"] for r in con.execute("PRAGMA table_info(listings)")}
133 + if "agency" not in cols:
134 + con.execute("ALTER TABLE listings ADD COLUMN agency TEXT")
135 + if "dup_hidden" not in cols:
136 + con.execute("ALTER TABLE listings ADD COLUMN dup_hidden INTEGER DEFAULT 0")
137 + if "dauid" not in cols: # aire de diffusion 2021 (stats de quartier)
138 + con.execute("ALTER TABLE listings ADD COLUMN dauid TEXT")
139 + if "vraiprix" not in cols: # estimation Vrai-Prix (JSON) + lien analyse
140 + con.execute("ALTER TABLE listings ADD COLUMN vraiprix TEXT")
141 + con.execute("CREATE INDEX IF NOT EXISTS idx_listings_duphidden ON listings(dup_hidden)")
142 + con.commit()
143 +
144 +
145 +def refresh_dedup(con: sqlite3.Connection) -> int:
146 + """Pré-calcule la déduplication de la famille sous-agences (`*_ag_*`) dans la
147 + colonne `dup_hidden`, pour que la lecture soit instantanée (« AND dup_hidden=0 »)
148 + au lieu d'un sous-select corrélé par ligne (300 s sur 75 k lignes).
149 +
150 + Règle (identique à l'ancienne clause) : une fiche de sous-agence est masquée
151 + si une fiche de plus haute priorité — même n° Centris (external_id), active —
152 + existe : le flux central/agence principale d'abord, sinon la sous-agence au
153 + plus petit uid. Les fiches non sous-agence ne sont jamais masquées.
154 + Retourne le nombre de fiches masquées."""
155 + AG = "source LIKE '%\\_ag\\_%' ESCAPE '\\'"
156 + NOTAG = "source NOT LIKE '%\\_ag\\_%' ESCAPE '\\'"
157 + con.execute("UPDATE listings SET dup_hidden=0")
158 + # 1) masquer les sous-agences dont le n° Centris est porté par une fiche
159 + # canonique (non sous-agence) active — semi-jointure, rapide.
160 + con.execute(
161 + f"UPDATE listings SET dup_hidden=1 WHERE active=1 AND {AG}"
162 + f" AND external_id IN (SELECT external_id FROM listings"
163 + f" WHERE active=1 AND {NOTAG})")
164 + # 2) parmi les sous-agences restantes (sans canonique), ne garder que le plus
165 + # petit uid par n° Centris.
166 + con.execute(
167 + f"UPDATE listings SET dup_hidden=1 WHERE active=1 AND {AG} AND dup_hidden=0"
168 + f" AND uid > (SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
169 + f" AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)")
170 + n = con.execute("SELECT COUNT(*) c FROM listings WHERE dup_hidden=1").fetchone()["c"]
171 + con.commit()
172 + return n
173 +
174 +
114 175 def connect() -> sqlite3.Connection:
176 + global _SCHEMA_READY
115 177 DB_PATH.parent.mkdir(parents=True, exist_ok=True)
116 178 con = sqlite3.connect(DB_PATH, timeout=60)
117 179 con.row_factory = sqlite3.Row
118 # WAL + busy_timeout : tolère les accès concurrents (watcher + web + sync
119 # manuel écrivent la même base) sans « database is locked ».
180 + # WAL + busy_timeout : accès concurrents (watcher + web) sans « db is locked ».
120 181 con.execute("PRAGMA journal_mode=WAL")
121 182 con.execute("PRAGMA busy_timeout=60000")
122 183 con.execute("PRAGMA synchronous=NORMAL")
123 con.executescript(_SCHEMA)
124 con.commit()
184 + if not _SCHEMA_READY:
185 + _init_schema(con)
186 + _SCHEMA_READY = True
125 187 return con
126 188
127 189
@@ -191,6 +253,7 @@ def sync_source(con: sqlite3.Connection, source: str,
191 253 area_sqft=lst.area_sqft, lot_sqft=lst.lot_sqft,
192 254 year_built=lst.year_built, mls=lst.mls, status=lst.status,
193 255 broker_name=lst.broker_name, broker_phone=lst.broker_phone,
256 + agency=lst.agency,
194 257 description=lst.description,
195 258 features=json.dumps(lst.features, ensure_ascii=False),
196 259 details=json.dumps(lst.details, ensure_ascii=False),
@@ -203,14 +266,14 @@ def sync_source(con: sqlite3.Connection, source: str,
203 266 address, sector, city, region, property_type, price,
204 267 price_label, bedrooms, bathrooms, powder_rooms, area_sqft,
205 268 lot_sqft, year_built, mls, status, broker_name, broker_phone,
206 description, features, details, images, lat, lng,
269 + agency, description, features, details, images, lat, lng,
207 270 content_hash, first_seen, last_seen, updated_at,
208 271 miss_count, active)
209 272 VALUES (:uid,:source,:external_id,:url,:title,:address,
210 273 :sector,:city,:region,:property_type,:price,:price_label,
211 274 :bedrooms,:bathrooms,:powder_rooms,:area_sqft,:lot_sqft,
212 275 :year_built,:mls,:status,:broker_name,:broker_phone,
213 :description,:features,:details,:images,:lat,:lng,
276 + :agency,:description,:features,:details,:images,:lat,:lng,
214 277 :content_hash,:now,:now,:now,0,1)""", params)
215 278 if lst.price is not None:
216 279 con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
@@ -227,7 +290,7 @@ def sync_source(con: sqlite3.Connection, source: str,
227 290 area_sqft=:area_sqft, lot_sqft=:lot_sqft,
228 291 year_built=:year_built, mls=:mls, status=:status,
229 292 broker_name=:broker_name, broker_phone=:broker_phone,
230 description=:description, features=:features,
293 + agency=:agency, description=:description, features=:features,
231 294 details=:details, images=:images,
232 295 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
233 296 content_hash=:content_hash, last_seen=:now,
added immoka/geocode.py +262 −0
@@ -0,0 +1,262 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# geocode.py : géocodage des adresses via Nominatim (OpenStreetMap)
5 +# - cache persistant (table geocode_cache) : une adresse n'est géocodée
6 +# qu'une seule fois, et par immeuble (adresse normalisée), pas par annonce
7 +# - politesse : 1 requête/seconde max, User-Agent identifiable
8 +# - validation : les coordonnées doivent tomber dans la région attendue
9 +# (bounding box par ville), sinon flag geocode_failed — jamais de
10 +# coordonnées bidon
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +import time
16 +
17 +import requests
18 +
19 +from . import db
20 +from .normalize import strip_accents
21 +
22 +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
23 +# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre
24 +# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…)
25 +AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"
26 + "Adresse_Geocodage/GeocodeServer/findAddressCandidates")
27 +AQ_MIN_SCORE = 75
28 +# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête
29 +# User-Agent contient des accents (latin-1)
30 +USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"
31 +REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s
32 +REQUEST_DELAY_AQ = 0.12 # Adresses Québec (MERN) : pas de limite stricte
33 +RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours
34 +
35 +# Bounding boxes (lat_min, lat_max, lng_min, lng_max)
36 +_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin
37 +# garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord
38 +# et la Gaspésie (expansion provinciale) — même boîte que schema.finalize()
39 +_BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)
40 +
41 +_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",
42 + "l'ancienne-lorette", "ancienne-lorette"}
43 +
44 +
45 +def _bbox_for(city: str) -> tuple[float, float, float, float]:
46 + key = strip_accents((city or "").strip().lower())
47 + return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC
48 +
49 +
50 +def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:
51 + return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3]
52 +
53 +
54 +def norm_key(address: str) -> str:
55 + """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.)."""
56 + s = strip_accents((address or "").lower())
57 + s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s)
58 + s = re.sub(r"[^a-z0-9]+", " ", s)
59 + return re.sub(r"\s+", " ", s).strip()
60 +
61 +
62 +class Geocoder:
63 + def __init__(self, con) -> None:
64 + self.con = con
65 + self.session = requests.Session()
66 + self.session.headers["User-Agent"] = USER_AGENT
67 + self._last = 0.0
68 +
69 + @staticmethod
70 + def _clean(address: str) -> str:
71 + """Nettoyages qui aident Nominatim sur les adresses québécoises."""
72 + s = address.strip()
73 + # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique
74 + s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s)
75 + # « 6275 et 6375 boulevard X » -> premier civique
76 + s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s)
77 + # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu
78 + s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I)
79 + # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau
80 + s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I)
81 + # « Vanier (Québec) » -> « Vanier, Québec »
82 + s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I)
83 + # abréviations cardinales : « Rue Salaberry O » -> « Ouest »
84 + s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s)
85 + s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)
86 + return s
87 +
88 + def _build_query(self, address: str, city: str) -> str:
89 + q = self._clean(address)
90 + key = strip_accents(q.lower())
91 + if city and strip_accents(city.lower()) not in key:
92 + q += f", {city}"
93 + if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():
94 + q += ", Québec"
95 + return q + ", Canada"
96 +
97 + def _query_nominatim(self, params: dict) -> tuple[float, float] | None:
98 + """Une requête Nominatim, throttlée."""
99 + wait = REQUEST_DELAY - (time.time() - self._last)
100 + if wait > 0:
101 + time.sleep(wait)
102 + try:
103 + resp = self.session.get(NOMINATIM_URL, params={
104 + "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params,
105 + }, timeout=20)
106 + self._last = time.time()
107 + resp.raise_for_status()
108 + hits = resp.json()
109 + except Exception:
110 + self._last = time.time()
111 + return None
112 + if not hits:
113 + return None
114 + try:
115 + return float(hits[0]["lat"]), float(hits[0]["lon"])
116 + except (KeyError, ValueError):
117 + return None
118 +
119 + def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None:
120 + """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS).
121 +
122 + Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE
123 + pour éviter les correspondances approximatives sur une autre rue.
124 + """
125 + premier = self._clean(address).split(",")[0].strip()
126 + ville = (city or "Québec").strip()
127 + wait = REQUEST_DELAY_AQ - (time.time() - self._last)
128 + if wait > 0:
129 + time.sleep(wait)
130 + try:
131 + resp = self.session.get(AQ_URL, params={
132 + "SingleLine": f"{premier}, {ville}",
133 + "f": "json", "outSR": 4326, "maxLocations": 1,
134 + }, timeout=20)
135 + self._last = time.time()
136 + resp.raise_for_status()
137 + cands = resp.json().get("candidates") or []
138 + except Exception:
139 + self._last = time.time()
140 + return None
141 + if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE:
142 + return None
143 + loc = cands[0].get("location") or {}
144 + try:
145 + return float(loc["y"]), float(loc["x"])
146 + except (KeyError, ValueError):
147 + return None
148 +
149 + def _attempts(self, address: str, city: str) -> list[dict]:
150 + """Stratégies de requête, de la plus précise à la moins précise.
151 +
152 + 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province)
153 + 2) recherche libre complète
154 + 3) structurée rue seule -> centroïde de rue (repli acceptable pour la
155 + carte quand le numéro civique est absent d'OpenStreetMap)
156 + """
157 + premier = self._clean(address).split(",")[0].strip()
158 + ville = (city or "").strip()
159 + if not ville:
160 + k = strip_accents(address.lower())
161 + ville = "Lévis" if "levis" in k else "Québec"
162 + commun = {"city": ville, "state": "Québec", "country": "Canada"}
163 +
164 + tries: list[dict] = []
165 + m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier)
166 + if m:
167 + tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun})
168 + tries.append({"q": self._build_query(address, city)})
169 + if m:
170 + tries.append({"street": m.group(2), **commun})
171 + return tries
172 +
173 + def resolve(self, address: str, city: str) -> tuple[float, float] | None:
174 + """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable."""
175 + key = norm_key(address)
176 + if not key or len(key) < 6:
177 + return None
178 + row = self.con.execute(
179 + "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",
180 + (key,)).fetchone()
181 + if row is not None:
182 + if not row["failed"]:
183 + return (row["lat"], row["lng"])
184 + if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:
185 + return None # échec récent : ne pas marteler l'API
186 +
187 + bbox = _bbox_for(city)
188 + coords = None
189 + # Adresses Québec (MERN) d'abord : rapide, autoritatif au Québec, pas de
190 + # limite stricte. Nominatim (1 req/s) seulement en repli.
191 + provider = "adresses_quebec"
192 + c = self._query_adresses_quebec(address, city)
193 + if c is not None and _in_bbox(*c, bbox):
194 + coords = c
195 + if coords is None:
196 + provider = "nominatim"
197 + for params in self._attempts(address, city):
198 + c = self._query_nominatim(params)
199 + if c is not None and _in_bbox(*c, bbox):
200 + coords = c
201 + break
202 + ok = coords is not None
203 + self.con.execute(
204 + "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)"
205 + " VALUES (?,?,?,?,?,?)"
206 + " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat,"
207 + " lng=excluded.lng, provider=excluded.provider,"
208 + " failed=excluded.failed, ts=excluded.ts",
209 + (key, coords[0] if ok else None, coords[1] if ok else None,
210 + provider, 0 if ok else 1, time.time()))
211 + self.con.commit()
212 + return coords if ok else None
213 +
214 +
215 +def run(limit: int | None = None) -> dict:
216 + """Géocode les annonces actives sans coordonnées, par adresse unique.
217 +
218 + `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de
219 + cache sont gratuits et toujours appliqués).
220 + """
221 + con = db.connect()
222 + geo = Geocoder(con)
223 + rows = con.execute(
224 + """SELECT uid, address, city FROM listings
225 + WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
226 + AND dup_hidden=0
227 + ORDER BY address""").fetchall()
228 +
229 + # regrouper par immeuble (adresse normalisée)
230 + groupes: dict[str, list] = {}
231 + for r in rows:
232 + groupes.setdefault(norm_key(r["address"]), []).append(r)
233 +
234 + done = failed = requests_made = 0
235 + for key, members in groupes.items():
236 + if not key:
237 + continue
238 + cached = con.execute(
239 + "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone()
240 + if cached is None:
241 + if limit is not None and requests_made >= limit:
242 + continue
243 + requests_made += 1
244 + coords = geo.resolve(members[0]["address"], members[0]["city"])
245 + if coords:
246 + for r in members:
247 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
248 + (coords[0], coords[1], r["uid"]))
249 + done += len(members)
250 + else:
251 + # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon
252 + for r in members:
253 + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",
254 + (r["uid"],))
255 + failed += len(members)
256 + con.commit()
257 +
258 + con.close()
259 + stats = {"geocoded": done, "failed": failed,
260 + "unique_addresses": len(groupes), "api_requests": requests_made}
261 + print(f"[immo-ka] geocode {stats}")
262 + return stats
modified immoka/ingest.py +6 −0
@@ -46,6 +46,12 @@ def run(sources: list[str] | None = None) -> list[dict]:
46 46 db.log_failure(con, sid, f"{exc}")
47 47 traceback.print_exc()
48 48 results.append({"source": sid, "error": str(exc)})
49 + # recalcule la déduplication (pré-calculée pour des lectures instantanées)
50 + try:
51 + hidden = db.refresh_dedup(con)
52 + print(f"[immo-ka] dédup: {hidden} doublon(s) de sous-agence masqué(s)")
53 + except Exception:
54 + traceback.print_exc()
49 55 con.close()
50 56 return results
51 57
added immoka/poi.py +248 −0
@@ -0,0 +1,248 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# poi.py : commodités de proximité par immeuble via Overpass (OpenStreetMap)
5 +# Pour chaque immeuble géolocalisé, une requête Overpass unique récupère
6 +# les points d'intérêt utiles à un locataire (épicerie, pharmacie, école,
7 +# garderie, parc, arrêt de bus, gym, clinique…) ; on retient le PLUS PROCHE
8 +# de chaque catégorie avec sa distance. Cache permanent par coordonnées
9 +# (table poi_cache, clé arrondie à 4 décimales ≈ 11 m : les unités d'un
10 +# même immeuble partagent la même entrée). Politesse : 1 requête/seconde.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import json
15 +import math
16 +import time
17 +
18 +import requests
19 +
20 +from . import db
21 +
22 +# Miroirs Overpass (rotation en cas d'erreur/limitation) — kumi.systems
23 +# tolère mieux les gros volumes que l'instance officielle
24 +OVERPASS_URLS = [
25 + "https://overpass.kumi.systems/api/interpreter",
26 + "https://overpass-api.de/api/interpreter",
27 +]
28 +USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"
29 +REQUEST_DELAY = 2.0
30 +REFRESH_AFTER = 90 * 86400 # les POI bougent peu : rafraîchir aux ~3 mois
31 +
32 +# Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union
33 +# d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI
34 +# des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour
35 +# Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local.
36 +TILE = 0.5
37 +TILE_MARGIN = 0.04 # ~4 km > plus grand rayon de catégorie (3 km)
38 +
39 +# Catégories : (clé, libellé FR, sélecteur Overpass, rayon m)
40 +CATEGORIES: list[tuple[str, str, str, int]] = [
41 + ("epicerie", "Épicerie", '["shop"="supermarket"]', 1500),
42 + ("depanneur", "Dépanneur", '["shop"="convenience"]', 800),
43 + ("pharmacie", "Pharmacie", '["amenity"="pharmacy"]', 1500),
44 + ("ecole", "École", '["amenity"="school"]', 1500),
45 + ("garderie", "Garderie", '["amenity"~"^(kindergarten|childcare)$"]', 1500),
46 + ("parc", "Parc", '["leisure"="park"]', 1200),
47 + ("bus", "Arrêt de bus", '["highway"="bus_stop"]', 600),
48 + ("metro", "Métro", '["railway"="station"]["station"="subway"]', 1500),
49 + ("gym", "Gym", '["leisure"="fitness_centre"]', 1500),
50 + ("cafe", "Café", '["amenity"="cafe"]', 1000),
51 + ("clinique", "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]', 2000),
52 + ("hopital", "Hôpital", '["amenity"="hospital"]', 3000),
53 + ("bibliotheque", "Bibliothèque", '["amenity"="library"]', 2000),
54 +]
55 +
56 +LABELS = {cat: label for cat, label, _, _ in CATEGORIES}
57 +
58 +
59 +def coord_key(lat: float, lng: float) -> str:
60 + return f"{round(lat, 4)},{round(lng, 4)}"
61 +
62 +
63 +def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
64 + r = 6371000.0
65 + p1, p2 = math.radians(lat1), math.radians(lat2)
66 + dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
67 + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
68 + return 2 * r * math.asin(math.sqrt(a))
69 +
70 +
71 +def _tile_of(lat: float, lng: float) -> tuple[int, int]:
72 + return (math.floor(lat / TILE), math.floor(lng / TILE))
73 +
74 +
75 +def _build_tile_query(ty: int, tx: int) -> str:
76 + """Tous les POI des catégories dans la tuile (bbox élargie de la marge)."""
77 + s = ty * TILE - TILE_MARGIN
78 + n = (ty + 1) * TILE + TILE_MARGIN
79 + w = tx * TILE - TILE_MARGIN
80 + e = (tx + 1) * TILE + TILE_MARGIN
81 + bbox = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"
82 + parts = [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES]
83 + return f'[out:json][timeout:180];({"".join(parts)});out center tags;'
84 +
85 +
86 +def _match_category(tags: dict) -> str | None:
87 + """Retrouve la catégorie Lou-Ka d'un élément OSM retourné."""
88 + shop = tags.get("shop")
89 + amenity = tags.get("amenity")
90 + leisure = tags.get("leisure")
91 + if shop == "supermarket":
92 + return "epicerie"
93 + if shop == "convenience":
94 + return "depanneur"
95 + if amenity == "pharmacy":
96 + return "pharmacie"
97 + if amenity == "school":
98 + return "ecole"
99 + if amenity in ("kindergarten", "childcare"):
100 + return "garderie"
101 + if leisure == "park":
102 + return "parc"
103 + if tags.get("highway") == "bus_stop":
104 + return "bus"
105 + if tags.get("railway") == "station" and tags.get("station") == "subway":
106 + return "metro"
107 + if leisure == "fitness_centre":
108 + return "gym"
109 + if amenity == "cafe":
110 + return "cafe"
111 + if amenity in ("clinic", "doctors"):
112 + return "clinique"
113 + if amenity == "hospital":
114 + return "hopital"
115 + if amenity == "library":
116 + return "bibliotheque"
117 + return None
118 +
119 +
120 +_RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES}
121 +
122 +
123 +class PoiClient:
124 + def __init__(self) -> None:
125 + self.session = requests.Session()
126 + self.session.headers["User-Agent"] = USER_AGENT
127 + self._last = 0.0
128 + self._url_idx = 0
129 +
130 + def _post(self, query: str) -> list | None:
131 + """POST Overpass avec throttling et rotation de miroir sur erreur."""
132 + wait = REQUEST_DELAY - (time.time() - self._last)
133 + if wait > 0:
134 + time.sleep(wait)
135 + for essai in range(len(OVERPASS_URLS)):
136 + url = OVERPASS_URLS[(self._url_idx + essai) % len(OVERPASS_URLS)]
137 + try:
138 + resp = self.session.post(url, data={"data": query}, timeout=90)
139 + self._last = time.time()
140 + resp.raise_for_status()
141 + self._url_idx = (self._url_idx + essai) % len(OVERPASS_URLS)
142 + return resp.json().get("elements") or []
143 + except Exception:
144 + self._last = time.time()
145 + continue
146 + return None
147 +
148 + def fetch_tile(self, ty: int, tx: int) -> list[dict] | None:
149 + """Tous les POI catégorisés d'une tuile : [{cat, name, lat, lng}]."""
150 + elements = self._post(_build_tile_query(ty, tx))
151 + if elements is None:
152 + return None
153 + pois = []
154 + for el in elements:
155 + tags = el.get("tags") or {}
156 + cat = _match_category(tags)
157 + if cat is None:
158 + continue
159 + elat = el.get("lat") or (el.get("center") or {}).get("lat")
160 + elng = el.get("lon") or (el.get("center") or {}).get("lon")
161 + if elat is None or elng is None:
162 + continue
163 + pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60],
164 + "lat": elat, "lng": elng})
165 + return pois
166 +
167 +
168 +def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]]) -> list[dict]:
169 + """Plus proche POI de chaque catégorie (dans son rayon), trié par distance."""
170 + out = []
171 + for cat, pois in pois_by_cat.items():
172 + radius = _RADII[cat]
173 + # préfiltre rectangulaire bon marché avant l'haversine
174 + dlat_max = radius / 111000.0
175 + dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat))))
176 + best = None
177 + for p in pois:
178 + if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max:
179 + continue
180 + d = _haversine_m(lat, lng, p["lat"], p["lng"])
181 + if d <= radius and (best is None or d < best["dist_m"]):
182 + best = {"cat": cat, "name": p["name"], "dist_m": round(d)}
183 + if best:
184 + out.append(best)
185 + return sorted(out, key=lambda p: p["dist_m"])
186 +
187 +
188 +def run(limit: int | None = None) -> dict:
189 + """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas.
190 +
191 + `limit` borne le nombre de requêtes Overpass de cette exécution
192 + (les entrées déjà en cache ne coûtent rien).
193 + """
194 + con = db.connect()
195 + client = PoiClient()
196 + rows = con.execute(
197 + """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings
198 + WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall()
199 +
200 + now = time.time()
201 + a_faire: list[tuple[float, float]] = []
202 + skipped = 0
203 + for r in rows:
204 + cached = con.execute(
205 + "SELECT ts FROM poi_cache WHERE coord_key=?",
206 + (f"{r['la']},{r['ln']}",)).fetchone()
207 + if cached and now - (cached["ts"] or 0) < REFRESH_AFTER:
208 + skipped += 1
209 + else:
210 + a_faire.append((r["la"], r["ln"]))
211 + if limit is not None:
212 + a_faire = a_faire[:limit]
213 +
214 + # 1) télécharger les POI des tuiles nécessaires (une requête par tuile)
215 + tuiles = sorted({_tile_of(la, ln) for la, ln in a_faire})
216 + pois_by_tile: dict[tuple[int, int], dict[str, list[dict]]] = {}
217 + tile_errors = []
218 + for t in tuiles:
219 + res = client.fetch_tile(*t)
220 + if res is None:
221 + tile_errors.append(t)
222 + else:
223 + by_cat: dict[str, list[dict]] = {}
224 + for p in res:
225 + by_cat.setdefault(p["cat"], []).append(p)
226 + pois_by_tile[t] = by_cat
227 +
228 + # 2) calcul local du plus proche par catégorie pour chaque immeuble
229 + done = errors = 0
230 + for la, ln in a_faire:
231 + t = _tile_of(la, ln)
232 + if t not in pois_by_tile:
233 + errors += 1 # tuile en échec : re-tentée au prochain run
234 + continue
235 + pois = _nearest_by_cat(la, ln, pois_by_tile[t])
236 + con.execute(
237 + "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)"
238 + " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts",
239 + (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now))
240 + done += 1
241 + con.commit()
242 +
243 + con.close()
244 + stats = {"fetched": done, "cached": skipped, "errors": errors,
245 + "tiles": len(tuiles), "tile_errors": len(tile_errors),
246 + "total_coords": len(rows)}
247 + print(f"[immo-ka] poi {stats}")
248 + return stats
added immoka/quartier.py +222 −0
@@ -0,0 +1,222 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# quartier.py : statistiques de quartier par annonce (à la Centris, en libre)
5 +# Base statique data/quartier.db construite par scripts/build_*.py :
6 +# - da_poly / da_stats : aires de diffusion 2021 + profil du recensement
7 +# - da_pmd : mesures de proximité StatCan (scores 0..1)
8 +# - da_defav : défavorisation matérielle/sociale INSPQ (quintiles)
9 +# - heat : classe d'îlot de chaleur/fraîcheur INSPQ par immeuble
10 +# - crime_mtl / igc : actes criminels SPVM (points) + indice de gravité
11 +# Jointure : lat/lng -> DAUID par point-dans-polygone local (préfiltre bbox),
12 +# mémorisée dans listings.dauid à l'enrichissement (boucle watch).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import json
17 +import math
18 +import sqlite3
19 +import time
20 +from pathlib import Path
21 +
22 +from . import db
23 +
24 +QUARTIER_DB = Path(__file__).resolve().parent.parent / "data" / "quartier.db"
25 +
26 +# villes couvertes par les points SPVM (agglomération de Montréal)
27 +_VILLES_SPVM = {"montreal", "montreal-est", "montreal-ouest", "westmount",
28 + "cote saint-luc", "cote-saint-luc", "hampstead", "mont-royal",
29 + "outremont", "verdun", "lasalle", "lachine", "anjou",
30 + "saint-leonard", "saint-laurent", "ahuntsic", "dorval",
31 + "pointe-claire", "kirkland", "beaconsfield", "dollard-des-ormeaux"}
32 +
33 +# correspondance ville -> fragment du nom de service dans la table igc
34 +_IGC_SERVICE = {
35 + "quebec": "SPVQ", "levis": "Lévis", "montreal": "SPVM",
36 + "laval": "Laval", "longueuil": "Longueuil",
37 +}
38 +
39 +
40 +def disponible() -> bool:
41 + return QUARTIER_DB.exists()
42 +
43 +
44 +def _connect() -> sqlite3.Connection:
45 + con = sqlite3.connect(f"file:{QUARTIER_DB}?mode=ro", uri=True)
46 + con.row_factory = sqlite3.Row
47 + return con
48 +
49 +
50 +# ---------------------------------------------------------------------------
51 +# lat/lng -> DAUID (point dans polygone, préfiltre bbox)
52 +# ---------------------------------------------------------------------------
53 +
54 +def _dans_anneau(lat: float, lng: float, anneau: list) -> bool:
55 + """Lancer de rayon (even-odd). anneau = [[lng, lat], ...]."""
56 + dedans = False
57 + n = len(anneau)
58 + j = n - 1
59 + for i in range(n):
60 + xi, yi = anneau[i][0], anneau[i][1]
61 + xj, yj = anneau[j][0], anneau[j][1]
62 + if (yi > lat) != (yj > lat) and \
63 + lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi:
64 + dedans = not dedans
65 + j = i
66 + return dedans
67 +
68 +
69 +def dauid_for(qcon: sqlite3.Connection, lat: float, lng: float) -> str | None:
70 + rows = qcon.execute(
71 + "SELECT dauid, poly FROM da_poly WHERE lat_min<=? AND lat_max>=?"
72 + " AND lng_min<=? AND lng_max>=?", (lat, lat, lng, lng)).fetchall()
73 + for r in rows:
74 + anneaux = json.loads(r["poly"])
75 + # even-odd sur tous les anneaux (les trous annulent)
76 + compte = sum(1 for a in anneaux if _dans_anneau(lat, lng, a))
77 + if compte % 2 == 1:
78 + return r["dauid"]
79 + return None
80 +
81 +
82 +# ---------------------------------------------------------------------------
83 +# Assemblage pour la fiche
84 +# ---------------------------------------------------------------------------
85 +
86 +def _cle_ville(city: str) -> str:
87 + import unicodedata
88 + s = "".join(c for c in unicodedata.normalize("NFD", city or "")
89 + if unicodedata.category(c) != "Mn")
90 + return s.strip().lower()
91 +
92 +
93 +def _crime_mtl(qcon: sqlite3.Connection, lat: float, lng: float) -> dict | None:
94 + """Comptage des actes criminels SPVM à < 500 m : 12 mois vs 12 précédents."""
95 + dlat = 500 / 111000.0
96 + dlng = 500 / (111000.0 * max(0.2, math.cos(math.radians(lat))))
97 + now = time.time()
98 + rows = qcon.execute(
99 + "SELECT lat, lng, ts FROM crime_mtl WHERE lat BETWEEN ? AND ?"
100 + " AND lng BETWEEN ? AND ? AND ts >= ?",
101 + (lat - dlat, lat + dlat, lng - dlng, lng + dlng, now - 730 * 86400)).fetchall()
102 + recent = avant = 0
103 + for r in rows:
104 + # distance exacte (le bbox est un carré)
105 + d = math.hypot((r["lat"] - lat) * 111000.0,
106 + (r["lng"] - lng) * 111000.0 * math.cos(math.radians(lat)))
107 + if d > 500:
108 + continue
109 + if r["ts"] >= now - 365 * 86400:
110 + recent += 1
111 + else:
112 + avant += 1
113 + if recent == 0 and avant == 0:
114 + return None
115 + return {"type": "points", "rayon_m": 500, "douze_mois": recent,
116 + "douze_mois_precedents": avant}
117 +
118 +
119 +def _crime_igc(qcon: sqlite3.Connection, city: str) -> dict | None:
120 + service = _IGC_SERVICE.get(_cle_ville(city))
121 + if not service:
122 + return None
123 + row = qcon.execute(
124 + "SELECT annee, indice FROM igc WHERE service LIKE '%' || ? || '%'"
125 + " ORDER BY annee DESC LIMIT 1", (service,)).fetchone()
126 + if row is None or row["indice"] is None:
127 + return None
128 + ref = qcon.execute(
129 + "SELECT indice FROM igc WHERE service LIKE '%canada%' AND annee=?",
130 + (row["annee"],)).fetchone()
131 + return {"type": "igc", "ville": city, "annee": row["annee"],
132 + "indice": round(row["indice"], 1),
133 + "indice_canada": round(ref["indice"], 1) if ref and ref["indice"] else None}
134 +
135 +
136 +def fiche_quartier(lat: float | None, lng: float | None, city: str,
137 + dauid: str | None = None) -> dict | None:
138 + """Bloc « Le quartier » d'une fiche. None si données indisponibles."""
139 + if not disponible() or lat is None or lng is None:
140 + return None
141 + qcon = _connect()
142 + try:
143 + if not dauid:
144 + dauid = dauid_for(qcon, lat, lng)
145 + out: dict = {"dauid": dauid}
146 +
147 + if dauid:
148 + r = qcon.execute("SELECT * FROM da_stats WHERE dauid=?", (dauid,)).fetchone()
149 + if r:
150 + out["demographie"] = {k: r[k] for k in
151 + ("population", "densite", "age_median",
152 + "revenu_median", "pct_locataires",
153 + "loyer_moyen", "pct_francais", "pct_univ")}
154 + # rangs centiles québécois (0-100) — voir scripts/merge_quartier.py
155 + r = qcon.execute("SELECT * FROM da_pmd_pct WHERE dauid=?", (dauid,)).fetchone()
156 + if r:
157 + out["proximite"] = {k: r[k] / 100.0 for k in r.keys()
158 + if k != "dauid" and r[k] is not None}
159 + r = qcon.execute("SELECT quintile_materiel, quintile_social FROM da_defav"
160 + " WHERE dauid=?", (dauid,)).fetchone()
161 + if r:
162 + out["defavorisation"] = dict(r)
163 +
164 + # îlot de chaleur : coordonnée exacte, sinon la plus proche (~120 m)
165 + key = f"{round(lat, 4)},{round(lng, 4)}"
166 + r = qcon.execute("SELECT classe, ecart FROM heat WHERE coord_key=?",
167 + (key,)).fetchone()
168 + if r is None:
169 + r = qcon.execute(
170 + "SELECT classe, ecart FROM heat WHERE coord_key LIKE ?"
171 + " AND classe IS NOT NULL LIMIT 1",
172 + (f"{round(lat, 3)}%",)).fetchone()
173 + if r and r["classe"] is not None:
174 + out["chaleur"] = {"classe": r["classe"], "ecart": r["ecart"]}
175 +
176 + # criminalité : points SPVM sur l'île, indice IGC ailleurs
177 + crime = None
178 + if _cle_ville(city) in _VILLES_SPVM:
179 + crime = _crime_mtl(qcon, lat, lng)
180 + if crime is None:
181 + crime = _crime_igc(qcon, city)
182 + if crime:
183 + out["crime"] = crime
184 +
185 + return out if len(out) > 1 else None
186 + except sqlite3.Error:
187 + return None
188 + finally:
189 + qcon.close()
190 +
191 +
192 +# ---------------------------------------------------------------------------
193 +# Enrichissement : mémoriser le DAUID de chaque annonce (boucle watch)
194 +# ---------------------------------------------------------------------------
195 +
196 +def enrich(limit: int | None = None) -> dict:
197 + """Remplit listings.dauid pour les annonces géolocalisées qui ne l'ont pas."""
198 + if not disponible():
199 + print("[immo-ka] quartier: data/quartier.db absent — étape sautée")
200 + return {"enriched": 0, "missing_db": True}
201 + con = db.connect()
202 + qcon = _connect()
203 + rows = con.execute(
204 + "SELECT uid, lat, lng FROM listings WHERE active=1 AND lat IS NOT NULL"
205 + " AND (dauid IS NULL OR dauid='')").fetchall()
206 + if limit is not None:
207 + rows = rows[:limit]
208 + done = introuvable = 0
209 + for r in rows:
210 + d = dauid_for(qcon, r["lat"], r["lng"])
211 + con.execute("UPDATE listings SET dauid=? WHERE uid=?",
212 + (d or "hors-zone", r["uid"]))
213 + if d:
214 + done += 1
215 + else:
216 + introuvable += 1
217 + con.commit()
218 + qcon.close()
219 + con.close()
220 + stats = {"enriched": done, "hors_zone": introuvable, "candidats": len(rows)}
221 + print(f"[immo-ka] quartier {stats}")
222 + return stats
modified immoka/schema.py +7 −0
@@ -58,6 +58,9 @@ class PropertyListing:
58 58 status: str = "a-vendre" # a-vendre | vendu | conditionnel
59 59 broker_name: str = "" # courtier inscripteur
60 60 broker_phone: str = ""
61 + agency: str = "" # sous-agence / bureau (ex. « Royal LePage Altitude »,
62 + # « Groupe Sutton - Synergie ») — affichage des
63 + # Sources par sous-agence
61 64 description: str = ""
62 65 features: list[str] = field(default_factory=list) # caractéristiques (texte source)
63 66 details: dict = field(default_factory=dict) # champs structurés (JSON)
@@ -104,6 +107,10 @@ class PropertyListing:
104 107 self.lot_sqft = parse_lot_sqft(texte)
105 108 self.year_built = parse_int(self.year_built)
106 109
110 + # sous-agence : à défaut, on retombe sur le courtier/agence inscripteur
111 + if not self.agency:
112 + self.agency = self.broker_name
113 +
107 114 # coordonnées fournies par la source : rejeter tout point hors de la
108 115 # province (lat/lng inversés, 0/0, coquilles)
109 116 if self.lat is not None and self.lng is not None:
added immoka/vraiprix.py +121 −0
@@ -0,0 +1,121 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# vraiprix.py : estimation de valeur marchande via Vrai-Prix (www.vrai-prix.com)
5 +# Pour chaque annonce : /api/search?q=<adresse> → id de l'unité d'évaluation,
6 +# puis /api/estimate?id → valeur estimée, fourchette P10-P90, indice de
7 +# confiance. Repli : POST /api/estimate (municipalité + type + superficie).
8 +# Résultat stocké dans listings.vraiprix (JSON) + lien vers /estimation/{id}.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import json
13 +import time
14 +import unicodedata
15 +
16 +import requests
17 +
18 +from . import db
19 +
20 +BASE = "https://www.vrai-prix.com"
21 +UA = "ImmoKaBot/1.0 (+https://www.immo-ka.com; contact@spboucher.ai)"
22 +DELAY = 0.25 # politesse
23 +
24 +# type Immo-Ka -> typeProp Vrai-Prix (repli manuel)
25 +_TYPE_MAP = {
26 + "Maison": "unifamilial", "Condo": "condo", "Duplex": "plex",
27 + "Triplex": "plex", "Multiplex": "plex", "Jumelé": "unifamilial",
28 + "Maison de ville": "unifamilial", "Chalet": "unifamilial",
29 + "Fermette/Agricole": "unifamilial", "Maison mobile": "unifamilial",
30 +}
31 +
32 +
33 +def _norm(s: str) -> str:
34 + return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())
35 + if unicodedata.category(c) != "Mn").strip()
36 +
37 +
38 +def _session() -> requests.Session:
39 + s = requests.Session()
40 + s.headers["User-Agent"] = UA
41 + return s
42 +
43 +
44 +def _search_id(s: requests.Session, address: str, city: str) -> str | None:
45 + """Retourne l'id de l'unité Vrai-Prix la mieux appariée à l'adresse."""
46 + q = f"{address}, {city}".strip(", ")
47 + if len(q) < 4:
48 + return None
49 + try:
50 + r = s.get(f"{BASE}/api/search", params={"q": q}, timeout=20)
51 + results = r.json().get("results", [])
52 + except Exception:
53 + return None
54 + if not results:
55 + return None
56 + ncity = _norm(city)
57 + # préférer une correspondance de municipalité, sinon le 1er
58 + for res in results:
59 + if ncity and _norm(res.get("municipalite", "")) == ncity:
60 + return res.get("id")
61 + return results[0].get("id")
62 +
63 +
64 +def _estimate_by_id(s: requests.Session, uid: str) -> dict | None:
65 + try:
66 + r = s.get(f"{BASE}/api/estimate", params={"id": uid}, timeout=25)
67 + return r.json()
68 + except Exception:
69 + return None
70 +
71 +
72 +def _pack(unit_id: str, result: dict) -> dict:
73 + """Champs conservés pour l'affichage sur la fiche Immo-Ka."""
74 + return {
75 + "id": unit_id,
76 + "value": result.get("estimate"),
77 + "low": result.get("low"),
78 + "high": result.get("high"),
79 + "confidence_pct": result.get("confidencePct"),
80 + "confidence": result.get("confidenceLevel"),
81 + "url": f"{BASE}/estimation/{unit_id}",
82 + }
83 +
84 +
85 +def estimate_for(s: requests.Session, address: str, city: str) -> dict | None:
86 + uid = _search_id(s, address, city)
87 + if not uid:
88 + return None
89 + data = _estimate_by_id(s, uid)
90 + if not data or not isinstance(data.get("result"), dict):
91 + return None
92 + return _pack(uid, data["result"])
93 +
94 +
95 +def run(limit: int | None = None) -> dict:
96 + """Enrichit les annonces sans estimation Vrai-Prix (adresse requise)."""
97 + con = db.connect()
98 + s = _session()
99 + rows = con.execute(
100 + "SELECT uid, address, city FROM listings"
101 + " WHERE active=1 AND vraiprix IS NULL AND address<>''"
102 + " ORDER BY first_seen DESC").fetchall()
103 + done = miss = 0
104 + for r in rows:
105 + if limit is not None and done + miss >= limit:
106 + break
107 + est = estimate_for(s, r["address"], r["city"])
108 + # on marque toujours (même un échec) pour ne pas re-sonder sans cesse ;
109 + # « {} » = tenté sans correspondance.
110 + con.execute("UPDATE listings SET vraiprix=? WHERE uid=?",
111 + (json.dumps(est or {}, ensure_ascii=False), r["uid"]))
112 + con.commit()
113 + if est:
114 + done += 1
115 + else:
116 + miss += 1
117 + time.sleep(DELAY)
118 + con.close()
119 + out = {"estimated": done, "no_match": miss}
120 + print(f"[immo-ka] vraiprix: {out}")
121 + return out
modified immoka/web.py +79 −7
@@ -36,13 +36,11 @@ _sync_lock = threading.Lock()
36 36 # (le central enrichi d'abord, puis la sous-agence au plus petit uid). Les fiches
37 37 # du central et des autres agences ne sont jamais masquées. → aucun double-comptage,
38 38 # et les sous-agences prennent le relais automatiquement si le central disparaît.
39 DEDUP_CLAUSE = (
40 " AND NOT EXISTS (SELECT 1 FROM listings d WHERE d.active=1"
41 " AND d.external_id = listings.external_id AND d.uid <> listings.uid"
42 " AND (listings.source LIKE '%\\_ag\\_%' ESCAPE '\\')"
43 " AND ((d.source NOT LIKE '%\\_ag\\_%' ESCAPE '\\')"
44 " OR (d.source LIKE '%\\_ag\\_%' ESCAPE '\\' AND d.uid < listings.uid)))"
45 )
39 +# Déduplication PRÉ-CALCULÉE : la colonne `dup_hidden` (remplie par
40 +# db.refresh_dedup après chaque sync) marque les doublons de sous-agences. La
41 +# lecture est ainsi instantanée (index) au lieu d'un sous-select corrélé par
42 +# ligne (~300 s sur 75 k lignes). Voir db.refresh_dedup pour la règle.
43 +DEDUP_CLAUSE = " AND dup_hidden=0"
46 44
47 45
48 46 def _row_to_dict(row) -> dict:
@@ -50,6 +48,11 @@ def _row_to_dict(row) -> dict:
50 48 d["features"] = json.loads(d.get("features") or "[]")
51 49 d["images"] = json.loads(d.get("images") or "[]")
52 50 d["details"] = json.loads(d.get("details") or "{}")
51 + if d.get("vraiprix"):
52 + try:
53 + d["vraiprix"] = json.loads(d["vraiprix"]) or None
54 + except (ValueError, TypeError):
55 + d["vraiprix"] = None
53 56 return d
54 57
55 58
@@ -124,6 +127,20 @@ def get_listing(uid: str):
124 127 d["price_history"] = [dict(r) for r in con.execute(
125 128 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",
126 129 (uid,)).fetchall()]
130 + # commodités de proximité (cache par immeuble, voir immoka/poi.py)
131 + if d.get("lat") is not None and d.get("lng") is not None:
132 + key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"
133 + poi_row = con.execute(
134 + "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()
135 + d["poi"] = json.loads(poi_row["pois"]) if poi_row else []
136 + else:
137 + d["poi"] = []
138 + # statistiques de quartier (recensement, proximité, chaleur, criminalité)
139 + from . import quartier
140 + dauid = d.get("dauid")
141 + d["quartier"] = quartier.fiche_quartier(
142 + d.get("lat"), d.get("lng"), d.get("city") or "",
143 + dauid if dauid and dauid != "hors-zone" else None)
127 144 con.close()
128 145 if d is None:
129 146 raise HTTPException(404, "Propriété introuvable")
@@ -217,6 +234,61 @@ def sources():
217 234 return {"sources": registry}
218 235
219 236
237 +# Rattachement d'une source à sa bannière (franchise) pour le regroupement.
238 +_FRANCHISES = [
239 + ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")),
240 + ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")),
241 + ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")),
242 + ("Royal LePage", lambda s: s == "royal_lepage"),
243 + ("Groupe Sutton", lambda s: s == "sutton"),
244 + ("Keller Williams", lambda s: s.startswith("kw_")),
245 + ("DuProprio", lambda s: s == "duproprio"),
246 +]
247 +
248 +
249 +def _franchise_of(source: str, source_names: dict) -> str:
250 + for name, match in _FRANCHISES:
251 + if match(source):
252 + return name
253 + return source_names.get(source, source) # agence indépendante = elle-même
254 +
255 +
256 +@app.get("/api/agencies")
257 +def agencies():
258 + """Arbre bannière → sous-agences (bureaux) avec le nombre d'inscriptions.
259 +
260 + Alimente la page « Sources » de l'app : chaque bannière est éclatée par
261 + sous-agence via le champ `agency` (bureau). Dédupliqué (n° Centris)."""
262 + con = db.connect()
263 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
264 + source_names = {s["id"]: s["name"] for s in registry}
265 + rows = con.execute(
266 + "SELECT source, COALESCE(NULLIF(agency,''), '') agency, COUNT(*) n"
267 + " FROM listings WHERE active=1" + DEDUP_CLAUSE +
268 + " GROUP BY source, agency").fetchall()
269 + con.close()
270 + tree: dict[str, dict] = {}
271 + for r in rows:
272 + fr = _franchise_of(r["source"], source_names)
273 + node = tree.setdefault(fr, {"franchise": fr, "total": 0, "agencies": {}})
274 + node["total"] += r["n"]
275 + # nom de sous-agence : le bureau (agency) sinon le nom de la source
276 + label = r["agency"] or source_names.get(r["source"], r["source"])
277 + a = node["agencies"].setdefault(label, {"name": label, "count": 0,
278 + "sources": set()})
279 + a["count"] += r["n"]
280 + a["sources"].add(r["source"])
281 + out = []
282 + for node in tree.values():
283 + ags = sorted(node["agencies"].values(), key=lambda x: -x["count"])
284 + for a in ags:
285 + a["sources"] = sorted(a["sources"])
286 + out.append({"franchise": node["franchise"], "total": node["total"],
287 + "sub_agencies": len(ags), "agencies": ags})
288 + out.sort(key=lambda x: -x["total"])
289 + return {"franchises": out}
290 +
291 +
220 292 @app.get("/api/stats")
221 293 def stats():
222 294 con = db.connect()
modified run.py +15 −0
@@ -9,6 +9,9 @@
9 9 python run.py watch [minutes] # synchronise en boucle (défaut 60 min)
10 10 python run.py serve [port] # démarre l'API + le frontend (défaut 8090)
11 11 python run.py list # liste les connecteurs enregistrés
12 + python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes)
13 + python run.py poi [n] # commodités de proximité par immeuble (max n requêtes)
14 + python run.py quartier [n] # rattache chaque annonce à son aire de diffusion (DAUID)
12 15 """
13 16 from __future__ import annotations
14 17
@@ -40,6 +43,18 @@ def main() -> None:
40 43 for sid in sorted(CONNECTORS):
41 44 print(sid)
42 45 print(f"-- {len(CONNECTORS)} connecteur(s)")
46 + elif cmd == "geocode":
47 + from immoka import geocode
48 + geocode.run(int(sys.argv[2]) if len(sys.argv) > 2 else None)
49 + elif cmd == "poi":
50 + from immoka import poi
51 + poi.run(int(sys.argv[2]) if len(sys.argv) > 2 else None)
52 + elif cmd == "quartier":
53 + from immoka import quartier
54 + quartier.enrich(int(sys.argv[2]) if len(sys.argv) > 2 else None)
55 + elif cmd == "vraiprix":
56 + from immoka import vraiprix
57 + vraiprix.run(int(sys.argv[2]) if len(sys.argv) > 2 else None)
43 58 elif cmd == "serve":
44 59 import uvicorn
45 60 port = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
46 61