spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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