SPB Git

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%
7.1 KB · 226 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// 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