Python 49.6%
TypeScript 25.5%
CSS 24.1%
1// -----------------------------------------------------------------------------2// Home-Ka — US real-estate aggregator (Groupe KA)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + robust API client (timeout, typed errors) for the Home-Ka5// FastAPI backend (homeka/web.py) — public search/map/detail + admin API.6// -----------------------------------------------------------------------------78/** details: free-form dictionary of source characteristics (label → value). */9export interface ListingDetails {10 price_from?: boolean;11 cover_thumb?: string;12 photo_captions?: string[];13 [key: string]: unknown;14}1516/** A LISTING = one publication of a property by one source. */17export interface Listing {18 uid: string;19 source: string;20 external_id: string;21 property_id?: number | null;22 url: string;23 title: string;24 street_address: string;25 unit?: string | null;26 city: string;27 state: string; // 2-letter USPS28 zip_code: string;29 county: string;30 property_type: string;31 property_subtype?: string | null;32 list_price: number | null;33 price_label: string;34 bedrooms: number | null;35 bathrooms: number | null; // can be 2.536 bathrooms_full?: number | null;37 bathrooms_half?: number | null;38 living_area_sqft: number | null;39 lot_size_sqft: number | null;40 year_built: number | null;41 apn?: string | null;42 mls_id: string;43 mls_name?: string | null;44 status: string; // active | pending | sold | withdrawn | coming-soon45 listed_at?: string | null;46 days_on_market?: number | null;47 brokerage_name: string;48 office_name?: string | null;49 agent_name: string;50 agent_phone: string;51 agent_email?: string | null;52 description: string;53 features: string[];54 details: ListingDetails;55 images: string[];56 lat: number | null;57 lng: number | null;58 price_history?: { ts: number; price: number | null }[];59 duplicates?: DuplicateListing[]; // same property published elsewhere60 property?: Property | null; // the physical PROPERTY behind this listing61 first_seen?: number;62 last_seen?: number;63 updated_at?: number;64 active?: number;65 published?: number;66}6768export interface DuplicateListing {69 uid: string;70 source: string;71 url: string;72 brokerage_name: string;73 agent_name: string;74 price_label: string;75}7677/** One row of a property's listing history (properties → listings). */78export interface PropertyListingRef {79 uid: string;80 source: string;81 status: string;82 list_price: number | null;83 first_seen: number | null;84 last_seen: number | null;85 active: number;86}8788/** The physical PROPERTY — Home-Ka separates PROPERTY from LISTING. */89export interface Property {90 id: number;91 apn?: string | null;92 street_address?: string | null;93 unit?: string | null;94 city?: string | null;95 state?: string | null;96 zip_code?: string | null;97 county?: string | null;98 property_type?: string | null;99 year_built?: number | null;100 living_area_sqft?: number | null;101 lot_size_sqft?: number | null;102 lat?: number | null;103 lng?: number | null;104 details?: Record<string, unknown>;105 first_seen?: number | null;106 last_seen?: number | null;107 listing_history?: PropertyListingRef[];108}109110export interface Facets {111 states: { state: string; n: number }[];112 cities: { city: string; n: number }[];113 property_types: string[];114 sources: { source: string; n: number }[];115}116117export interface Source {118 id: string;119 name: string;120 connector_type: string | null;121 states: string[];122 enabled: number;123 active_listings: number;124 last_sync: number | null;125}126127export interface SyncLogRow {128 source: string;129 ts: number;130 found: number;131 added: number;132 updated: number;133 removed: number;134 ok: number;135 message: string;136}137138export interface Stats {139 total: number;140 sources: number;141 cities: number;142 states: number;143 avg_price: number | null;144 min_price: number | null;145 max_price: number | null;146 properties: number;147 by_state: { state: string; n: number; avg_price: number | null }[];148 by_type: { property_type: string; n: number; avg_price: number | null }[];149 quality?: Record<string, unknown>;150 recent_syncs?: SyncLogRow[];151}152153// --- Source display names -----------------------------------------------------154const SOURCE_NAMES: Record<string, string> = {};155export function registerSourceNames(sources: Source[]) {156 for (const s of sources) SOURCE_NAMES[s.id] = s.name;157}158export function sourceName(id: string): string {159 if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];160 return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());161}162163async function get<T>(path: string, headers?: Record<string, string>): Promise<T> {164 const ctrl = new AbortController();165 const timer = setTimeout(() => ctrl.abort(), 25000);166 try {167 const res = await fetch(path, { signal: ctrl.signal, headers });168 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);169 return (await res.json()) as T;170 } finally {171 clearTimeout(timer);172 }173}174175async function post<T>(176 path: string, body?: unknown, headers?: Record<string, string>,177): Promise<T> {178 const ctrl = new AbortController();179 const timer = setTimeout(() => ctrl.abort(), 60000);180 try {181 const res = await fetch(path, {182 method: "POST",183 headers: { "Content-Type": "application/json", ...(headers ?? {}) },184 body: body === undefined ? undefined : JSON.stringify(body),185 signal: ctrl.signal,186 });187 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);188 return (await res.json()) as T;189 } finally {190 clearTimeout(timer);191 }192}193194export interface ListingFilters {195 q?: string;196 city?: string;197 state?: string;198 zip_code?: string;199 county?: string;200 property_type?: string;201 status?: string;202 source?: string;203 price_min?: string;204 price_max?: string;205 beds_min?: string;206 baths_min?: string;207 sqft_min?: string;208 sort?: string; // recent | price_asc | price_desc209}210211export function listingParams(f: ListingFilters): URLSearchParams {212 const params = new URLSearchParams();213 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);214 return params;215}216217export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {218 const params = listingParams(f);219 params.set("limit", String(limit));220 params.set("offset", String(offset));221 return get<{ total: number; count: number; listings: Listing[] }>(222 `/api/listings?${params}`);223}224225export const fetchListing = (uid: string) =>226 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);227export const fetchFacets = (state?: string) =>228 get<Facets>(`/api/facets${state ? `?state=${encodeURIComponent(state)}` : ""}`);229export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");230export const fetchStats = () => get<Stats>("/api/stats");231232// --- Formatting (US) ----------------------------------------------------------233export const fmtPrice = (p: number | null | undefined, label?: string) =>234 p != null235 ? "$" + Math.round(p).toLocaleString("en-US")236 : label || "Price on request";237238export const fmtArea = (a: number | null | undefined): string | null =>239 a != null ? `${Math.round(a).toLocaleString("en-US")} sq ft` : null;240241export const fmtDate = (ts: number): string =>242 new Date(ts * 1000).toLocaleDateString("en-US", {243 month: "long", day: "numeric", year: "numeric",244 });245246/** 2.5 → "2.5", 2 → "2" — bathrooms can be half-counts. */247export const fmtBaths = (b: number | null | undefined): string =>248 b == null ? "—" : (Number.isInteger(b) ? String(b) : b.toFixed(1));249250/** "Austin, TX 78704" from the parts that exist. */251export const fmtCityLine = (252 l: { city?: string | null; state?: string | null; zip_code?: string | null },253): string =>254 [255 [l.city, l.state].filter(Boolean).join(", "),256 l.zip_code ?? "",257 ].filter(Boolean).join(" ");258259export const STATUS_LABELS: Record<string, string> = {260 "active": "For sale",261 "pending": "Pending",262 "sold": "Sold",263 "withdrawn": "Withdrawn",264 "coming-soon": "Coming soon",265};266267// =============================================================================268// Admin API — /api/admin/* (optional X-Admin-Token header, stored locally)269// =============================================================================270271export const ADMIN_TOKEN_KEY = "homeka_admin_token";272273export function getAdminToken(): string {274 try { return localStorage.getItem(ADMIN_TOKEN_KEY) ?? ""; } catch { return ""; }275}276export function setAdminToken(token: string) {277 try {278 if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token);279 else localStorage.removeItem(ADMIN_TOKEN_KEY);280 } catch { /* storage unavailable */ }281}282function adminHeaders(): Record<string, string> {283 const t = getAdminToken();284 return t ? { "X-Admin-Token": t } : {};285}286287export interface AdminOverview {288 active_listings: number;289 properties: number;290 enabled_sources: number;291 errors_24h: number;292 brokerages_by_status: { partnership_status: string; n: number }[];293 last_successful_sync: number | null;294}295296export interface AdminSource {297 id: string;298 name: string;299 connector_type: string | null;300 enabled: number;301 authority: number | null;302 states: string[];303 notes: string;304 brokerage_id: number | null;305 config: Record<string, unknown>;306 active_listings: number;307 published: number;308 no_geo: number;309 last_sync: number | null;310 last_ok: number | null;311 last_message: string | null;312 last_found: number | null;313 last_added: number | null;314 last_removed: number | null;315 errors_7d: number;316 freshness_hours: number | null;317}318319export interface ConnectorFamily {320 family: string;321 class: string;322 module: string;323 doc: string;324 instances: number;325}326export interface CustomConnector {327 source_id: string;328 class: string;329 module: string;330}331332export interface Brokerage {333 id: number;334 name: string;335 website: string | null;336 states: string[];337 cities: string[];338 estimated_agents: number | null;339 estimated_listings: number | null;340 mls_affiliations: string[];341 idx_present: number | null;342 idx_provider: string | null;343 reso_detected: number | null;344 possible_feed_type: string | null;345 contact_page: string | null;346 partnership_contact: string | null;347 technical_contact: string | null;348 feed_probability_score: number | null;349 priority_score: number | null;350 partnership_status: string;351 source_id: string | null;352 last_inspected: number | null;353 inspect_error: string | null;354 evidence: Record<string, unknown>;355}356357export const PARTNERSHIP_STATUSES = [358 "prospect", "to-contact", "contacted", "in-discussion",359 "feed-received", "live", "declined",360] as const;361362export const fetchAdminOverview = () =>363 get<AdminOverview>("/api/admin/overview", adminHeaders());364365export const fetchAdminSources = () =>366 get<{ sources: AdminSource[] }>("/api/admin/sources", adminHeaders());367368export const fetchAdminConnectors = () =>369 get<{ families: ConnectorFamily[]; custom: CustomConnector[] }>(370 "/api/admin/connectors", adminHeaders());371372export interface BrokerageFilters {373 state?: string;374 status?: string;375 feed_type?: string;376 idx_provider?: string;377 q?: string;378 min_priority?: string;379 inspected?: string; // "1" | "0" | ""380 sort?: string; // priority | feed | name | inspected381}382383export function fetchAdminBrokerages(f: BrokerageFilters, limit = 50, offset = 0) {384 const params = new URLSearchParams();385 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);386 params.set("limit", String(limit));387 params.set("offset", String(offset));388 return get<{ total: number; count: number; brokerages: Brokerage[] }>(389 `/api/admin/brokerages?${params}`, adminHeaders());390}391392export const inspectBrokerage = (id: number) =>393 post<Record<string, unknown>>(394 `/api/admin/brokerages/${id}/inspect`, undefined, adminHeaders());395396export const setBrokerageStatus = (id: number, status: string) =>397 post<{ id: number; status: string }>(398 `/api/admin/brokerages/${id}/status`, { status }, adminHeaders());399400export const runDiscovery = (limit = 25) =>401 post<{ status: string; limit: number }>(402 `/api/admin/discover?limit=${limit}`, undefined, adminHeaders());403