mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-04-25 00:36:32 +02:00
Add anchor price support for variant products
When a user confirms a price from the modal, we now store it as an "anchor price". On subsequent refreshes, if multiple price candidates are found (common with variant products like different sizes/colors), we select the candidate closest to the anchor price. This fixes the issue where variant products would randomly switch to a different variant's price on refresh. Changes: - Add anchor_price column to products table - Save anchor price when user confirms a price selection - Use anchor price to select correct variant on refresh - 20% tolerance - if no candidate is within 20% of anchor, fall back to consensus Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bffc427fff
commit
389915a6ec
5 changed files with 70 additions and 7 deletions
|
|
@ -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 $$;
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -566,6 +566,21 @@ export const productQueries = {
|
|||
);
|
||||
return result.rows[0]?.preferred_extraction_method || null;
|
||||
},
|
||||
|
||||
updateAnchorPrice: async (id: number, price: number): Promise<void> => {
|
||||
await pool.query(
|
||||
'UPDATE products SET anchor_price = $1 WHERE id = $2',
|
||||
[price, id]
|
||||
);
|
||||
},
|
||||
|
||||
getAnchorPrice: async (id: number): Promise<number | null> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -26,11 +26,15 @@ async function checkPrices(): Promise<void> {
|
|||
// 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
|
||||
|
|
|
|||
|
|
@ -1291,11 +1291,15 @@ export async function scrapeProduct(url: string, userId?: number): Promise<Scrap
|
|||
/**
|
||||
* Multi-strategy voting scraper with user review support.
|
||||
* Runs all extraction methods, finds consensus, and flags ambiguous cases for user review.
|
||||
*
|
||||
* @param anchorPrice - The price the user previously confirmed. Used to select the correct
|
||||
* variant on refresh when multiple prices are found.
|
||||
*/
|
||||
export async function scrapeProductWithVoting(
|
||||
url: string,
|
||||
userId?: number,
|
||||
preferredMethod?: ExtractionMethod
|
||||
preferredMethod?: ExtractionMethod,
|
||||
anchorPrice?: number
|
||||
): Promise<ScrapedProductWithCandidates> {
|
||||
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}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue