mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-15 10:52:36 +02:00
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:
parent
059336536f
commit
61ffafdd8c
2 changed files with 127 additions and 16 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue