Detect pre-order/coming soon products as out of stock

- AI verification now checks if product is purchasable RIGHT NOW
- AI returns stockStatus field for pre-order/coming soon detection
- Enhanced generic stock status detection with pre-order phrases:
  - Coming soon, available soon, releases on, pre-order, etc.
  - Notify me when available, join waitlist, etc.
- Pre-order buttons no longer count as Add to Cart
- Fixes issue where unreleased products showed as in stock

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-23 10:55:08 -05:00
parent 059336536f
commit 61ffafdd8c
2 changed files with 127 additions and 16 deletions

View file

@ -19,28 +19,39 @@ export interface AIVerificationResult {
confidence: number; confidence: number;
suggestedPrice: ParsedPrice | null; suggestedPrice: ParsedPrice | null;
reason: string; 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$ Scraped Price: $SCRAPED_PRICE$ $CURRENCY$
Analyze the HTML content below and determine: Analyze the HTML content below and determine:
1. Is the scraped price the correct CURRENT/SALE price for the main product? 1. Is the scraped price the correct CURRENT/SALE price for the main product?
2. If not, what is the correct price? 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 a "savings" amount (e.g., "Save $189.99")
- Scraped price might be from a bundle/combo deal section - Scraped price might be from a bundle/combo deal section
- Scraped price might be shipping cost or add-on price - Scraped price might be shipping cost or add-on price
- Scraped price might be the original/crossed-out price instead of the sale 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: Return a JSON object with:
- isCorrect: boolean - true if the scraped price is correct - isCorrect: boolean - true if the scraped price is correct
- confidence: number from 0 to 1 - confidence: number from 0 to 1
- suggestedPrice: the correct price as a number (or null if scraped price is correct) - suggestedPrice: the correct price as a number (or null if scraped price is correct)
- suggestedCurrency: currency code if suggesting a different price - 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. Only return valid JSON, no explanation text outside the JSON.
@ -314,6 +325,7 @@ function parseVerificationResponse(
confidence: 0.5, confidence: 0.5,
suggestedPrice: null, suggestedPrice: null,
reason: 'Could not parse AI response', reason: 'Could not parse AI response',
stockStatus: 'unknown',
}; };
let jsonStr = responseText.trim(); 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 { return {
isCorrect: data.isCorrect ?? true, isCorrect: data.isCorrect ?? true,
confidence: data.confidence ?? 0.5, confidence: data.confidence ?? 0.5,
suggestedPrice, suggestedPrice,
reason: data.reason || 'No reason provided', reason: data.reason || 'No reason provided',
stockStatus,
}; };
} catch (error) { } catch (error) {
console.error('[AI Verify] Failed to parse response:', responseText); console.error('[AI Verify] Failed to parse response:', responseText);

View file

@ -846,6 +846,15 @@ export async function scrapeProduct(url: string, userId?: number): Promise<Scrap
console.log(`[AI Verify] Price might be incorrect but no confident suggestion: ${verifyResult.reason}`); console.log(`[AI Verify] Price might be incorrect but no confident suggestion: ${verifyResult.reason}`);
// Don't set aiStatus if verification was inconclusive // Don't set aiStatus if verification was inconclusive
} }
// Use AI-detected stock status if we don't have a definitive one yet
// or if AI says it's out of stock (AI can catch pre-order/coming soon)
if (verifyResult.stockStatus && verifyResult.stockStatus !== 'unknown') {
if (result.stockStatus === 'unknown' || verifyResult.stockStatus === 'out_of_stock') {
console.log(`[AI Verify] Stock status: ${verifyResult.stockStatus} (was: ${result.stockStatus})`);
result.stockStatus = verifyResult.stockStatus;
}
}
} }
} catch (verifyError) { } catch (verifyError) {
console.error(`[AI Verify] Verification failed for ${url}:`, verifyError); console.error(`[AI Verify] Verification failed for ${url}:`, verifyError);
@ -1060,7 +1069,8 @@ function extractGenericStockStatus($: CheerioAPI): StockStatus {
const availability = $('[itemprop="availability"]').attr('content') || const availability = $('[itemprop="availability"]').attr('content') ||
$('[itemprop="availability"]').attr('href') || ''; $('[itemprop="availability"]').attr('href') || '';
if (availability.toLowerCase().includes('outofstock') || if (availability.toLowerCase().includes('outofstock') ||
availability.toLowerCase().includes('discontinued')) { availability.toLowerCase().includes('discontinued') ||
availability.toLowerCase().includes('preorder')) {
return 'out_of_stock'; return 'out_of_stock';
} }
if (availability.toLowerCase().includes('instock') || if (availability.toLowerCase().includes('instock') ||
@ -1068,14 +1078,96 @@ function extractGenericStockStatus($: CheerioAPI): StockStatus {
return 'in_stock'; return 'in_stock';
} }
// Check for add to cart button - strong indicator of in stock // Be conservative - only check main product area text, not entire body
const hasAddToCart = $('button[class*="add-to-cart" i]').length > 0 || // to avoid false positives from sidebar recommendations, etc.
$('button[id*="add-to-cart" i]').length > 0 || const mainContent = $('main, [role="main"], #main, .main-content, .product-detail, .pdp-main').text().toLowerCase();
$('[data-testid*="add-to-cart" i]').length > 0 || const textToCheck = mainContent || $('body').text().toLowerCase().slice(0, 5000);
$('button:contains("Add to Cart")').length > 0 ||
$('input[value*="Add to Cart" i]').length > 0;
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'; return 'in_stock';
} }
@ -1088,11 +1180,6 @@ function extractGenericStockStatus($: CheerioAPI): StockStatus {
return 'out_of_stock'; 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) // Strong out-of-stock phrases (must be exact matches to avoid false positives)
const strongOutOfStockPhrases = [ const strongOutOfStockPhrases = [
'this item is out of stock', 'this item is out of stock',