spb/immo-ka Public
Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 66.4%
TypeScript 19.9%
CSS 13.2%
HTML 0.5%
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// -----------------------------------------------------------------------------67export interface Room {8 nom?: string;9 niveau?: string;10 dimensions?: string;11 revetement?: string;12}1314/** details : dictionnaire libre de caractéristiques Centris (libellé → valeur),15 * avec la clé spéciale `pieces` (liste des pièces + dimensions). */16export interface ListingDetails {17 pieces?: Room[];18 price_from?: boolean;19 [key: string]: unknown;20}2122export interface Listing {23 uid: string;24 source: string;25 external_id: string;26 url: string;27 title: string;28 address: string;29 sector: string;30 city: string;31 region: string;32 property_type: string;33 price: number | null;34 price_label: string;35 bedrooms: number | null;36 bathrooms: number | null;37 powder_rooms: number | null;38 area_sqft: number | null;39 lot_sqft: number | null;40 year_built: number | null;41 mls: string;42 status: string;43 broker_name: string;44 broker_phone: string;45 description: string;46 features: string[];47 details: ListingDetails;48 images: string[];49 lat: number | null;50 lng: number | null;51 price_history?: { ts: number; price: number | null }[];52 poi?: Poi[]; // commodités de proximité (fiche seulement)53 quartier?: Quartier | null; // stats de quartier (fiche seulement)54 vraiprix?: VraiPrix | null; // estimation de valeur marchande (Vrai-Prix)55 first_seen?: number;56 last_seen?: number;57 updated_at?: number;58 active?: number;59}6061export interface Facets {62 cities: string[];63 sectors: string[];64 property_types: string[];65 sources: { source: string; n: number }[];66}6768export interface Source {69 id: string;70 name: string;71 url: string;72 listing_url?: string;73 coverage?: string;74 type?: string;75 connector?: string | null;76 status: string;77 active_listings: number;78 last_sync: number | null;79}8081export interface Stats {82 total: number;83 sources: number;84 cities: number;85 avg_price: number | null;86 min_price: number | null;87 max_price: number | null;88 recent_syncs?: {89 source: string; ts: number; found: number; added: number;90 updated: number; removed: number; ok: number; message: string;91 }[];92}9394// --- Noms d'agences (jolis libellés) ----------------------------------------95const SOURCE_NAMES: Record<string, string> = {};96export function registerSourceNames(sources: Source[]) {97 for (const s of sources) SOURCE_NAMES[s.id] = s.name;98}99export function sourceName(id: string): string {100 if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];101 // repli lisible pour les sous-agences RE/MAX générées (remax_ag_xxx)102 if (id.startsWith("remax_ag_"))103 return "RE/MAX " + id.slice(9).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());104 return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());105}106107async function get<T>(path: string): Promise<T> {108 const ctrl = new AbortController();109 const timer = setTimeout(() => ctrl.abort(), 25000);110 try {111 const res = await fetch(path, { signal: ctrl.signal });112 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);113 return (await res.json()) as T;114 } finally {115 clearTimeout(timer);116 }117}118119export interface ListingFilters {120 city?: string;121 sector?: string;122 region?: string;123 property_type?: string;124 source?: string;125 price_min?: string;126 price_max?: string;127 bedrooms_min?: string;128 bathrooms_min?: string;129 area_min?: string;130 q?: string;131 sort?: string; // price_asc | price_desc | recent132}133134export function listingParams(f: ListingFilters): URLSearchParams {135 const params = new URLSearchParams();136 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);137 return params;138}139140export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {141 const params = listingParams(f);142 params.set("limit", String(limit));143 params.set("offset", String(offset));144 return get<{ total: number; count: number; listings: Listing[] }>(145 `/api/listings?${params}`);146}147148export const fetchListing = (uid: string) =>149 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);150export const fetchFacets = (city?: string) =>151 get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);152export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");153export const fetchStats = () => get<Stats>("/api/stats");154155export interface SubAgency { name: string; count: number; sources: string[] }156export interface Franchise {157 franchise: string;158 total: number;159 sub_agencies: number;160 agencies: SubAgency[];161}162export const fetchAgencies = () =>163 get<{ franchises: Franchise[] }>("/api/agencies");164165// --- Formatage --------------------------------------------------------------166export const fmtPrice = (p: number | null, label?: string) =>167 p != null168 ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"169 : label || "Prix sur demande";170171export const fmtArea = (a: number | null): string | null =>172 a != null ? `${Math.round(a).toLocaleString("fr-CA")} pi²` : null;173174export const fmtDate = (ts: number): string =>175 new Date(ts * 1000).toLocaleDateString("fr-CA", {176 day: "numeric", month: "long", year: "numeric",177 });178179// --- Vrai-Prix : estimation de valeur marchande ------------------------------180export interface VraiPrix {181 id: string;182 value: number | null;183 low: number | null;184 high: number | null;185 confidence_pct: number | null;186 confidence: string | null; // A | B | C | D187 url: string; // page d'analyse détaillée188}189190// --- Quartier (recensement 2021, proximité StatCan, chaleur INSPQ, crime) ----191export interface Poi { cat: string; name: string; dist_m: number }192export interface Quartier {193 dauid?: string | null;194 demographie?: {195 population: number | null; densite: number | null; age_median: number | null;196 revenu_median: number | null; pct_locataires: number | null;197 loyer_moyen: number | null; pct_francais: number | null; pct_univ: number | null;198 };199 proximite?: Record<string, number>;200 defavorisation?: { quintile_materiel: number | null; quintile_social: number | null };201 chaleur?: { classe: number; ecart: number | null };202 crime?:203 | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number }204 | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null };205}206