diff --git a/backend/src/services/ai-extractor.ts b/backend/src/services/ai-extractor.ts index 1b383b9..61dfcbb 100644 --- a/backend/src/services/ai-extractor.ts +++ b/backend/src/services/ai-extractor.ts @@ -19,28 +19,39 @@ export interface AIVerificationResult { confidence: number; suggestedPrice: ParsedPrice | null; reason: string; + stockStatus?: StockStatus; } -const VERIFICATION_PROMPT = `You are a price verification assistant. I scraped a product page and found a price. Please verify if this price is correct. +const VERIFICATION_PROMPT = `You are a price and availability verification assistant. I scraped a product page and found a price. Please verify if this price is correct AND if the product is currently available for purchase. Scraped Price: $SCRAPED_PRICE$ $CURRENCY$ Analyze the HTML content below and determine: 1. Is the scraped price the correct CURRENT/SALE price for the main product? 2. If not, what is the correct price? +3. Is this product currently available for purchase RIGHT NOW? -Common issues to watch for: +Common price issues to watch for: - Scraped price might be a "savings" amount (e.g., "Save $189.99") - Scraped price might be from a bundle/combo deal section - Scraped price might be shipping cost or add-on price - Scraped price might be the original/crossed-out price instead of the sale price +Common availability issues to watch for: +- Product shows "Coming Soon" or "Available [future date]" - NOT in stock +- Product shows "Pre-order" or "Reserve now" - NOT in stock +- Product shows "Notify me when available" or "Sign up for alerts" - NOT in stock +- Product shows "Out of stock" or "Sold out" - NOT in stock +- Product has no "Add to Cart" button but shows a future release date - NOT in stock +- Product CAN be added to cart and purchased today - IN stock + Return a JSON object with: - isCorrect: boolean - true if the scraped price is correct - confidence: number from 0 to 1 - suggestedPrice: the correct price as a number (or null if scraped price is correct) - suggestedCurrency: currency code if suggesting a different price -- reason: brief explanation of your decision +- stockStatus: "in_stock", "out_of_stock", or "unknown" - based on whether the product can be purchased RIGHT NOW +- reason: brief explanation of your decision (mention both price and availability) Only return valid JSON, no explanation text outside the JSON. @@ -314,6 +325,7 @@ function parseVerificationResponse( confidence: 0.5, suggestedPrice: null, reason: 'Could not parse AI response', + stockStatus: 'unknown', }; let jsonStr = responseText.trim(); @@ -348,11 +360,23 @@ function parseVerificationResponse( } } + // Parse stock status from AI response + let stockStatus: StockStatus = 'unknown'; + if (data.stockStatus) { + const status = data.stockStatus.toLowerCase().replace(/[^a-z_]/g, ''); + if (status === 'in_stock' || status === 'instock') { + stockStatus = 'in_stock'; + } else if (status === 'out_of_stock' || status === 'outofstock') { + stockStatus = 'out_of_stock'; + } + } + return { isCorrect: data.isCorrect ?? true, confidence: data.confidence ?? 0.5, suggestedPrice, reason: data.reason || 'No reason provided', + stockStatus, }; } catch (error) { console.error('[AI Verify] Failed to parse response:', responseText); diff --git a/backend/src/services/scraper.ts b/backend/src/services/scraper.ts index c1bf365..2de96d2 100644 --- a/backend/src/services/scraper.ts +++ b/backend/src/services/scraper.ts @@ -846,6 +846,15 @@ export async function scrapeProduct(url: string, userId?: number): Promise 0 || - $('button[id*="add-to-cart" i]').length > 0 || - $('[data-testid*="add-to-cart" i]').length > 0 || - $('button:contains("Add to Cart")').length > 0 || - $('input[value*="Add to Cart" i]').length > 0; + // Be conservative - only check main product area text, not entire body + // to avoid false positives from sidebar recommendations, etc. + const mainContent = $('main, [role="main"], #main, .main-content, .product-detail, .pdp-main').text().toLowerCase(); + const textToCheck = mainContent || $('body').text().toLowerCase().slice(0, 5000); - if (hasAddToCart) { + // Check for pre-order / coming soon indicators BEFORE checking add to cart + // Some sites show a "Pre-order" button that looks like add to cart + const preOrderComingSoonPhrases = [ + 'coming soon', + 'coming in', + 'available soon', + 'available in', + 'arriving soon', + 'arriving in', + 'releases on', + 'release date', + 'expected release', + 'launches on', + 'launching soon', + 'pre-order', + 'preorder', + 'pre order', + 'notify me when available', + 'notify when available', + 'sign up to be notified', + 'sign up for availability', + 'email me when available', + 'get notified when', + 'join the waitlist', + 'join waitlist', + 'not yet released', + 'not yet available', + 'coming this', + 'coming next', + 'available starting', + ]; + + for (const phrase of preOrderComingSoonPhrases) { + if (textToCheck.includes(phrase)) { + // Double check it's not just a section about pre-orders in general + // by looking for the phrase near price/product context + const phraseIndex = textToCheck.indexOf(phrase); + const contextStart = Math.max(0, phraseIndex - 200); + const contextEnd = Math.min(textToCheck.length, phraseIndex + 200); + const context = textToCheck.substring(contextStart, contextEnd); + + // If the context mentions price, buy, cart, or product, it's likely about this product + if (context.includes('$') || context.includes('price') || + context.includes('buy') || context.includes('cart') || + context.includes('order') || context.includes('purchase')) { + return 'out_of_stock'; + } + } + } + + // Check for explicit pre-order/coming soon elements + const hasPreOrderBadge = $('[class*="pre-order" i]').length > 0 || + $('[class*="preorder" i]').length > 0 || + $('[class*="coming-soon" i]').length > 0 || + $('[class*="comingsoon" i]').length > 0 || + $('[data-testid*="pre-order" i]').length > 0 || + $('[data-testid*="coming-soon" i]').length > 0 || + $('button:contains("Pre-order")').length > 0 || + $('button:contains("Preorder")').length > 0 || + $('button:contains("Notify Me")').length > 0; + + if (hasPreOrderBadge) { + return 'out_of_stock'; + } + + // Check for add to cart button - strong indicator of in stock + // But make sure it's not a pre-order button + const addToCartButtons = $('button[class*="add-to-cart" i], button[id*="add-to-cart" i], [data-testid*="add-to-cart" i], button:contains("Add to Cart"), input[value*="Add to Cart" i]'); + let hasRealAddToCart = false; + + addToCartButtons.each((_, el) => { + const buttonText = $(el).text().toLowerCase(); + const buttonClass = $(el).attr('class')?.toLowerCase() || ''; + // Make sure it's not a pre-order or notify button + if (!buttonText.includes('pre-order') && + !buttonText.includes('preorder') && + !buttonText.includes('notify') && + !buttonText.includes('waitlist') && + !buttonClass.includes('pre-order') && + !buttonClass.includes('preorder')) { + hasRealAddToCart = true; + } + }); + + if (hasRealAddToCart) { return 'in_stock'; } @@ -1088,11 +1180,6 @@ function extractGenericStockStatus($: CheerioAPI): StockStatus { return 'out_of_stock'; } - // Be conservative - only check main product area text, not entire body - // to avoid false positives from sidebar recommendations, etc. - const mainContent = $('main, [role="main"], #main, .main-content, .product-detail, .pdp-main').text().toLowerCase(); - const textToCheck = mainContent || $('body').text().toLowerCase().slice(0, 5000); - // Strong out-of-stock phrases (must be exact matches to avoid false positives) const strongOutOfStockPhrases = [ 'this item is out of stock',