SPB Git forge

spb/immo-ka

Public

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

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
14.1 KB · 255 lines typescript
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche (aucun LLM, aucune5//   donnée inventée) — briques réutilisées par le héro, le score, « En bref »,6//   l'aside desktop et le CTA sticky :7//   · comparaisonPrix : position du prix demandé vs estimation Vrai-Prix ;8//   · immoKaScore : 0-100 = 60 % emplacement (indice de proximité StatCan du9//     secteur) + 40 % prix (écart à l'estimation Vrai-Prix : 0 % → 65,10//     −30 % → 100, +30 % → 20). Score PARTIEL et dit tel quel quand une11//     composante manque ;12//   · enBref : 4 à 6 constats priorisés (prix, rôle d'évaluation, accessibilité,13//     air, inondation, taxes/copropriété, chaleur, baisse de prix, temps sur le14//     marché, publications multiples), chacun avec son ton et sa preuve ;15//   · lecture des caractéristiques Centris (taxes, copropriété, évaluation).16// -----------------------------------------------------------------------------17import { AirNearby, Inondation, Listing, fmtPrice } from "../api";18import { Tone, NBSP, fmtPct, parseMontant } from "./ui";1920/* --- caractéristiques chiffrées lues dans `details` --------------------------- */21const pick = (l: Listing, keys: string[]): unknown => {22  const d = l.details ?? {};23  for (const k of keys) if (d[k] != null && String(d[k]).trim()) return d[k];24  return null;25};2627export const estLocation = (l: Listing) => l.details?.transaction === "location";2829/** Taxes annuelles publiées (municipales + scolaires), en $ ; null si absentes. */30export function taxesAnnuelles(l: Listing): { total: number; municipales: number | null; scolaires: number | null } | null {31  const m = parseMontant(pick(l, ["Taxes municipales", "Taxe municipale"]));32  const s = parseMontant(pick(l, ["Taxes scolaires", "Taxe scolaire"]));33  const tot = (m ?? 0) + (s ?? 0);34  if (tot <= 0 || tot > 500_000) return null;35  return { total: tot, municipales: m, scolaires: s };36}3738/** Frais de copropriété mensuels publiés, en $ ; null si absents. */39export function fraisCoproMensuels(l: Listing): number | null {40  const v = pick(l, ["Frais de copropriété", "Frais de condo", "Frais de copropriété (mensuels)", "Frais communs"]);41  const n = parseMontant(v);42  if (n == null) return null;43  // certaines sources publient un montant annuel « /an »44  return /an\b|année|annuel/i.test(String(v)) ? Math.round(n / 12) : n;45}4647/** Évaluation municipale (rôle) : totale, terrain, bâtiment, année. */48export function evaluationMunicipale(l: Listing): { total: number; terrain: number | null; batiment: number | null; annee: string | null } | null {49  const vp = l.vraiprix;50  let total = parseMontant(pick(l, ["Évaluation municipale", "Évaluation municipale (totale)", "Évaluation municipale totale"]));51  const terrain = parseMontant(pick(l, ["Évaluation municipale (terrain)"])) ?? vp?.valeur_terrain ?? null;52  const batiment = parseMontant(pick(l, ["Évaluation municipale (bâtiment)"])) ?? vp?.valeur_batiment ?? null;53  if (total == null && terrain != null && batiment != null) total = terrain + batiment;54  if (total == null && vp?.valeur_role) total = vp.valeur_role;55  if (total == null) return null;56  const annee = pick(l, ["Évaluation municipale (année)"]);57  return { total, terrain, batiment, annee: annee != null ? String(annee) : null };58}5960/* --- comparaison de prix (Vrai-Prix) ------------------------------------------ */61export interface ComparaisonPrix {62  tone: "good" | "ok" | "high";63  pct: number;                 // écart signé en % (négatif = sous l'estimation)64  label: string;               // « Sous l'estimation » / « Prix aligné » / « Au-dessus de l'estimation »65  court: string;               // « ↓ 8 % vs estimation »66  ref: number;                 // valeur de référence ($)67  refLabel: string;68  confidence: string | null;   // A | B | C | D69}7071export function comparaisonPrix(l: Listing): ComparaisonPrix | null {72  const vp = l.vraiprix;73  if (l.price == null || estLocation(l) || !vp || vp.value == null || vp.value <= 0) return null;74  const pct = Math.round(((l.price - vp.value) / vp.value) * 100);75  if (Math.abs(pct) > 60) return null;   // prix non comparable (terrain, commerce, lot…)76  const tone = pct <= -5 ? "good" : pct <= 5 ? "ok" : "high";77  return {78    tone, pct, ref: vp.value, refLabel: "estimation Vrai-Prix", confidence: vp.confidence,79    label: tone === "good" ? "Sous l'estimation" : tone === "ok" ? "Prix aligné" : "Au-dessus de l'estimation",80    court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs estimation`,81  };82}8384/* --- Immo-Ka Score -------------------------------------------------------------- */85export interface ImmoKaScore {86  value: number | null;         // score affiché (0-100) ou null si rien de fiable87  partial: boolean;             // une composante manque88  emplacement: number | null;   // indice de proximité StatCan (0-100)89  prix: number | null;          // composante prix (0-100)90  label: string | null;91  explication: string;92}9394export function kaLabel(score: number | null | undefined): string | null {95  if (score == null) return null;96  if (score >= 85) return "Exceptionnel";97  if (score >= 70) return "Excellent";98  if (score >= 55) return "Très bon";99  if (score >= 40) return "Moyen";100  return "Faible";101}102103export function scorePrix(deviation: number | null | undefined): number | null {104  if (deviation == null) return null;105  // 0 % d'écart → 65 ; −30 % → 100 ; +30 % → 20 (borné 5-100)106  return Math.round(Math.max(5, Math.min(100, 65 - deviation * 150)));107}108109/** Indice d'emplacement 0-100 = moyenne des mesures de proximité StatCan110 *  (épiceries, transport, pharmacies, parcs, écoles, santé…) du secteur. */111export function indiceEmplacement(l: Listing): number | null {112  const p = l.quartier?.proximite;113  if (!p) return null;114  const vals = Object.entries(p)115    .filter(([k, v]) => k.startsWith("prox_") && typeof v === "number" && Number.isFinite(v))116    .map(([, v]) => Math.max(0, Math.min(1, v as number)));117  if (vals.length < 3) return null;118  return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100);119}120121export function immoKaScore(l: Listing): ImmoKaScore {122  const emplacement = indiceEmplacement(l);123  const cmp = comparaisonPrix(l);124  const prix = cmp ? scorePrix(cmp.pct / 100) : null;125  let value: number | null = null;126  let partial = false;127  if (emplacement != null && prix != null) value = Math.round(emplacement * 0.6 + prix * 0.4);128  else if (emplacement != null) { value = emplacement; partial = true; }129  else if (prix != null) { value = prix; partial = true; }130  const explication =131    "L'Immo-Ka Score combine l'emplacement (indice de proximité aux services de Statistique Canada " +132    "pour le secteur : épiceries, transport en commun, pharmacies, parcs, écoles, soins de santé — 60 %) " +133    "et le prix (écart du prix demandé à l'estimation Vrai-Prix, fondée sur les ventes comparables et le " +134    "rôle d'évaluation — 40 %). Quand une composante manque, le score est dit partiel et repose sur la seule " +135    "composante disponible. Il n'intègre ni l'état du bâtiment ni l'inspection.";136  return { value, partial, emplacement, prix, label: value != null ? kaLabel(value) : null, explication };137}138139/* --- En bref -------------------------------------------------------------------- */140export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }141142export function enBref(l: Listing, x: {143  air: AirNearby | null; inondation: Inondation | null; loadingRisques: boolean;144}): Constat[] {145  const out: Constat[] = [];146  const cmp = comparaisonPrix(l);147  const location = estLocation(l);148149  // 1. prix vs estimation Vrai-Prix150  if (cmp) {151    out.push({152      cle: "prix",153      tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",154      titre: cmp.tone === "good" ? "Prix demandé sous l'estimation" : cmp.tone === "high" ? "Prix demandé au-dessus de l'estimation" : "Prix aligné sur l'estimation",155      detail: `${Math.abs(cmp.pct)}${NBSP}% ${cmp.pct < 0 ? "sous" : cmp.pct > 0 ? "au-dessus de" : "≈"} ${fmtPrice(cmp.ref)} (Vrai-Prix${cmp.confidence ? `, confiance ${cmp.confidence}` : ""})`,156    });157  } else if (l.price != null && !location) {158    out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé",159               detail: "Pas d'estimation Vrai-Prix exploitable pour ce type de bien." });160  }161162  // 2. rôle d'évaluation163  const ev = evaluationMunicipale(l);164  if (ev && l.price != null && !location && ev.total > 0) {165    const pct = Math.round(((l.price - ev.total) / ev.total) * 100);166    if (Math.abs(pct) <= 150)167      out.push({ cle: "role", tone: "neutral", titre: `${Math.abs(pct)}${NBSP}% ${pct >= 0 ? "au-dessus" : "sous"} l'évaluation municipale`,168                 detail: `Rôle ${ev.annee ? `${ev.annee} ` : ""}: ${fmtPrice(ev.total)}${ev.terrain != null && ev.batiment != null ? ` (terrain ${fmtPrice(ev.terrain)}, bâtiment ${fmtPrice(ev.batiment)})` : ""}` });169  }170171  // 3. accessibilité (proximité StatCan)172  const m = indiceEmplacement(l);173  if (m != null)174    out.push({ cle: "acces", tone: m >= 70 ? "good" : m >= 45 ? "neutral" : "warn",175               titre: m >= 85 ? "Secteur exceptionnellement bien desservi" : m >= 70 ? "Services et transport à proximité" : m >= 45 ? "Accessibilité moyenne" : "Secteur peu desservi sans voiture",176               detail: `Indice de proximité StatCan ${m}/100 (épiceries, transport, pharmacies, parcs, écoles, santé)` });177178  // 4. qualité de l'air179  if (x.air?.station) {180    const pm = x.air.mesures?.["PM2.5"];181    if (pm?.ref) {182      const r = pm.moyenne / pm.ref;183      out.push({184        cle: "air", tone: r <= 2 ? "good" : r <= 3 ? "warn" : "bad",185        titre: r <= 1 ? "Air de très bonne qualité" : r <= 2 ? "Bonne qualité de l'air" : r <= 3 ? "Qualité de l'air passable" : "Particules fines élevées",186        detail: `PM2,5 ${pm.moyenne.toLocaleString("fr-CA")}${NBSP}µg/m³ · station ${x.air.station}${x.air.distance_km != null ? ` (${x.air.distance_km.toLocaleString("fr-CA")} km)` : ""} · ${pm.annee}`,187      });188    }189  }190191  // 5. inondation192  if (x.inondation) {193    const d = x.inondation;194    if (d.statut === "en_zone")195      out.push({ cle: "inond", tone: d.severite === "eleve" ? "bad" : "warn", titre: "Adresse en zone inondable",196                 detail: d.zones[0] ? `${d.zones[0].type}${d.zones[0].recurrence ? ` (${d.zones[0].recurrence})` : ""} — carte officielle BDZI` : "Cartographie officielle BDZI" });197    else if (d.statut === "a_proximite")198      out.push({ cle: "inond", tone: "warn", titre: "Zone inondable à proximité",199                 detail: d.zones[0] ? `${d.zones[0].type} à ~${d.zones[0].distance_m} m` : "Selon la cartographie BDZI" });200    else if (d.statut === "hors_zone")201      out.push({ cle: "inond", tone: "good", titre: "Hors zone inondable", detail: "Secteur cartographié (BDZI, gouvernement du Québec)" });202    else203      out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation indéterminé", detail: "Secteur non couvert par la cartographie officielle" });204  } else if (x.loadingRisques && l.lat != null) {205    out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation", detail: "Vérification en cours…" });206  }207208  // 6. charges : taxes et copropriété209  const tx = taxesAnnuelles(l);210  const copro = fraisCoproMensuels(l);211  if (tx || copro != null) {212    const parts: string[] = [];213    if (tx) parts.push(`taxes ${fmtPrice(Math.round(tx.total))}${NBSP}/an (≈${NBSP}${fmtPrice(Math.round(tx.total / 12))}${NBSP}/mois)`);214    if (copro != null) parts.push(`copropriété ${fmtPrice(copro)}${NBSP}/mois`);215    const mensuel = Math.round((tx ? tx.total / 12 : 0) + (copro ?? 0));216    out.push({ cle: "charges", tone: "neutral", titre: `Charges fixes ≈ ${fmtPrice(mensuel)}${NBSP}/mois hors hypothèque`,217               detail: parts.join(" · ") });218  }219220  // 7. îlot de chaleur marqué221  if (l.quartier?.chaleur && l.quartier.chaleur.classe >= 8)222    out.push({ cle: "chaleur", tone: "warn", titre: "Îlot de chaleur urbain",223               detail: `Secteur parmi les plus chauds (classe ${l.quartier.chaleur.classe}/9${l.quartier.chaleur.ecart != null ? `, +${l.quartier.chaleur.ecart.toFixed(1)}${NBSP}°C` : ""}) — INSPQ` });224225  // 8. baisse de prix observée226  const hist = (l.price_history ?? []).filter((h) => h.price != null);227  if (hist.length >= 2 && hist[0].price! < hist[1].price!)228    out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse",229               detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (${fmtPct(((hist[0].price! - hist[1].price!) / hist[1].price!) * 100)})` });230231  // 9. temps sur le marché (observé par Immo-Ka)232  if ((l.days_on_market ?? 0) >= 60)233    out.push({ cle: "marche", tone: "neutral", titre: `Sur le marché depuis ${l.days_on_market}${NBSP}jours`,234               detail: "Observé par Immo-Ka depuis la première synchronisation — marge de négociation possible" });235236  // 10. publications multiples237  if ((l.duplicates?.length ?? 0) > 0)238    out.push({ cle: "dups", tone: "neutral", titre: `Aussi publiée sur ${l.duplicates!.length} autre${l.duplicates!.length > 1 ? "s" : ""} plateforme${l.duplicates!.length > 1 ? "s" : ""}`,239               detail: "Immo-Ka affiche la version la plus complète — voir le dossier" });240241  return out.slice(0, 6);242}243244/** Ligne résumé : « Maison · 3 chambres · 2 sdb · 1 500 pi² · construit en 1998 » */245export function ligneResume(l: Listing): string[] {246  const p: string[] = [];247  if (l.property_type) p.push(l.property_type);248  if (l.bedrooms != null) p.push(`${Math.round(l.bedrooms)} chambre${l.bedrooms > 1 ? "s" : ""}`);249  if (l.bathrooms != null) p.push(`${l.bathrooms} sdb${l.powder_rooms ? ` + ${l.powder_rooms} s.e.` : ""}`);250  if (l.area_sqft) p.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);251  else if (l.lot_sqft) p.push(`terrain ${Math.round(l.lot_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);252  if (l.year_built) p.push(`construit en ${l.year_built}`);253  return p;254}255