Architektura platformy nieruchomości w Scali
Nowoczesne platformy nieruchomości, takie jak Zillow, Idealista, Immobiliare.it e Prawy ruch zarządzaj milionami reklam, miliardy miesięcznych wyszukiwań i strumieni danych w czasie rzeczywistym z agencji, MLS (Usługa wielu ogłoszeń) i właściciele prywatni. Zaprojektuj system zdolny do utrzymania obciążenie to wymaga precyzyjnych decyzji architektonicznych na każdej warstwie stosu: z od modelowania danych po badania geoprzestrzenne, od potoku multimedialnego po system powiadomienia w czasie rzeczywistym.
W tym artykule zbudujemy pełną architekturę platformy PropTech na dużą skalę, analizowanie każdego komponentu za pomocą przykładów kodu w Maszynopis e Pyton, porównania technologii i wzorców projektowych sprawdzonych w produkcji.
Czego się nauczysz
- Architektura mikrousług dla platform nieruchomościowych z dekompozycją domen
- Kompletny model danych: właściwości, oferty, agenci, transakcje, media
- Zaawansowane wyszukiwanie geoprzestrzenne za pomocą Elasticsearch, PostGIS i H3
- Potok pozyskiwania reklam z heterogenicznych źródeł (MLS/RESO Web API, scraping, kanał XML)
- Rurociąg mediów: przetwarzanie obrazu, plany pięter, wirtualne wycieczki 3D
- Funkcjonalność w czasie rzeczywistym: powiadomienia, wiadomości, alerty cenowe
- Strategie wydajnościowe na dużą skalę: buforowanie, CDN, sharding, CQRS
- Zgodność: RODO, godziwe mieszkalnictwo, przepisy regionalne
Krajobraz platform nieruchomości
Przed zaprojektowaniem architektury konieczne jest zrozumienie konkurencyjnego krajobrazu i otoczenia modele biznesowe, które kierują wyborami technicznymi. Każda platforma ma unikalną mieszankę funkcjonalność, która bezpośrednio wpływa na decyzje dotyczące infrastruktury.
| Platforma | Rynek | Aktywne reklamy | Osobliwość | Znany stos |
|---|---|---|---|---|
| Zillow | USA | ~135 milionów nieruchomości | Zestimate (ocena ML) | Java, Kafka, Elasticsearch |
| Idealista | UE (ES, IT, PT) | ~1,8 mln reklam | Interaktywne wyszukiwanie na mapie | Java, Solr, PostgreSQL |
| Immobiliare.it | Włochy | ~1,2 mln reklam | Automatyczna ocena, mapa cieplna | PHP/Go, Elasticsearch |
| Prawy ruch | UK | ~1 milion reklam | Wyszukiwanie metodą rysowania (rysowanie obszaru) | .NET, SQL Server, Azure |
| Redfin | USA | ~100 milionów nieruchomości | Zintegrowani agenci, wycieczki 3D | Java, Reaguj, Kafka |
Modele monetyzacji i wpływ techniczny
Model biznesowy bezpośrednio wpływa na architekturę. Platforma freemium z listami sponsorowanymi potrzeba silnika obsługującego reklamy, A/B testowanie i śledzenie wyświetleń. Modelka na bazie ołowiu wymaga routingu inteligentne kontakty do agentów i zintegrowany CRM. Modelka transakcyjne (iBuying) obejmuje proces oceny ML i zarządzanie zgodność finansowa i zaawansowana z przepisami.
- Ogłoszenia sponsorowane: wymaga silnika rankingu reklam, systemu licytacji i analiz ROI
- Abonament agencyjny: zarządzanie poziomami, rozliczenia cykliczne, pulpit agenta
- Generowanie leadów: scoring leadów, inteligentny routing, śledzenie atrybucji
- Transakcyjne (iBuying): Modele oceny ML, zarządzanie ofertą, rurociąg prawny
- SaaS dla agencji: multi-tenancy, white-label, API do integracji z CRM
Architektura na wysokim poziomie
Platforma nieruchomości drabinowych przyjmuje architekturę Mikrousługi zorientowane na domeny, gdzie każda usługa ma własną bazę danych i komunikuje się poprzez zdarzenia asynchroniczne. To podejście umożliwia niezależne skalowanie komponentów pod większym obciążeniem (zwykle wyszukiwanie i interfejsy API publiczne) bez wpływu na mniej pożądane usługi.
Zasada przewodnia: wzór figowy dusiciela
Większość platform nieruchomości zaczyna od monolitów. Migracja do mikroserwisów następuje stopniowo poprzez Wzór figowy Dusiciela: usługi są wyodrębniane jako pierwsze przy dużym obciążeniu (badania, media), a następnie stopniowo pozostałych, utrzymując funkcjonowanie monolitu przez całe przejście.
Dekompozycja na mikrousługi
| Praca | Odpowiedzialność | Bazy danych | Wzory |
|---|---|---|---|
| Usługa aukcji | Reklamy CRUD, walidacja, obieg publikacji | PostgreSQL + PostGIS | CQRS, pozyskiwanie zdarzeń |
| Usługa wyszukiwania | Wyszukiwanie pełnotekstowe, filtry, geoprzestrzenne, aspektowe | Elasticsearch/OpenSearch | Odczyt modelu (CQRS) |
| Obsługa Użytkownika | Uwierzytelnianie, profile, preferencje, zapisane | PostgreSQL | OAuth 2.0 / OIDC |
| Usługa agenta | Profile agentów, agencje, oceny, dostępność | PostgreSQL | Usługa domeny |
| Serwis Medialny | Przesyłanie, przetwarzanie, optymalizacja, CDN | S3 + DynamoDB (metadane) | Potok asynchroniczny |
| Usługa przesyłania wiadomości | Czat agent-użytkownik, prośby o informacje, powiadomienia | MongoDB / Cassandra | WebSocket + sterowany zdarzeniami |
| Usługa powiadamiania | E-mail, push, SMS, alerty cenowe, nowe ogłoszenia | Redis + PostgreSQL | Rozwinięcie, silnik szablonów |
| Usługa analityczna | Śledzenie wizyt, heatmapy, raporty dla agentów | ClickHouse / BigQuery | Transmisja wydarzeń |
| Usługa przyjmowania | Import z MLS, kanałów XML, zewnętrznych API | Przejściowa baza danych + kolejka | Rurociąg ETL |
| Usługa wyceny | Szacunki cen ML, dane porównawcze, trendy rynkowe | Sklep z funkcjami + rejestr modeli | Rurociąg ML |
Schemat architektury
+------------------+
| API Gateway |
| (Kong / Envoy) |
+--------+---------+
|
+--------------------+--------------------+
| | |
+--------v------+ +--------v------+ +--------v------+
| Listing | | Search | | User |
| Service | | Service | | Service |
| (PostgreSQL) | | (Elasticsearch)| | (PostgreSQL) |
+--------+------+ +---------------+ +--------+------+
| |
| Events (Kafka / NATS) |
+--------------------+-------------------+
| | |
+--------v------+ +--------v------+ +--------v------+
| Media | | Messaging | | Notification |
| Service | | Service | | Service |
| (S3 + CDN) | | (MongoDB) | | (Redis) |
+---------------+ +---------------+ +---------------+
|
+--------v------+ +---------------+
| Ingestion | | Valuation |
| Service | | Service |
| (ETL Pipeline)| | (ML Models) |
+---------------+ +---------------+
Model danych: serce platformy
Model danych platformy nieruchomości jest zaskakująco złożony. Singiel nieruchomość fizyka może mieć wielokrotności reklamy z biegiem czasu być zarządzane przez inny agenci, idź w górę transakcje i wygenerować setki zasoby multimedialne. Zaprojektuj te relacje poprawnie i krytycznie wydajność i spójność danych.
Schemat encji głównej
// Entità base: Proprietà fisica
interface Property {
readonly id: string;
readonly externalId?: string; // ID da MLS/fonte esterna
readonly source: DataSource;
readonly propertyType: PropertyType;
readonly address: Address;
readonly location: GeoPoint; // lat/lng per ricerca spaziale
readonly h3Index: string; // H3 cell index (risoluzione 9)
readonly characteristics: PropertyCharacteristics;
readonly amenities: ReadonlyArray<Amenity>;
readonly energyRating?: EnergyRating;
readonly cadastralRef?: string; // Riferimento catastale (IT)
readonly createdAt: Date;
readonly updatedAt: Date;
}
type PropertyType =
| 'apartment' | 'house' | 'villa' | 'penthouse'
| 'studio' | 'loft' | 'commercial' | 'land'
| 'garage' | 'office' | 'warehouse';
interface Address {
readonly street: string;
readonly streetNumber: string;
readonly city: string;
readonly province: string;
readonly region: string;
readonly postalCode: string;
readonly country: string;
readonly formattedAddress: string;
readonly neighborhood?: string;
}
interface GeoPoint {
readonly lat: number;
readonly lng: number;
}
interface PropertyCharacteristics {
readonly sqMeters: number;
readonly rooms: number;
readonly bedrooms: number;
readonly bathrooms: number;
readonly floor?: number;
readonly totalFloors?: number;
readonly hasElevator?: boolean;
readonly hasBalcony?: boolean;
readonly hasTerrace?: boolean;
readonly hasGarden?: boolean;
readonly gardenSqMeters?: number;
readonly yearBuilt?: number;
readonly condition: PropertyCondition;
readonly heatingType?: HeatingType;
readonly orientation?: ReadonlyArray<Orientation>;
}
// Annuncio: un'istanza di vendita/affitto
interface Listing {
readonly id: string;
readonly propertyId: string;
readonly agentId: string;
readonly agencyId?: string;
readonly listingType: 'sale' | 'rent' | 'auction';
readonly status: ListingStatus;
readonly price: Money;
readonly pricePerSqMeter: Money;
readonly condominiumFees?: Money;
readonly description: LocalizedText;
readonly media: ReadonlyArray<MediaAsset>;
readonly virtualTourUrl?: string;
readonly availableFrom?: Date;
readonly publishedAt?: Date;
readonly expiresAt?: Date;
readonly viewCount: number;
readonly favoriteCount: number;
readonly contactCount: number;
}
interface Money {
readonly amount: number;
readonly currency: CurrencyCode;
}
type CurrencyCode = 'EUR' | 'USD' | 'GBP' | 'CHF';
interface LocalizedText {
readonly [locale: string]: string; // { it: "...", en: "..." }
}
type ListingStatus =
| 'draft' | 'pending_review' | 'active'
| 'under_offer' | 'sold' | 'rented'
| 'expired' | 'withdrawn';
Niektóre kluczowe aspekty tego modelu:
- Oddzielenie nieruchomości od oferty: właściwość fizyczna istnieje niezależnie z reklam. To samo mieszkanie można wystawić na sprzedaż, następnie wycofać i wynająć, generowanie odrębnych zestawień powiązanych z tym samym podmiotem.
-
Niezmienność: używane są wszystkie interfejsy
readonlyzapobiegać przypadkowe mutacje. Przejścia stanów tworzą nowe obiekty. - Indeks H3: wstępnie obliczone po wstawieniu, aby umożliwić wyszukiwanie geoprzestrzenne wydajne dzięki agregacji heksagonalnej.
-
Wielowalutowy i wielojęzyczny: natywne wsparcie przez
MoneyeLocalizedTextna rynki międzynarodowe.
Potok przetwarzania reklam
Bijące serce platformy nieruchomości i potok pozyskiwania danych. Reklamy pochodzą z heterogenicznych źródeł: pasz Internetowy interfejs API RESO (norma amerykańska/międzynarodowa), Zastrzeżone kanały XML, interfejsy API agencji, ręczne przesyłanie i pobieranie z portali partnerów. Każde źródło ma swój własny format, częstotliwość aktualizacji i poziom jakości danych.
RESO Web API: standard branżowy
Il Internetowy interfejs API RESO (Organizacja ds. standardów nieruchomości). i nowoczesny standard do wymiany danych MLS, w oparciu o REST/OData z ładunkiem JSON. Zastępuje stary RETS (obecnie przestarzałe). Data Dictionary 2.x definiuje standardowe nazwy pól (ListingId, StandardStatus, ListPrice, LivingArea) zapewniających interoperacyjność pomiędzy systemami.
Architektura rurociągów ETL
import { EventEmitter } from 'events';
// Interfaccia generica per sorgenti dati
interface ListingSource {
readonly sourceId: string;
readonly sourceType: 'reso_api' | 'xml_feed' | 'manual' | 'scraper';
fetch(since: Date): Promise<ReadonlyArray<RawListing>>;
}
// Dati grezzi da qualsiasi fonte
interface RawListing {
readonly externalId: string;
readonly source: string;
readonly rawData: Record<string, unknown>;
readonly fetchedAt: Date;
}
// Pipeline di ingestione con step chiari
class ListingIngestionPipeline {
private readonly events = new EventEmitter();
constructor(
private readonly sources: ReadonlyArray<ListingSource>,
private readonly normalizer: ListingNormalizer,
private readonly validator: ListingValidator,
private readonly deduplicator: ListingDeduplicator,
private readonly enricher: ListingEnricher,
private readonly repository: ListingRepository,
private readonly searchIndex: SearchIndexer,
) {}
async ingest(source: ListingSource): Promise<IngestionResult> {
const startTime = Date.now();
const result: IngestionResult = {
sourceId: source.sourceId,
processed: 0,
created: 0,
updated: 0,
skipped: 0,
errors: [],
};
// Step 1: Fetch dati grezzi
const rawListings = await source.fetch(
await this.getLastSyncTime(source.sourceId)
);
for (const raw of rawListings) {
try {
// Step 2: Normalizzazione (formato sorgente -> formato interno)
const normalized = this.normalizer.normalize(raw);
// Step 3: Validazione (campi obbligatori, range, formato)
const validation = this.validator.validate(normalized);
if (!validation.isValid) {
result.skipped++;
result.errors.push({
externalId: raw.externalId,
errors: validation.errors,
});
continue;
}
// Step 4: Deduplicazione (match per indirizzo, coordinate, ID esterno)
const existingId = await this.deduplicator.findDuplicate(normalized);
// Step 5: Arricchimento (geocoding, H3 index, neighborhood)
const enriched = await this.enricher.enrich(normalized);
// Step 6: Persistenza
if (existingId) {
await this.repository.update(existingId, enriched);
result.updated++;
} else {
await this.repository.create(enriched);
result.created++;
}
// Step 7: Indicizzazione per la ricerca (asincrona)
this.events.emit('listing:upserted', enriched);
result.processed++;
} catch (error) {
result.errors.push({
externalId: raw.externalId,
errors: [String(error)],
});
}
}
// Aggiorna timestamp ultima sync
await this.updateLastSyncTime(source.sourceId, new Date());
this.events.emit('ingestion:completed', {
...result,
durationMs: Date.now() - startTime,
});
return result;
}
private async getLastSyncTime(sourceId: string): Promise<Date> {
// Recupera da DB il timestamp dell'ultima sincronizzazione
return new Date(Date.now() - 24 * 60 * 60 * 1000); // fallback: 24h fa
}
private async updateLastSyncTime(sourceId: string, time: Date): Promise<void> {
// Persiste il timestamp per la prossima esecuzione
}
}
interface IngestionResult {
readonly sourceId: string;
processed: number;
created: number;
updated: number;
skipped: number;
errors: Array<{ externalId: string; errors: string[] }>;
}
Normalizacja: z RETURN do formatu wewnętrznego
Normalizacja jest najważniejszym krokiem w rurociągu. Każde źródło danych ma swój format różne i normalizacja musi odwzorowywać pola heterogeniczne w jednolity model. Oto przykład normalizatora dla formatu RESO:
class ResoListingNormalizer implements ListingNormalizer {
normalize(raw: RawListing): NormalizedListing {
const data = raw.rawData as ResoPropertyData;
return {
externalId: String(data.ListingId),
source: raw.source,
propertyType: this.mapPropertyType(data.PropertyType),
listingType: this.mapListingType(data.TransactionType),
status: this.mapStatus(data.StandardStatus),
price: {
amount: data.ListPrice,
currency: data.CurrencyCode ?? 'USD',
},
address: {
street: data.StreetName,
streetNumber: data.StreetNumber,
city: data.City,
province: data.StateOrProvince,
postalCode: data.PostalCode,
country: data.Country ?? 'US',
formattedAddress: this.buildFormattedAddress(data),
},
location: data.Latitude && data.Longitude
? { lat: data.Latitude, lng: data.Longitude }
: undefined,
characteristics: {
sqMeters: this.sqFeetToSqMeters(data.LivingArea),
rooms: data.RoomsTotal ?? 0,
bedrooms: data.BedroomsTotal ?? 0,
bathrooms: data.BathroomsTotalInteger ?? 0,
yearBuilt: data.YearBuilt,
},
description: {
en: data.PublicRemarks ?? '',
},
photos: (data.Media ?? []).map((m: ResoMedia) => ({
url: m.MediaURL,
order: m.Order,
caption: m.ShortDescription,
})),
rawData: raw.rawData,
fetchedAt: raw.fetchedAt,
};
}
private mapPropertyType(resoType: string): PropertyType {
const mapping: Record<string, PropertyType> = {
'Residential': 'house',
'Condominium': 'apartment',
'Townhouse': 'house',
'Land': 'land',
'Commercial': 'commercial',
};
return mapping[resoType] ?? 'apartment';
}
private mapStatus(resoStatus: string): ListingStatus {
const mapping: Record<string, ListingStatus> = {
'Active': 'active',
'Pending': 'under_offer',
'Closed': 'sold',
'Withdrawn': 'withdrawn',
'Expired': 'expired',
};
return mapping[resoStatus] ?? 'draft';
}
private sqFeetToSqMeters(sqFeet?: number): number {
return sqFeet ? Math.round(sqFeet * 0.092903 * 100) / 100 : 0;
}
private mapListingType(txType: string): 'sale' | 'rent' {
return txType === 'Lease' ? 'rent' : 'sale';
}
private buildFormattedAddress(data: ResoPropertyData): string {
return [data.StreetNumber, data.StreetName, data.City, data.StateOrProvince]
.filter(Boolean)
.join(', ');
}
}
Architektura wyszukiwania: Elasticsearch i Geospatial
Najbardziej krytyczne wyszukiwanie i funkcjonalność dowolnej platformy nieruchomości. Użytkownicy tak oczekuj natychmiastowych wyników, łączonych filtrów, sortowania według trafności/ceny/odległości i wyszukiwanie map z aktualizacją w czasie rzeczywistym. Elastyczne wyszukiwanie (lub jego widelec Otwórz wyszukiwanie) i dominujący wybór w branży dzięki wsparciu natywny dla wyszukiwań pełnotekstowych, geoprzestrzennych i aspektowych.
Indeks Elasticsearch dla reklam
{
"mappings": {
"properties": {
"listingId": { "type": "keyword" },
"propertyType": { "type": "keyword" },
"listingType": { "type": "keyword" },
"status": { "type": "keyword" },
"price": { "type": "long" },
"pricePerSqm": { "type": "float" },
"currency": { "type": "keyword" },
"location": { "type": "geo_point" },
"geoShape": { "type": "geo_shape" },
"h3Index": { "type": "keyword" },
"h3Res7": { "type": "keyword" },
"city": { "type": "keyword" },
"neighborhood": { "type": "keyword" },
"province": { "type": "keyword" },
"postalCode": { "type": "keyword" },
"sqMeters": { "type": "integer" },
"rooms": { "type": "integer" },
"bedrooms": { "type": "integer" },
"bathrooms": { "type": "integer" },
"floor": { "type": "integer" },
"yearBuilt": { "type": "integer" },
"hasElevator": { "type": "boolean" },
"hasBalcony": { "type": "boolean" },
"hasGarden": { "type": "boolean" },
"energyRating": { "type": "keyword" },
"amenities": { "type": "keyword" },
"description": { "type": "text", "analyzer": "multilingual_analyzer" },
"title": { "type": "text", "analyzer": "multilingual_analyzer",
"fields": { "keyword": { "type": "keyword" } } },
"agentId": { "type": "keyword" },
"agencyId": { "type": "keyword" },
"publishedAt": { "type": "date" },
"updatedAt": { "type": "date" },
"viewCount": { "type": "integer" },
"photoCount": { "type": "integer" },
"hasVirtualTour": { "type": "boolean" }
}
},
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"multilingual_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "stop_it", "stop_en"]
}
},
"filter": {
"stop_it": { "type": "stop", "stopwords": "_italian_" },
"stop_en": { "type": "stop", "stopwords": "_english_" }
}
}
}
}
Wyszukaj interfejs API za pomocą filtrów geoprzestrzennych
Zapytanie wyszukiwania łączy filtry logiczne, zakresy liczbowe, wyszukiwanie pełnotekstowe i ograniczenia geoprzestrzenne. Obsługujemy trzy tryby wyszukiwania geograficznego: geo_odległość (promień od punktu), geo_bounding_box (prostokąt na mapie) e geo_shape (wielokąt rysowany przez użytkownika, taki jak „Rysuj wyszukiwanie” w Rightmove).
interface PropertySearchParams {
readonly query?: string;
readonly listingType: 'sale' | 'rent';
readonly propertyTypes?: ReadonlyArray<PropertyType>;
readonly priceMin?: number;
readonly priceMax?: number;
readonly sqMetersMin?: number;
readonly sqMetersMax?: number;
readonly bedroomsMin?: number;
readonly bathroomsMin?: number;
readonly amenities?: ReadonlyArray<string>;
readonly geoFilter?: GeoFilter;
readonly sortBy?: 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'distance';
readonly page?: number;
readonly pageSize?: number;
}
type GeoFilter =
| { type: 'radius'; center: GeoPoint; radiusKm: number }
| { type: 'bbox'; topLeft: GeoPoint; bottomRight: GeoPoint }
| { type: 'polygon'; points: ReadonlyArray<GeoPoint> };
class PropertySearchService {
constructor(private readonly esClient: ElasticsearchClient) {}
async search(params: PropertySearchParams): Promise<SearchResult> {
const must: any[] = [];
const filter: any[] = [];
// Filtro obbligatorio: tipo annuncio e stato attivo
filter.push({ term: { listingType: params.listingType } });
filter.push({ term: { status: 'active' } });
// Full-text search (titolo + descrizione)
if (params.query) {
must.push({
multi_match: {
query: params.query,
fields: ['title^3', 'description', 'city^2', 'neighborhood^2'],
type: 'best_fields',
fuzziness: 'AUTO',
},
});
}
// Filtri range prezzo
if (params.priceMin || params.priceMax) {
filter.push({
range: {
price: {
...(params.priceMin ? { gte: params.priceMin } : {}),
...(params.priceMax ? { lte: params.priceMax } : {}),
},
},
});
}
// Filtri superficie
if (params.sqMetersMin || params.sqMetersMax) {
filter.push({
range: {
sqMeters: {
...(params.sqMetersMin ? { gte: params.sqMetersMin } : {}),
...(params.sqMetersMax ? { lte: params.sqMetersMax } : {}),
},
},
});
}
// Tipo proprietà
if (params.propertyTypes?.length) {
filter.push({ terms: { propertyType: params.propertyTypes } });
}
// Camere minime
if (params.bedroomsMin) {
filter.push({ range: { bedrooms: { gte: params.bedroomsMin } } });
}
// Amenities (AND logic)
if (params.amenities?.length) {
for (const amenity of params.amenities) {
filter.push({ term: { amenities: amenity } });
}
}
// Filtro geospaziale
if (params.geoFilter) {
filter.push(this.buildGeoFilter(params.geoFilter));
}
const page = params.page ?? 0;
const pageSize = params.pageSize ?? 20;
const response = await this.esClient.search({
index: 'listings',
body: {
query: {
bool: {
must: must.length ? must : [{ match_all: {} }],
filter,
},
},
sort: this.buildSort(params.sortBy, params.geoFilter),
from: page * pageSize,
size: pageSize,
aggs: this.buildAggregations(),
},
});
return this.mapResponse(response, page, pageSize);
}
private buildGeoFilter(geo: GeoFilter): Record<string, unknown> {
switch (geo.type) {
case 'radius':
return {
geo_distance: {
distance: `${geo.radiusKm}km`,
location: { lat: geo.center.lat, lon: geo.center.lng },
},
};
case 'bbox':
return {
geo_bounding_box: {
location: {
top_left: { lat: geo.topLeft.lat, lon: geo.topLeft.lng },
bottom_right: { lat: geo.bottomRight.lat, lon: geo.bottomRight.lng },
},
},
};
case 'polygon':
return {
geo_shape: {
geoShape: {
shape: {
type: 'polygon',
coordinates: [
geo.points.map(p => [p.lng, p.lat]),
],
},
relation: 'within',
},
},
};
}
}
private buildAggregations(): Record<string, unknown> {
return {
price_stats: { stats: { field: 'price' } },
property_types: { terms: { field: 'propertyType', size: 15 } },
bedrooms: { terms: { field: 'bedrooms', size: 10 } },
price_ranges: {
range: {
field: 'price',
ranges: [
{ key: 'under_100k', to: 100000 },
{ key: '100k_200k', from: 100000, to: 200000 },
{ key: '200k_350k', from: 200000, to: 350000 },
{ key: '350k_500k', from: 350000, to: 500000 },
{ key: 'over_500k', from: 500000 },
],
},
},
neighborhoods: { terms: { field: 'neighborhood', size: 30 } },
energy_ratings: { terms: { field: 'energyRating', size: 10 } },
};
}
private buildSort(
sortBy?: string,
geoFilter?: GeoFilter
): Array<Record<string, unknown>> {
switch (sortBy) {
case 'price_asc':
return [{ price: 'asc' }];
case 'price_desc':
return [{ price: 'desc' }];
case 'newest':
return [{ publishedAt: 'desc' }];
case 'distance':
if (geoFilter?.type === 'radius') {
return [{
_geo_distance: {
location: { lat: geoFilter.center.lat, lon: geoFilter.center.lng },
order: 'asc',
unit: 'km',
},
}];
}
return [{ _score: 'desc' }];
default:
return [{ _score: 'desc' }, { publishedAt: 'desc' }];
}
}
private mapResponse(
response: any,
page: number,
pageSize: number
): SearchResult {
return {
listings: response.hits.hits.map((hit: any) => ({
...hit._source,
score: hit._score,
distance: hit.sort?.[0],
})),
total: response.hits.total.value,
page,
pageSize,
facets: {
priceStats: response.aggregations?.price_stats,
propertyTypes: response.aggregations?.property_types?.buckets,
bedrooms: response.aggregations?.bedrooms?.buckets,
priceRanges: response.aggregations?.price_ranges?.buckets,
neighborhoods: response.aggregations?.neighborhoods?.buckets,
energyRatings: response.aggregations?.energy_ratings?.buckets,
},
};
}
}
Indeksowanie geoprzestrzenne: PostGIS, H3 i S2
Oprócz Elasticsearch podstawowa warstwa trwałości wymaga możliwości geoprzestrzennych zaawansowane do operacji takich jak obliczanie dokładnych odległości, agregowanie według stref i analiza rynku. W tej przestrzeni dominują trzy technologie.
| Technologia | Typ | Podstawowe zastosowanie | Zalety | Limity |
|---|---|---|---|---|
| PocztaGIS | Rozszerzenie PostgreSQL | Zapytania przestrzenne SQL, złożone geometrie | Standardowy OGC, dojrzały, łącz z danymi relacyjnymi | Skalowalność pionowa, brak natywnego klastrowania |
| H3 (Ubera) | Hierarchiczna siatka sześciokątna | Agregacja według obszaru, bliskości, analityki | Jednolite, hierarchiczne wyszukiwanie O(1). | Pięciokąty na krawędziach, przybliżenie krawędzi |
| Geometria S2 (Google) | Hierarchiczne komórki sferyczne | Zasięg regionu, zapytanie o zakres, fragmentowanie | Dokładne pokrycie, używane w BigQuery/Spanner | Niejednorodne komórki czworoboczne, złożone API |
Zapytania geoprzestrzenne za pomocą PostGIS
-- Ricerca per raggio: proprietà entro 5km da un punto
SELECT
l.id,
l.title,
l.price,
p.property_type,
ST_Distance(
p.location::geography,
ST_MakePoint(12.4964, 41.9028)::geography
) AS distance_meters
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND l.listing_type = 'sale'
AND ST_DWithin(
p.location::geography,
ST_MakePoint(12.4964, 41.9028)::geography, -- Roma centro
5000 -- 5km in metri
)
AND l.price BETWEEN 150000 AND 400000
ORDER BY distance_meters ASC
LIMIT 50;
-- Ricerca per bounding box (viewport della mappa)
SELECT l.id, l.title, l.price,
ST_X(p.location) AS lng, ST_Y(p.location) AS lat
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND p.location && ST_MakeEnvelope(
12.45, 41.87, -- bottom-left (lng, lat)
12.55, 41.93, -- top-right (lng, lat)
4326 -- SRID WGS84
)
ORDER BY l.published_at DESC
LIMIT 200;
-- Clustering per H3: conteggio annunci per cella esagonale
SELECT
h3_lat_lng_to_cell(p.location, 7) AS h3_cell,
COUNT(*) AS listing_count,
AVG(l.price) AS avg_price,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l.price) AS median_price
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND l.listing_type = 'sale'
AND p.city = 'Milano'
GROUP BY h3_cell
HAVING COUNT(*) >= 3
ORDER BY avg_price DESC;
dlaczego H3 dla PropTech?
H3 rozwiązuje zasadniczy problem: efektywną agregację przestrzenną. Zamiast kalkulować
w czasie rzeczywistym skupionych reklam na mapie wstępnie oblicza wskaźnik H3 każdej nieruchomości
po włożeniu. Rozdzielczość 7 (~5,16 km²) jest idealna do widoków na miasto, rozdzielczość 9
(~0,105 km²) na poziomie sąsiedztwa. Proste zapytania zbiorcze
GROUP BY h3_indexrzędy wielkości szybciej niż operacje geometryczne
w czasie rzeczywistym.
Media Pipeline: obrazy, plany pięter i wirtualne wycieczki
Obrazy mają największy wpływ na decyzję użytkownika o kliknięciu reklamy. Platforma skalowana przetwarza miliony obrazów dziennie, każdy w wielu formatach i uchwały. Przeciętny rurociąg musi być asynchroniczny, odporny i zoptymalizowany pod kątem prędkość dostawy za pośrednictwem CDN'a.
Przepływ przetwarzania multimediów
- Przesłane pliki: agent przesyła oryginalne zdjęcia za pośrednictwem ustalonego adresu URL do S3
- Walidacja: format kontrolny, rozmiar, zawartość NSFW (ML)
- Przetwarzanie: generacja wariantów (miniatura 300px, średnia 800px, duża 1600px, WebP/AVIF)
- Optymalizacja: inteligentna kompresja, wrażliwe usuwanie metadanych EXIF, znak wodny
- Wzbogacanie ML: klasyfikacja pomieszczeń (kuchnia, łazienka, pokój dzienny), wynik jakości, ankieta dotycząca planu piętra
- Dystrybucja CDN: Push do węzłów brzegowych CDN w celu dostarczenia na całym świecie <50 ms
- Indeksowanie: aktualizacja metadanych w indeksie wyszukiwania
interface ImageVariant {
readonly suffix: string;
readonly width: number;
readonly quality: number;
readonly format: 'webp' | 'avif' | 'jpeg';
}
const IMAGE_VARIANTS: ReadonlyArray<ImageVariant> = [
{ suffix: 'thumb', width: 300, quality: 75, format: 'webp' },
{ suffix: 'medium', width: 800, quality: 80, format: 'webp' },
{ suffix: 'large', width: 1600, quality: 85, format: 'webp' },
{ suffix: 'avif-medium', width: 800, quality: 70, format: 'avif' },
{ suffix: 'original', width: 0, quality: 90, format: 'jpeg' },
] as const;
class ImageProcessingService {
constructor(
private readonly storage: ObjectStorage,
private readonly sharp: SharpInstance,
private readonly classifier: RoomClassifier,
private readonly queue: MessageQueue,
) {}
async processUploadedImage(event: ImageUploadEvent): Promise<ProcessedImage> {
const { listingId, imageKey, uploadedBy } = event;
// 1. Scarica l'originale
const originalBuffer = await this.storage.download(imageKey);
// 2. Valida (dimensione, formato, contenuto)
const metadata = await this.sharp(originalBuffer).metadata();
if (!metadata.width || metadata.width < 600) {
throw new ImageValidationError('Risoluzione minima: 600px di larghezza');
}
// 3. Genera tutte le varianti
const variants = await Promise.all(
IMAGE_VARIANTS.map(variant =>
this.generateVariant(originalBuffer, imageKey, variant)
)
);
// 4. Classifica la stanza (ML asincrono)
const classification = await this.classifier.classify(originalBuffer);
// 5. Rimuovi EXIF sensibili, mantieni orientamento
const sanitizedMetadata = this.sanitizeExif(metadata);
const processedImage: ProcessedImage = {
listingId,
originalKey: imageKey,
variants: variants.map(v => ({
key: v.key,
url: this.storage.getPublicUrl(v.key),
width: v.width,
height: v.height,
format: v.format,
sizeBytes: v.sizeBytes,
})),
classification: {
roomType: classification.label,
confidence: classification.score,
},
metadata: sanitizedMetadata,
processedAt: new Date(),
};
// 6. Invalida cache CDN per l'annuncio
await this.queue.publish('cdn:invalidate', {
patterns: [`/listings/${listingId}/images/*`],
});
return processedImage;
}
private async generateVariant(
buffer: Buffer,
originalKey: string,
variant: ImageVariant
): Promise<GeneratedVariant> {
let pipeline = this.sharp(buffer);
if (variant.width > 0) {
pipeline = pipeline.resize(variant.width, undefined, {
fit: 'inside',
withoutEnlargement: true,
});
}
const outputBuffer = await pipeline
.toFormat(variant.format, { quality: variant.quality })
.toBuffer({ resolveWithObject: true });
const variantKey = originalKey.replace(
/\.[^.]+$/, `-${variant.suffix}.${variant.format}`
);
await this.storage.upload(variantKey, outputBuffer.data, {
contentType: `image/${variant.format}`,
cacheControl: 'public, max-age=31536000, immutable',
});
return {
key: variantKey,
width: outputBuffer.info.width,
height: outputBuffer.info.height,
format: variant.format,
sizeBytes: outputBuffer.info.size,
};
}
private sanitizeExif(metadata: any): Record<string, unknown> {
// Mantieni solo dati non sensibili
return {
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
format: metadata.format,
};
}
}
Funkcjonalność w czasie rzeczywistym
Nowoczesne platformy nieruchomości wymagają wykraczających poza to możliwości działania w czasie rzeczywistym prosta aktualizacja strony. Użytkownicy tego oczekują powiadomienia push gdy opublikowana zostanie nieruchomość w ich obszarze zainteresowań, przesyłanie wiadomości natychmiastowy z agentami i alert cenowy kiedy nieruchomość zapisane ulegają zmianom.
System ostrzegania i powiadamiania
interface SavedSearch {
readonly userId: string;
readonly searchParams: PropertySearchParams;
readonly notifyVia: ReadonlyArray<'push' | 'email' | 'sms'>;
readonly frequency: 'instant' | 'daily' | 'weekly';
readonly createdAt: Date;
readonly lastNotifiedAt?: Date;
}
interface PriceAlert {
readonly userId: string;
readonly listingId: string;
readonly thresholdType: 'any_change' | 'decrease' | 'below_amount';
readonly thresholdAmount?: number;
readonly notifyVia: ReadonlyArray<'push' | 'email'>;
}
class PriceAlertService {
constructor(
private readonly alertRepo: PriceAlertRepository,
private readonly listingRepo: ListingRepository,
private readonly notifier: NotificationService,
private readonly searchService: PropertySearchService,
) {}
// Invocato dall'event bus quando un listing cambia prezzo
async onPriceChanged(event: ListingPriceChangedEvent): Promise<void> {
const { listingId, oldPrice, newPrice } = event;
// Trova tutti gli alert per questo listing
const alerts = await this.alertRepo.findByListingId(listingId);
const notifications = alerts
.filter(alert => this.shouldNotify(alert, oldPrice, newPrice))
.map(alert => ({
userId: alert.userId,
channels: alert.notifyVia,
template: 'price_change',
data: {
listingId,
oldPrice: oldPrice.amount,
newPrice: newPrice.amount,
changePercent: ((newPrice.amount - oldPrice.amount) / oldPrice.amount * 100).toFixed(1),
currency: newPrice.currency,
direction: newPrice.amount < oldPrice.amount ? 'decrease' : 'increase',
},
}));
await Promise.all(
notifications.map(n => this.notifier.send(n))
);
}
// Invocato su schedule per "nuovi annunci nelle ricerche salvate"
async processNewListingAlerts(): Promise<void> {
const searches = await this.getSearchesToProcess();
for (const savedSearch of searches) {
const results = await this.searchService.search({
...savedSearch.searchParams,
publishedAfter: savedSearch.lastNotifiedAt,
pageSize: 10,
});
if (results.total > 0) {
await this.notifier.send({
userId: savedSearch.userId,
channels: savedSearch.notifyVia,
template: 'new_listings',
data: {
count: results.total,
topListings: results.listings.slice(0, 3),
searchUrl: this.buildSearchUrl(savedSearch.searchParams),
},
});
await this.alertRepo.updateLastNotified(savedSearch.userId, new Date());
}
}
}
private shouldNotify(
alert: PriceAlert,
oldPrice: Money,
newPrice: Money
): boolean {
switch (alert.thresholdType) {
case 'any_change':
return oldPrice.amount !== newPrice.amount;
case 'decrease':
return newPrice.amount < oldPrice.amount;
case 'below_amount':
return newPrice.amount <= (alert.thresholdAmount ?? 0);
default:
return false;
}
}
private async getSearchesToProcess(): Promise<ReadonlyArray<SavedSearch>> {
// Recupera le ricerche salvate che necessitano di notifica
// basandosi sulla frequenza configurata
return this.alertRepo.findSearchesReadyForNotification();
}
private buildSearchUrl(params: PropertySearchParams): string {
const qs = new URLSearchParams();
if (params.listingType) qs.set('type', params.listingType);
if (params.priceMin) qs.set('price_min', String(params.priceMin));
if (params.priceMax) qs.set('price_max', String(params.priceMax));
return `/search?${qs.toString()}`;
}
}
Projekt API: punkty końcowe RESTful
Publiczny interfejs API platformy jest zgodny z zasadami REST z wersjonowaniem w ścieżce i paginacją
wyniki oparte na kursorze i obsługa wielu języków poprzez nagłówek Accept-Language.
Oto główne punkty końcowe zorganizowane według domeny.
| Metoda | Punkty końcowe | Opis | Autoryt |
|---|---|---|---|
GET |
/api/v1/listings |
Wyszukaj reklamy za pomocą filtrów | Publiczny |
GET |
/api/v1/listings/{id} |
Szczegóły pojedynczej reklamy | Publiczny |
POST |
/api/v1/listings |
Utwórz nową reklamę | Agent |
PATCH |
/api/v1/listings/{id} |
Aktualizuj ogłoszenie (cena, status, zdjęcie) | Agent (właściciel) |
POST |
/api/v1/search |
Zaawansowane wyszukiwanie za pomocą filtrów geograficznych | Publiczny |
POST |
/api/v1/search/map |
Klaster na widok mapy (agregacja H3) | Publiczny |
GET |
/api/v1/search/suggest |
Automatyczne uzupełnianie lokalizacji i dzielnic | Publiczny |
POST |
/api/v1/users/{id}/favorites |
Zapisz ogłoszenie w ulubionych | Użytkownik |
POST |
/api/v1/users/{id}/saved-searches |
Zapisz wyszukiwanie alertu | Użytkownik |
POST |
/api/v1/messages |
Wyślij wiadomość do agenta | Użytkownik |
GET |
/api/v1/agents/{id} |
Profil agenta z ocenami | Publiczny |
POST |
/api/v1/media/upload-url |
Wygeneruj wcześniej ustalony adres URL do przesłania | Agent |
GET |
/api/v1/valuations/estimate |
Szacunkowa cena rynkowa | Publiczny |
Paginacja oparta na kursorach
W przypadku dużych zbiorów danych paginacja oparta na przesunięciu (page/pageSize) ulega pogorszeniu szybko. Adoptujemy paginacja oparta na kursorze w przypadku interfejsów API o dużym natężeniu ruchu, gwarantując stałą wydajność niezależnie od głębokości strony.
{
"data": [
{
"id": "lst_abc123",
"title": "Trilocale luminoso zona Brera",
"price": { "amount": 385000, "currency": "EUR" },
"propertyType": "apartment",
"bedrooms": 2,
"sqMeters": 95,
"location": { "lat": 45.4726, "lng": 9.1860 },
"thumbnail": "https://cdn.platform.com/lst_abc123/thumb.webp"
}
],
"pagination": {
"cursor": "eyJzIjoiMjAyNi0wMy0wOFQxMDozMDowMFoiLCJpIjoibHN0X2FiYzEyMyJ9",
"hasMore": true,
"totalEstimate": 1247
},
"facets": {
"propertyTypes": [
{ "key": "apartment", "count": 832 },
{ "key": "house", "count": 215 }
],
"priceStats": { "min": 85000, "max": 2500000, "avg": 342000 }
}
}
Wydajność Scali
Platforma nieruchomości zawierająca miliony ofert musi radzić sobie ze znaczącymi skokami ruchu: liczba wyszukiwań wzrasta o 300–400% w niedzielne wieczory i w okresach zmiany sezonu. Strategie wydajność musi działać na każdym poziomie architektury.
Buforowanie wielopoziomowe
| Poziom | Technologia | TTL | Używać | Typowy współczynnik trafień |
|---|---|---|---|---|
| L1 – Krawędź/CDN | CloudFront / Szybko | 5-60 minut | Obrazy, CSS/JS, strony statyczne | 90-95% |
| L2 – Brama API | Pamięć podręczna lakieru/konga | 1-5 minut | GET odpowiedzi API (szczegóły aukcji, wyniki wyszukiwania) | 60-75% |
| L3 – Aplikacja | Klaster Redis | 30 s-15 min | Sesja, liczba aspektów, geokodowanie, agregacje H3 | 80-90% |
| L4 - Zapytania | Elasticsearch replikuje | Prawie w czasie rzeczywistym | Przeczytaj odpowiedzi na wyszukiwania, oddzielny indeks sugestii | Nie dotyczy (skalowanie odczytu) |
| L5 – Bazy danych | PostgreSQL + pgbouncer | Nie dotyczy | Pule połączeń, odczyt replik na raport | Nie dotyczy |
Fragmentowanie bazy danych według regionu
Z milionami nieruchomości rozmieszczonych geograficznie, z podziałem według regionu i strategii bardziej naturalne dla platform nieruchomości. Każdy fragment zawiera dane makroregionu, co pozwala na skalowanie w poziomie i jednocześnie zgodność z przepisami miejsce przechowywania danych (RODO).
- Klucz fragmentu: region geograficzny (np.
country:region=IT:lombardia) - Rozgromienie: interfejs API bramy określa fragment na podstawie lokalizacji zapytania
- Zapytania między fragmentami: zbieranie rozproszone w przypadku wyszukiwań w wielu regionach (rzadko)
- Przywracanie równowagi: gdy region przekroczy próg, ulega podziałowi (np. Mediolan staje się dedykowanym odłamkiem)
Wzór CQRS dla separacji odczytu/zapisu
Wzór CQRS (oddzielenie odpowiedzialności za zapytania dotyczące poleceń) i szczególnie skuteczny w przypadku platform nieruchomości, gdzie współczynnik odczytu/zapisu wynosi zwykle 100:1. The pisma święte (tworzenie ogłoszeń, aktualizacja cen) przechodzą przez Serwis Oferty z PostgreSQL jako źródłem prawdy. The odczyty (badania, szczegóły aukcji) były przydatne z Elasticsearch i Redis, aktualizowane asynchronicznie poprzez zdarzenia. To oddzielenie pozwala na niezależną optymalizację obu ścieżek.
Wielojęzyczny i wielowalutowy
Platformy działające na rynkach międzynarodowych (takie jak Idealista w Hiszpanii, Włoszech i Portugalii) muszą zarządzać treściami w wielu językach i cenami w różnych walutach. Strategia różni się w zależności od bazy do rodzaju treści.
Strategia dotycząca typów treści
- Statyczny interfejs użytkownika: pliki tłumaczeń (i18n) ładowane z frontendu, jeden język za pakiet (Angular i18n, React i18next)
-
Opisy reklam: pole
LocalizedTextw DB, tłumaczenie automatyczne za pośrednictwem Tłumacza DeepL/Google z flagą „przetłumaczone automatycznie”. - Adresy: nie przetłumaczone (pozostają w lokalnym języku), ale miasto może mają odmiany (Mediolan/Mediolan, Rzym/Rzym)
- Ceny: przechowywane w oryginalnej walucie, przeliczane na bieżąco w celu wyświetlenia z kursami aktualizowanymi przez API (EBC, Open Exchange Rates)
-
SEO: Zlokalizowane adresy URL (
/it/vendita/milanovs/en/sale/milan) z tagamihreflangaby uniknąć powielania treści
Zgodność: RODO i Fair Housing
Platformy nieruchomości przetwarzają bardzo wrażliwe dane: preferencje mieszkaniowe, możliwości finanse, historia badań. Zgodność nie jest opcją, jest wymogiem architektonicznym co wpływa na konstrukcję systemu na każdym poziomie.
RODO: Wpływ na architekturę
-
Minimalizacja danych: zbierać tylko te dane, które są absolutnie niezbędne.
Nie służcie polu
viewCountw odpowiedzi publicznej nie śledź lokalizacji GPS użytkownika bez wyraźnej zgody. -
Prawo do usunięcia: narzędzie
DELETE /api/v1/users/{id}/dataktóry usuwa profile, zapisane wyszukiwania, wiadomości i anonimizuje logi w ciągu 30 dni. -
Przenośność danych:
GET /api/v1/users/{id}/exportwygenerować archiwum JSON/CSV ze wszystkimi danymi użytkownika. - Zarządzanie zgodami: każdy moduł śledzący (GA4, mapa cieplna, remarketing) jest tylko aktywowany po wyraźnej zgodzie, ze szczegółowością według kategorii.
- Miejsce przechowywania danych: podział według regionu ułatwia zgodność z wymaganiami że dane użytkowników z UE pozostają w centrach danych UE.
Sprawiedliwe mieszkalnictwo i dyskryminacja
W USA Ustawa o godziwym mieszkalnictwie zabrania dyskryminacji w sprzedaży i wynajmie nieruchomości ze względu na rasę, kolor skóry, religię, płeć, niepełnosprawność, stan rodzinny lub pochodzenie narodowy. Implikacje techniczne:
- Filtry wyszukiwania: nigdy nie oferuj filtrów, które mogą działać jako serwer proxy cechy chronione (np. „skład demograficzny okolicy”)
- Kierowanie reklam: Nie należy używać sponsorowanego targetowania reklam chronione kryteria demograficzne (Facebook zapłacił za to 5 mln dolarów w 2022 r.)
- Modele ML: regularne audyty pod kątem stronniczości w modelach wyceny, np zalecenie. Model systematycznie zaniżający wartość nieruchomości w sąsiedztwie większości mniejszościowej i nie tylko niesprawiedliwe i nielegalne.
- Opisy reklam: automatyczny filtr języka dyskryminującego w opisach (np. „idealny dla par bez dzieci” narusza ustawę o godziwych warunkach mieszkaniowych)
Ostrzeżenie: stronniczość w modelach ratingowych
Modele ML wyceny nieruchomości (takie jak „Zestimate”) mogą wzmacniać błędy historyczne. Jeśli dane szkoleniowe odzwierciedlają dziesięciolecia dyskryminacji na rynku (zaznaczenie), to model odtworzy te nierówności. Jest to niezbędne do wdrożenia uczciwość audyty za pomocą wskaźników kapitałowych na podstawie danych demograficznych i ciągłego monitorowania prognoz według obszaru.
Wnioski i architektura referencyjna
Budowa platformy nieruchomości na dużą skalę i ćwiczenie projekt systemu który dotyka niemal każdego obszaru inżynierii oprogramowania: od złożonego modelowania domen po badania geoprzestrzenne, od zarządzania rurociągami multimedialnymi po przestrzeganie przepisów rygorystyczne.
Kluczowe zasady, które wyłoniły się z tej analizy, to:
- Rozkład domeny: wyraźnie oddzielne zestawienie, wyszukiwanie, media, Użytkownik i Powiadomienie w niezależnych serwisach z dedykowanymi bazami danych
- CQRS jako podstawowy wzór: ze stosunkiem odczytu/zapisu 100:1, oddziel ścieżki odczytu (Elasticsearch) i zapisu (PostgreSQL), a nie optymalizację przedwczesne i architektoniczną koniecznością
- Geospatial jako obywatel pierwszej klasy: H3 do agregacji, PostGIS do precyzja, Elasticsearch geo_point do wyszukiwania pełnego stosu
- Potoki asynchroniczne: pozyskiwanie reklam, przetwarzanie multimediów i powiadomienia muszą być sterowane zdarzeniami, aby obsługiwać skoki bez pogarszania komfortu użytkownika
- Zgodność według projektu: RODO i uczciwe warunki mieszkaniowe nie są elementami do dodania później, ale ograniczenia architektoniczne, które kierują wyborami od pierwszego dnia
W kolejnym artykule z tej serii szczegółowo omówimy modele ewaluacji nieruchomości w oparciu o uczenie maszynowe, analizując, jak Zillow, Redfin i Europejskie platformy szacują ceny rynkowe za pomocą automatycznych modeli wyceny (AVM). jakie wyzwania techniczne i etyczne pociąga za sobą ta zdolność.
Zasoby i spostrzeżenia
- ZWROT Web API: reso.org - Standard wymiany danych MLS
- H3: h3geo.org — sześciokątny system indeksowania danych geoprzestrzennych firmy Uber
- Elasticsearch Geoprzestrzenne: Elastic.co/docs — dokumentacja zapytań geograficznych
- PocztaGIS: postgis.net - Rozszerzenie geoprzestrzenne dla PostgreSQL
- RODO dla PropTech: Wytyczne EROD dotyczące profilowania i zautomatyzowanego podejmowania decyzji
- Ustawa o godziwych warunkach mieszkaniowych: hud.gov – amerykańskie prawo antydyskryminacyjne







