SPB Git

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%
6.5 KB · 206 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// -----------------------------------------------------------------------------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