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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user