Flatten repo structure: move crawler/ to root, remove vqa/ and immoweb/

The crawler subdirectory was the only active project. Moving it to the
repo root simplifies paths and removes the unnecessary nesting. The
vqa/ and immoweb/ directories were legacy/unused and have been removed.

Updated .drone.yml, .gitignore, .claude/ docs, and skills to reflect
the new flat structure.
This commit is contained in:
Viktor Barzin 2026-02-07 23:01:20 +00:00
parent e2247be700
commit eafbc1ac52
No known key found for this signature in database
GPG key ID: 0EB088298288D958
221 changed files with 70 additions and 146140 deletions

View file

@ -0,0 +1,61 @@
// Generic API client with authentication
import type { AuthUser } from '@/auth/types';
import { ApiError } from '@/types';
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
params?: Record<string, string | number | boolean | Date | undefined>;
}
/**
* Build query string from parameters object
*/
function buildQueryString(params: Record<string, string | number | boolean | Date | undefined>): string {
const queryString = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
if (value instanceof Date) {
queryString.append(key, value.toISOString());
} else {
queryString.append(key, String(value));
}
}
}
return queryString.toString();
}
/**
* Generic authenticated API request
*/
export async function apiRequest<T>(
user: AuthUser,
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const { method = 'GET', params } = options;
let url = endpoint;
if (params) {
const queryString = buildQueryString(params);
if (queryString) {
url = `${endpoint}?${queryString}`;
}
}
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${user.accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new ApiError(`Error: ${response.status}`, response.status);
}
return response.json() as Promise<T>;
}

View file

@ -0,0 +1,74 @@
// Health check service for backend connectivity
export type HealthStatus = 'checking' | 'healthy' | 'unhealthy';
export interface HealthCheckResult {
status: HealthStatus;
latencyMs?: number;
error?: string;
}
/**
* Check backend health by calling the /api/status endpoint
*/
export async function checkBackendHealth(): Promise<HealthCheckResult> {
const startTime = performance.now();
try {
const response = await fetch('/api/status', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Short timeout for health checks
signal: AbortSignal.timeout(5000),
});
const latencyMs = Math.round(performance.now() - startTime);
if (!response.ok) {
return {
status: 'unhealthy',
latencyMs,
error: `HTTP ${response.status}`,
};
}
const data = await response.json();
if (data.status === 'OK') {
return {
status: 'healthy',
latencyMs,
};
}
return {
status: 'unhealthy',
latencyMs,
error: 'Unexpected response',
};
} catch (error) {
const latencyMs = Math.round(performance.now() - startTime);
if (error instanceof Error) {
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
return {
status: 'unhealthy',
latencyMs,
error: 'Request timeout',
};
}
return {
status: 'unhealthy',
latencyMs,
error: error.message,
};
}
return {
status: 'unhealthy',
latencyMs,
error: 'Connection failed',
};
}
}

View file

@ -0,0 +1,6 @@
// Re-export all services
export { apiRequest } from './apiClient';
export { fetchListingGeoJSON, refreshListings } from './listingService';
export { streamListingGeoJSON, type StreamingProgress } from './streamingService';
export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService';
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';

View file

@ -0,0 +1,54 @@
// Listing service for fetching and refreshing listings
import type { AuthUser } from '@/auth/types';
import type { GeoJSONFeatureCollection, RefreshListingsResponse } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel';
import { apiRequest } from './apiClient';
import { API_ENDPOINTS } from '@/constants';
/**
* Build listing query parameters from form values
*/
function buildListingParams(parameters: ParameterValues): Record<string, string | number | boolean | Date | undefined> {
return {
listing_type: parameters.listing_type,
min_bedrooms: parameters.min_bedrooms,
max_bedrooms: parameters.max_bedrooms,
max_price: parameters.max_price,
min_price: parameters.min_price,
min_sqm: parameters.min_sqm,
max_sqm: parameters.max_sqm,
min_price_per_sqm: parameters.min_price_per_sqm,
max_price_per_sqm: parameters.max_price_per_sqm,
last_seen_days: parameters.last_seen_days,
let_date_available_from: parameters.available_from,
district_names: parameters.district || undefined,
furnish_types: parameters.furnish_types?.join(',') || undefined,
};
}
/**
* Fetch listing data as GeoJSON
*/
export async function fetchListingGeoJSON(
user: AuthUser,
parameters: ParameterValues
): Promise<GeoJSONFeatureCollection> {
return apiRequest<GeoJSONFeatureCollection>(user, API_ENDPOINTS.LISTING_GEOJSON, {
method: 'GET',
params: buildListingParams(parameters),
});
}
/**
* Trigger a listing refresh task
*/
export async function refreshListings(
user: AuthUser,
parameters: ParameterValues
): Promise<RefreshListingsResponse> {
return apiRequest<RefreshListingsResponse>(user, API_ENDPOINTS.REFRESH_LISTINGS, {
method: 'POST',
params: buildListingParams(parameters),
});
}

