Add real-time per-store search progress via SSE streaming

Backend: new /api/search/stream SSE endpoint that emits events
as each store completes: start (store list), store_complete
(results + duration), store_error, and done (final meta).

Frontend: results page now shows live progress per store with
spinning indicators while searching, checkmarks when done, and
X marks on errors. Each store chip shows product count and
response time. Results stream into the table as stores complete
instead of waiting for all stores to finish.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mariosemes
2026-03-26 22:15:50 +01:00
parent fe56c3b17e
commit 37425812e0
4 changed files with 322 additions and 83 deletions

View File

@@ -17,6 +17,18 @@ export interface SearchOptions {
groupId?: number;
}
export interface StoreProgress {
type: 'start' | 'store_complete' | 'store_error' | 'done';
storeId?: number;
storeName?: string;
results?: Product[];
resultCount?: number;
duration?: number;
error?: string;
stores?: Array<{ id: number; name: string; renderJs: boolean }>;
meta?: SearchResult['meta'];
}
export interface SearchResult {
results: Product[];
meta: {
@@ -122,3 +134,92 @@ export async function search(options: SearchOptions): Promise<SearchResult> {
},
};
}
function resolveStores(options: SearchOptions): Store[] {
if (options.storeIds?.length) return getStoresByIds(options.storeIds);
if (options.groupId) return getStoresByGroup(options.groupId);
if (options.categoryId) return getStoresByCategory(options.categoryId);
return getEnabledStores();
}
export async function searchStreaming(
options: SearchOptions,
onProgress: (event: StoreProgress) => void,
): Promise<void> {
const startTime = Date.now();
const { query } = options;
const stores = resolveStores(options);
if (stores.length === 0) {
onProgress({ type: 'done', meta: { query, duration: 0, storeCount: 0, totalResults: 0, errors: [] } });
return;
}
// Send start event with store list
onProgress({
type: 'start',
stores: stores.map((s) => ({ id: s.id, name: s.name, renderJs: !!s.render_js })),
});
const limit = pLimit(MAX_CONCURRENCY);
const errors: SearchResult['meta']['errors'] = [];
let totalResults = 0;
const scrapePromises = stores.map((store) =>
limit(async () => {
const searchUrl = store.search_url.replace('{query}', encodeURIComponent(query));
const storeStart = Date.now();
const rateLimiter = getLimiter(store.id, 1, Math.floor(store.rate_window / store.rate_limit));
try {
const scrapeFn = store.render_js
? () => scrapeStoreWithBrowser(store, searchUrl)
: () => scrapeStore(store, searchUrl);
const result = await rateLimiter.schedule(scrapeFn);
const duration = Date.now() - storeStart;
const products = result.items.map((item) =>
normalizeResult(item, store.id, store.name, store.base_url, store.currency)
);
logScrape(store.id, query, true, products.length, duration);
totalResults += products.length;
onProgress({
type: 'store_complete',
storeId: store.id,
storeName: store.name,
results: products,
resultCount: products.length,
duration,
});
} catch (err) {
const duration = Date.now() - storeStart;
const errorMessage = err instanceof Error ? err.message : String(err);
logScrape(store.id, query, false, 0, duration, errorMessage);
errors.push({ storeId: store.id, storeName: store.name, error: errorMessage });
onProgress({
type: 'store_error',
storeId: store.id,
storeName: store.name,
error: errorMessage,
duration,
});
}
})
);
await Promise.all(scrapePromises);
onProgress({
type: 'done',
meta: {
query,
duration: Date.now() - startTime,
storeCount: stores.length,
totalResults,
errors,
},
});
}