SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
7.9 KB · 262 lines typescript
Raw Blame History
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}147148async function get<T>(path: string): Promise<T> {149  const ctrl = new AbortController();150  const timer = setTimeout(() => ctrl.abort(), 20000);151  try {152    const res = await fetch(path, { signal: ctrl.signal });153    if (!res.ok) throw new Error(`API ${res.status} — ${path}`);154    return (await res.json()) as T;155  } finally {156    clearTimeout(timer);157  }158}159160export interface ListingFilters {161  city?: string;162  sector?: string;163  unit_type?: string;164  source?: string;165  price_min?: string;166  price_max?: string;167  pets?: string;          // "oui" -> acceptés (oui OU conditions)168  furnished?: string;     // "1" | "0"169  available_by?: string;  // ISO : dispo maintenant ou avant cette date170  area_min?: string;      // superficie minimale (pi²)171  q?: string;172}173174export function fetchListings(f: ListingFilters) {175  const params = new URLSearchParams();176  for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);177  return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);178}179180export interface GroupStat {181  key: string;182  count: number;183  sources?: number;184  avg_price: number | null;185  min_price: number | null;186}187188export interface DetailedStats {189  totals: {190    total: number;191    with_price: number;192    avg: number | null;193    median: number | null;194    min: number | null;195    max: number | null;196    sources: number;197    cities: number;198    regions: number;199    gps_pct: number | null;200    superficie_moyenne: number | null;201    dispo_now: number;202  };203  histogram: { lo: number; hi: number | null; count: number }[];204  by_type: GroupStat[];205  by_city: GroupStat[];206  by_source: GroupStat[];207  by_region: GroupStat[];208  offre: {209    furnished_pct: number | null;210    pets_oui_pct: number | null;211    pets_connu: number;212    chauffage_pct: number | null;213    electricite_pct: number | null;214    eau_chaude_pct: number | null;215    internet_pct: number | null;216    clim_pct: number | null;217    stationnement_pct: number | null;218    balcon_pct: number | null;219    dispo_now: number;220    dispo_date: number;221    dispo_inconnue: number;222    superficie_moyenne: number | null;223    superficie_connue: number;224    prix_pi2: { key: string; count: number; val: number }[];225  };226  baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[];227  sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] };228}229230export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");231232export const fetchListing = (uid: string) =>233  get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);234export const fetchFacets = (city?: string) =>235  get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);236237/** Date ISO à +n jours (pour « dispo d'ici 1 mois », etc.) */238export function isoInDays(n: number): string {239  const d = new Date();240  d.setDate(d.getDate() + n);241  return d.toISOString().slice(0, 10);242}243export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");244export const fetchStats = () => get<Stats>("/api/stats");245246export const fmtPrice = (p: number | null, label?: string) =>247  p != null248    ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"249    : label || "Prix sur demande";250251/** "now" -> « Maintenant », "2026-12-01" -> « 1ᵉʳ décembre 2026 » */252export function fmtAvailability(iso: string | null): string | null {253  if (!iso) return null;254  if (iso === "now") return "Maintenant";255  const [y, m, d] = iso.split("-").map(Number);256  if (!y || !m || !d) return null;257  const txt = new Date(y, m - 1, d).toLocaleDateString("fr-CA", {258    day: "numeric", month: "long", year: "numeric",259  });260  return txt.replace(/^1 /, "1ᵉʳ ");261}262