View file

@ -0,0 +1,137 @@
// Streaming service for progressive listing data loading
import type { AuthUser } from '@/auth/types';
import type { PropertyFeature } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel';
import { ApiError } from '@/types';
import { API_ENDPOINTS } from '@/constants';
/**
* Build query string from parameters object
*/
function buildQueryString(params: Record<string, string | number | boolean | Date | undefined>): string {
const queryString = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
if (value instanceof Date) {
queryString.append(key, value.toISOString());
} else {
queryString.append(key, String(value));
}
}
}
return queryString.toString();
}
/**
* Build listing query parameters from form values
*/
function buildListingParams(parameters: ParameterValues): Record<string, string | number | boolean | Date | undefined> {
return {
listing_type: parameters.listing_type,
min_bedrooms: parameters.min_bedrooms,
max_bedrooms: parameters.max_bedrooms,
max_price: parameters.max_price,
min_price: parameters.min_price,
min_sqm: parameters.min_sqm,
max_sqm: parameters.max_sqm,
min_price_per_sqm: parameters.min_price_per_sqm,
max_price_per_sqm: parameters.max_price_per_sqm,
last_seen_days: parameters.last_seen_days,
let_date_available_from: parameters.available_from,
district_names: parameters.district || undefined,
furnish_types: parameters.furnish_types?.join(',') || undefined,
};
}
export interface StreamMessage {
type: 'metadata' | 'batch' | 'complete';
features?: PropertyFeature[];
total?: number;
total_expected?: number;
batch_size?: number;
cached?: boolean;
}
export interface StreamingProgress {
count: number;
total?: number;
}
/**
* Stream listing GeoJSON data as an async generator.
* Yields batches of features as they arrive from the server.
*/
export async function* streamListingGeoJSON(
user: AuthUser,
parameters: ParameterValues,
onProgress?: (progress: StreamingProgress) => void
): AsyncGenerator<PropertyFeature[], void, unknown> {
const params = buildListingParams(parameters);
const queryString = buildQueryString(params);
const url = queryString
? `${API_ENDPOINTS.LISTING_GEOJSON_STREAM}?${queryString}`
: API_ENDPOINTS.LISTING_GEOJSON_STREAM;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${user.accessToken}`,
},
});
if (!response.ok) {
throw new ApiError(`Error: ${response.status}`, response.status);
}
if (!response.body) {
throw new Error('No response body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let totalCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const message: StreamMessage = JSON.parse(line);
if (message.type === 'metadata') {
onProgress?.({ count: 0, total: message.total_expected });
} else if (message.type === 'batch' && message.features) {
totalCount += message.features.length;
onProgress?.({ count: totalCount });
yield message.features;
} else if (message.type === 'complete') {
onProgress?.({ count: message.total ?? totalCount, total: message.total });
}
} catch (e) {
console.error('Failed to parse streaming message:', e);
}
}
}
// Process any remaining data in the buffer
if (buffer.trim()) {
try {
const message: StreamMessage = JSON.parse(buffer);
if (message.type === 'batch' && message.features) {
yield message.features;
}
} catch (e) {
console.error('Failed to parse final streaming message:', e);
}
}
}

View file

@ -0,0 +1,58 @@
// Task service for fetching task status
import type { AuthUser } from '@/auth/types';
import type { TaskStatusResponse } from '@/types';
import { apiRequest } from './apiClient';
import { API_ENDPOINTS } from '@/constants';
export interface CancelTaskResponse {
success: boolean;
message: string;
}
export interface ClearAllTasksResponse {
success: boolean;
count: number;
message: string;
}
/**
* Fetch all active tasks for the current user
*/
export async function fetchTasksForUser(user: AuthUser): Promise<string[]> {
return apiRequest<string[]>(user, API_ENDPOINTS.TASKS_FOR_USER);
}
/**
* Fetch the status of a specific task
*/
export async function fetchTaskStatus(
user: AuthUser,
taskId: string
): Promise<TaskStatusResponse> {
return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, {
params: { task_id: taskId },
});
}
/**
* Cancel a running task
*/
export async function cancelTask(
user: AuthUser,
taskId: string
): Promise<CancelTaskResponse> {
return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, {
method: 'POST',
params: { task_id: taskId },
});
}
/**
* Clear all tasks for the current user
*/
export async function clearAllTasks(user: AuthUser): Promise<ClearAllTasksResponse> {
return apiRequest<ClearAllTasksResponse>(user, API_ENDPOINTS.CLEAR_ALL_TASKS, {
method: 'POST',
});
}