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// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + client API robuste (timeout, erreurs typées)5// -----------------------------------------------------------------------------67export interface ListingDetails {8 inclusions?: Record<string, boolean>;9 appliances?: Record<string, boolean>;10 parking?: { available?: boolean; type?: string; included?: boolean; price?: number };11 contact?: { phone?: string; email?: string };12 ac?: boolean;13 elevator?: boolean;14 balcony?: boolean;15 pool?: boolean;16 gym?: boolean;17 laundry?: boolean;18 storage?: boolean;19 smoking?: boolean;20 floor?: number;21 price_from?: boolean;22}2324export interface Poi {25 cat: string; // epicerie, pharmacie, ecole, parc, bus…26 name: string;27 dist_m: number;28}2930export interface Digest {31 version: number;32 texte_nettoye: string;33 en_bref: string | null;34 sections: { titre: string; texte: string }[];35 faits: {36 prix_mensuel: number | null;37 date_disponibilite: string | null;38 duree_bail_minimale_mois: number | null;39 nb_occupants_total: number | null;40 salle_de_bain: "commune" | "privee" | null;41 cuisine: "commune" | "privee" | null;42 electromenagers: string[];43 inclusions: string[];44 contraintes: string[];45 depot_mentionne: string | null;46 quartier_mentionne: string | null;47 };48 confiance: Record<string, "haute" | "faible">;49 incoherences: string[];50 completude: number;51}5253export interface Quartier {54 dauid?: string | null;55 demographie?: {56 population: number | null;57 densite: number | null;58 age_median: number | null;59 revenu_median: number | null;60 pct_locataires: number | null;61 loyer_moyen: number | null;62 pct_francais: number | null;63 pct_univ: number | null;64 };65 proximite?: Record<string, number>; // scores 0..1 (PMD StatCan)66 defavorisation?: { quintile_materiel: number | null; quintile_social: number | null };67 chaleur?: { classe: number; ecart: number | null }; // 1 fraîcheur … 9 chaleur68 crime?:69 | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number }70 | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null };71}7273export interface Listing {74 uid: string;75 source: string;76 external_id: string;77 url: string;78 title: string;79 address: string;80 sector: string;81 city: string;82 unit_type: string;83 price: number | null;84 price_label: string;85 availability: string;86 availability_date: string | null; // ISO "2026-07-01" ou "now"87 area_sqft: number | null;88 pets: string | null; // "oui" | "non" | "conditions"89 furnished: boolean | null;90 description: string;91 amenities: string[];92 details: ListingDetails;93 images: string[];94 lat: number | null;95 lng: number | null;96 poi?: Poi[]; // commodités de proximité (fiche seulement)97 quartier?: Quartier | null; // stats de quartier (fiche seulement)98 digest?: Digest | null; // description structurée (fiche seulement)99 price_history?: { ts: number; price: number | null }[];100 first_seen?: number;101 last_seen: number;102 updated_at: number;103 active: number;104}105106/** 250 -> « 250 m », 1240 -> « 1,2 km » */107export const fmtDist = (m: number): string =>108 m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`;109110export interface Facets {111 cities: string[];112 sectors: string[];113 unit_types: string[];114 sources: { source: string; n: number }[];115}116117export interface Source {118 id: string;119 name: string;120 url: string;121 listing_url: string;122 sectors: string;123 connector: string | null;124 status: string;125 active_listings: number;126 last_sync: number | null;127}128129export interface Stats {130 total: number;131 quebec: number;132 levis: number;133 montreal: number;134 autres: number; // reste de la province (Outaouais, Estrie, Mauricie…)135 sources: number;136 avg_price: number | null;137}138139const SOURCE_NAMES: Record<string, string> = {};140141export function registerSourceNames(sources: Source[]) {142 for (const s of sources) SOURCE_NAMES[s.id] = s.name;143}144export function sourceName(id: string): string {145 return SOURCE_NAMES[id] ?? id;146}147148/** Préfixe de l'app (ex. "/lou") — dérivé de la base Vite, sans slash final. */149export const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, "");150151async function get<T>(path: string): Promise<T> {152 const ctrl = new AbortController();153 const timer = setTimeout(() => ctrl.abort(), 20000);154 try {155 const res = await fetch(`${API_BASE}${path}`, { signal: ctrl.signal });156 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);157 return (await res.json()) as T;158 } finally {159 clearTimeout(timer);160 }161}162163export interface ListingFilters {164 city?: string;165 sector?: string;166 unit_type?: string;167 source?: string;168 price_min?: string;169 price_max?: string;170 pets?: string; // "oui" -> acceptés (oui OU conditions)171 furnished?: string; // "1" | "0"172 available_by?: string; // ISO : dispo maintenant ou avant cette date173 area_min?: string; // superficie minimale (pi²)174 q?: string;175}176177export function fetchListings(f: ListingFilters) {178 const params = new URLSearchParams();179 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);180 return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);181}182183export interface GroupStat {184 key: string;185 count: number;186 sources?: number;187 avg_price: number | null;188 min_price: number | null;189}190191export interface DetailedStats {192 totals: {193 total: number;194 with_price: number;195 avg: number | null;196 median: number | null;197 min: number | null;198 max: number | null;199 sources: number;200 cities: number;201 regions: number;202 gps_pct: number | null;203 superficie_moyenne: number | null;204 dispo_now: number;205 };206 histogram: { lo: number; hi: number | null; count: number }[];207 by_type: GroupStat[];208 by_city: GroupStat[];209 by_source: GroupStat[];210 by_region: GroupStat[];211 offre: {212 furnished_pct: number | null;213 pets_oui_pct: number | null;214 pets_connu: number;215 chauffage_pct: number | null;216 electricite_pct: number | null;217 eau_chaude_pct: number | null;218 internet_pct: number | null;219 clim_pct: number | null;220 stationnement_pct: number | null;221 balcon_pct: number | null;222 dispo_now: number;223 dispo_date: number;224 dispo_inconnue: number;225 superficie_moyenne: number | null;226 superficie_connue: number;227 prix_pi2: { key: string; count: number; val: number }[];228 };229 baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[];230 sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] };231}232233export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");234235export const fetchListing = (uid: string) =>236 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);237export const fetchFacets = (city?: string) =>238 get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);239240/** Date ISO à +n jours (pour « dispo d'ici 1 mois », etc.) */241export function isoInDays(n: number): string {242 const d = new Date();243 d.setDate(d.getDate() + n);244 return d.toISOString().slice(0, 10);245}246export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");247export const fetchStats = () => get<Stats>("/api/stats");248249export const fmtPrice = (p: number | null, label?: string) =>250 p != null251 ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"252 : label || "Prix sur demande";253254/** "now" -> « Maintenant », "2026-12-01" -> « 1ᵉʳ décembre 2026 » */255export function fmtAvailability(iso: string | null): string | null {256 if (!iso) return null;257 if (iso === "now") return "Maintenant";258 const [y, m, d] = iso.split("-").map(Number);259 if (!y || !m || !d) return null;260 const txt = new Date(y, m - 1, d).toLocaleDateString("fr-CA", {261 day: "numeric", month: "long", year: "numeric",262 });263 return txt.replace(/^1 /, "1ᵉʳ ");264}265