spb/ora-ka Public
Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)
Python 80%
TypeScript 12.9%
CSS 6.8%
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + client API robuste (timeout, erreurs typées)5// -----------------------------------------------------------------------------67/** Préfixe de l'app (ex. « /immo » en prod, dérivé de la base Vite).8 * Toutes les URLs relatives (API, liens) doivent être préfixées par API_BASE. */9export const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, "");1011export interface Room {12 nom?: string;13 niveau?: string;14 dimensions?: string;15 revetement?: string;16}1718/** details : dictionnaire libre de caractéristiques Centris (libellé → valeur),19 * avec la clé spéciale `pieces` (liste des pièces + dimensions). */20export interface ListingDetails {21 pieces?: Room[];22 price_from?: boolean;23 [key: string]: unknown;24}2526export interface Listing {27 uid: string;28 source: string;29 external_id: string;30 url: string;31 title: string;32 address: string;33 sector: string;34 city: string;35 region: string;36 property_type: string;37 price: number | null;38 price_label: string;39 bedrooms: number | null;40 bathrooms: number | null;41 powder_rooms: number | null;42 area_sqft: number | null;43 lot_sqft: number | null;44 year_built: number | null;45 mls: string;46 status: string;47 broker_name: string;48 broker_phone: string;49 description: string;50 features: string[];51 details: ListingDetails;52 images: string[];53 lat: number | null;54 lng: number | null;55 price_history?: { ts: number; price: number | null }[];56 poi?: Poi[]; // commodités de proximité (fiche seulement)57 quartier?: Quartier | null; // stats de quartier (fiche seulement)58 vraiprix?: VraiPrix | null; // estimation de valeur marchande (Vrai-Prix)59 first_seen?: number;60 last_seen?: number;61 updated_at?: number;62 active?: number;63}6465export interface Facets {66 cities: string[];67 sectors: string[];68 property_types: string[];69 sources: { source: string; n: number }[];70}7172export interface Source {73 id: string;74 name: string;75 url: string;76 listing_url?: string;77 coverage?: string;78 type?: string;79 connector?: string | null;80 status: string;81 active_listings: number;82 last_sync: number | null;83}8485export interface Stats {86 total: number;87 sources: number;88 cities: number;89 avg_price: number | null;90 min_price: number | null;91 max_price: number | null;92 recent_syncs?: {93 source: string; ts: number; found: number; added: number;94 updated: number; removed: number; ok: number; message: string;95 }[];96 vraiprix?: {97 ensemble: VpBanniere | null;98 bannieres: VpBanniere[];99 };100}101102/** Écart prix demandé vs estimation Vrai-Prix, agrégé par bannière. */103export interface VpBanniere {104 banniere: string;105 n: number;106 median_delta_pct: number;107 p25: number;108 p75: number;109 pct_sur10: number;110 pct_juste: number;111 pct_sous5: number;112}113114// --- Noms d'agences (jolis libellés) ----------------------------------------115const SOURCE_NAMES: Record<string, string> = {};116export function registerSourceNames(sources: Source[]) {117 for (const s of sources) SOURCE_NAMES[s.id] = s.name;118}119export function sourceName(id: string): string {120 if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];121 // repli lisible pour les sous-agences RE/MAX générées (remax_ag_xxx)122 if (id.startsWith("remax_ag_"))123 return "RE/MAX " + id.slice(9).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());124 return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());125}126127async function get<T>(path: string): Promise<T> {128 const ctrl = new AbortController();129 const timer = setTimeout(() => ctrl.abort(), 25000);130 try {131 const res = await fetch(path, { signal: ctrl.signal });132 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);133 return (await res.json()) as T;134 } finally {135 clearTimeout(timer);136 }137}138139export interface ListingFilters {140 city?: string;141 sector?: string;142 region?: string;143 property_type?: string;144 source?: string;145 price_min?: string;146 price_max?: string;147 bedrooms_min?: string;148 bathrooms_min?: string;149 area_min?: string;150 q?: string;151 sort?: string; // price_asc | price_desc | recent152}153154export function listingParams(f: ListingFilters): URLSearchParams {155 const params = new URLSearchParams();156 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);157 return params;158}159160export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {161 const params = listingParams(f);162 params.set("limit", String(limit));163 params.set("offset", String(offset));164 return get<{ total: number; count: number; listings: Listing[] }>(165 `${API_BASE}/api/listings?${params}`);166}167168export const fetchListing = (uid: string) =>169 get<Listing>(`${API_BASE}/api/listings/${encodeURIComponent(uid)}`);170export const fetchFacets = (city?: string) =>171 get<Facets>(`${API_BASE}/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);172export const fetchSources = () => get<{ sources: Source[] }>(`${API_BASE}/api/sources`);173export const fetchStats = () => get<Stats>(`${API_BASE}/api/stats`);174175export interface SubAgency { name: string; count: number; sources: string[] }176export interface Franchise {177 franchise: string;178 total: number;179 sub_agencies: number;180 agencies: SubAgency[];181}182export const fetchAgencies = () =>183 get<{ franchises: Franchise[] }>(`${API_BASE}/api/agencies`);184185// --- Formatage --------------------------------------------------------------186export const fmtPrice = (p: number | null, label?: string) =>187 p != null188 ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"189 : label || "Prix sur demande";190191export const fmtArea = (a: number | null): string | null =>192 a != null ? `${Math.round(a).toLocaleString("fr-CA")} pi²` : null;193194export const fmtDate = (ts: number): string =>195 new Date(ts * 1000).toLocaleDateString("fr-CA", {196 day: "numeric", month: "long", year: "numeric",197 });198199// --- Vrai-Prix : estimation de valeur marchande ------------------------------200export interface VraiPrix {201 id: string;202 value: number | null;203 low: number | null;204 high: number | null;205 confidence_pct: number | null;206 confidence: string | null; // A | B | C | D207 url: string; // page d'analyse détaillée208}209210// --- Quartier (recensement 2021, proximité StatCan, chaleur INSPQ, crime) ----211export interface Poi { cat: string; name: string; dist_m: number }212export interface Quartier {213 dauid?: string | null;214 demographie?: {215 population: number | null; densite: number | null; age_median: number | null;216 revenu_median: number | null; pct_locataires: number | null;217 loyer_moyen: number | null; pct_francais: number | null; pct_univ: number | null;218 };219 proximite?: Record<string, number>;220 defavorisation?: { quintile_materiel: number | null; quintile_social: number | null };221 chaleur?: { classe: number; ecart: number | null };222 crime?:223 | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number }224 | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null };225}226