diff --git a/backend/src/index.ts b/backend/src/index.ts index a755533..f06f119 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -164,6 +164,10 @@ async function runMigrations() { IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'price_candidates') THEN ALTER TABLE products ADD COLUMN price_candidates JSONB; END IF; + -- Anchor price: the price the user confirmed, used to select correct variant on refresh + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'anchor_price') THEN + ALTER TABLE products ADD COLUMN anchor_price DECIMAL(10,2); + END IF; END $$; `); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index c8ca614..f8346b1 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -566,6 +566,21 @@ export const productQueries = { ); return result.rows[0]?.preferred_extraction_method || null; }, + + updateAnchorPrice: async (id: number, price: number): Promise => { + await pool.query( + 'UPDATE products SET anchor_price = $1 WHERE id = $2', + [price, id] + ); + }, + + getAnchorPrice: async (id: number): Promise => { + const result = await pool.query( + 'SELECT anchor_price FROM products WHERE id = $1', + [id] + ); + return result.rows[0]?.anchor_price ? parseFloat(result.rows[0].anchor_price) : null; + }, }; // Price History types and queries diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts index d91ff34..cf3803d 100644 --- a/backend/src/routes/products.ts +++ b/backend/src/routes/products.ts @@ -57,6 +57,9 @@ router.post('/', async (req: AuthRequest, res: Response) => { // Store the preferred extraction method and the user-selected price await productQueries.updateExtractionMethod(product.id, selectedMethod); + // Store the anchor price - used on refresh to select the correct variant + await productQueries.updateAnchorPrice(product.id, selectedPrice); + // Record the user-selected price await priceHistoryQueries.create( product.id, diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index d74a812..74e16bb 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -26,11 +26,15 @@ async function checkPrices(): Promise { // Get preferred extraction method for this product (if user previously selected one) const preferredMethod = await productQueries.getPreferredExtractionMethod(product.id); - // Use voting scraper with preferred method if available + // Get anchor price for variant products (the price the user confirmed) + const anchorPrice = await productQueries.getAnchorPrice(product.id); + + // Use voting scraper with preferred method and anchor price if available const scrapedData = await scrapeProductWithVoting( product.url, product.user_id, - preferredMethod as ExtractionMethod | undefined + preferredMethod as ExtractionMethod | undefined, + anchorPrice || undefined ); // Check for back-in-stock notification diff --git a/backend/src/services/scraper.ts b/backend/src/services/scraper.ts index 00e7d87..af68604 100644 --- a/backend/src/services/scraper.ts +++ b/backend/src/services/scraper.ts @@ -1291,11 +1291,15 @@ export async function scrapeProduct(url: string, userId?: number): Promise { const result: ScrapedProductWithCandidates = { name: null, @@ -1427,15 +1431,48 @@ export async function scrapeProductWithVoting( // If user has a preferred method, try to use it if (preferredMethod && allCandidates.length > 0) { - const preferredCandidate = allCandidates.find(c => c.method === preferredMethod); - if (preferredCandidate) { - console.log(`[Voting] Using preferred method ${preferredMethod}: ${preferredCandidate.price}`); - result.price = { price: preferredCandidate.price, currency: preferredCandidate.currency }; + const preferredCandidates = allCandidates.filter(c => c.method === preferredMethod); + if (preferredCandidates.length > 0) { + let selectedCandidate = preferredCandidates[0]; + + // If we have an anchor price and multiple candidates from preferred method, + // select the one closest to the anchor price (handles variant products) + if (anchorPrice && preferredCandidates.length > 1) { + selectedCandidate = preferredCandidates.reduce((closest, candidate) => { + const closestDiff = Math.abs(closest.price - anchorPrice); + const candidateDiff = Math.abs(candidate.price - anchorPrice); + return candidateDiff < closestDiff ? candidate : closest; + }); + console.log(`[Voting] Using anchor price ${anchorPrice} to select from ${preferredCandidates.length} candidates: ${selectedCandidate.price}`); + } + + console.log(`[Voting] Using preferred method ${preferredMethod}: ${selectedCandidate.price}`); + result.price = { price: selectedCandidate.price, currency: selectedCandidate.currency }; result.selectedMethod = preferredMethod; return result; } } + // If we have an anchor price but no preferred method, find closest candidate overall + if (anchorPrice && allCandidates.length > 0) { + const closestCandidate = allCandidates.reduce((closest, candidate) => { + const closestDiff = Math.abs(closest.price - anchorPrice); + const candidateDiff = Math.abs(candidate.price - anchorPrice); + return candidateDiff < closestDiff ? candidate : closest; + }); + + // Only use anchor matching if the difference is reasonable (within 20%) + const priceDiff = Math.abs(closestCandidate.price - anchorPrice) / anchorPrice; + if (priceDiff < 0.20) { + console.log(`[Voting] Using anchor price ${anchorPrice} to select: ${closestCandidate.price} (${(priceDiff * 100).toFixed(1)}% diff)`); + result.price = { price: closestCandidate.price, currency: closestCandidate.currency }; + result.selectedMethod = closestCandidate.method; + return result; + } else { + console.log(`[Voting] Anchor price ${anchorPrice} too different from candidates (closest: ${closestCandidate.price}, ${(priceDiff * 100).toFixed(1)}% diff), using consensus`); + } + } + // Find consensus const { price: consensusPrice, hasConsensus, groups } = findPriceConsensus(allCandidates); console.log(`[Voting] Consensus: ${hasConsensus}, Groups: ${groups.length}, Winner: ${consensusPrice?.price}`);