mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-23 23:28:12 +02:00
Merge pull request #140 from R0m1k3/claude/add-product-monitoring-01DyNDGnKBX4fHNSjAkwVckd
Claude/add product monitoring 01 dy nd gn kbx4f hn sj akw vckd
This commit is contained in:
2 files changed
+77
-28
No files matched your search
+15
-13
@@ -134,26 +134,28 @@ EXTRACTION_PROMPT_TEMPLATE = """Extract product price and stock status from this
|
|||||||
|
|
||||||
**PRICE (IMPORTANT - French format):**
|
**PRICE (IMPORTANT - French format):**
|
||||||
- **FIRST**: Look for "PRIX DÉTECTÉ:" at the start of text - this is the extracted price
|
- **FIRST**: Look for "PRIX DÉTECTÉ:" at the start of text - this is the extracted price
|
||||||
- French prices use COMMA for decimals: "12,99 €" means 12.99
|
- **The price has already been converted to English format for you**
|
||||||
- Thousands separator is SPACE or DOT: "1 234,56 €" or "1.234,56 €" means 1234.56
|
- French original: "3,99 €" → Already shown to you as: "3.99 €"
|
||||||
|
- Just extract the number you see (e.g., "3.99" from "PRIX DÉTECTÉ: 3.99 €")
|
||||||
- Currency symbol is usually € at the end
|
- Currency symbol is usually € at the end
|
||||||
- Look for: "PRIX DÉTECTÉ:", price tags, "Prix:", "€", numbers near "Ajouter au panier"
|
- Look for: "PRIX DÉTECTÉ:", price tags, "Prix:", "€", numbers near "Ajouter au panier"
|
||||||
- Extract as DECIMAL NUMBER (convert comma to dot): 12,99 -> 12.99
|
- Extract as DECIMAL NUMBER: If you see "3.99", return 3.99
|
||||||
- Ignore crossed-out/barré prices (old prices)
|
- Ignore crossed-out/barré prices (old prices)
|
||||||
- If multiple prices, take the current/main price (not the original)
|
- If multiple prices, take the current/main price (not the original)
|
||||||
|
|
||||||
**CRITICAL - Read digits carefully:**
|
**CRITICAL - Common mistakes to avoid:**
|
||||||
- PAY CLOSE ATTENTION to first digit: "1,99 €" is NOT "9,99 €"
|
- "3.99 €" means 3.99 (NOT 399.00, NOT 3990.00)
|
||||||
- Double-check: Is it 1, 7, 8, or 9? These look similar
|
- "1.99 €" means 1.99 (NOT 199.00, NOT 1990.00)
|
||||||
- Small prices (< 5€) are common: 0,99 €, 1,49 €, 1,99 €, 2,99 €, 3,99 €, 4,99 €
|
- "0.99 €" means 0.99 (NOT 99.00, NOT 990.00)
|
||||||
- VERIFY the price makes sense for the product type
|
- The decimal point separates euros from cents
|
||||||
|
- Small prices (< 10€) are very common for everyday items
|
||||||
|
|
||||||
- Examples of valid prices:
|
- Examples of valid prices:
|
||||||
* "1,99 €" -> 1.99 (NOT 9.99)
|
* "PRIX DÉTECTÉ: 1.99 €" -> 1.99 (NOT 199 or 1990)
|
||||||
* "0,99 €" -> 0.99 (NOT 9.99)
|
* "PRIX DÉTECTÉ: 3.99 €" -> 3.99 (NOT 399 or 3990)
|
||||||
* "89,99 €" -> 89.99
|
* "0.99 €" -> 0.99 (NOT 99)
|
||||||
* "1 234,56 €" -> 1234.56
|
* "89.99 €" -> 89.99 (NOT 8999)
|
||||||
* "PRIX DÉTECTÉ: 89,99 €" -> 89.99
|
* "1234.56 €" -> 1234.56
|
||||||
- If you find ANY price with € symbol, extract it with confidence >= 0.8
|
- If you find ANY price with € symbol, extract it with confidence >= 0.8
|
||||||
- If digits are unclear or blurry, reduce confidence to 0.5-0.7
|
- If digits are unclear or blurry, reduce confidence to 0.5-0.7
|
||||||
- If unclear: set null and confidence < 0.5
|
- If unclear: set null and confidence < 0.5
|
||||||
|
|||||||
@@ -147,50 +147,76 @@ class BrowserlessService:
|
|||||||
Extract price from generic e-commerce pages using common selectors and patterns.
|
Extract price from generic e-commerce pages using common selectors and patterns.
|
||||||
Returns price text (e.g., "1,99 €") or empty string if not found.
|
Returns price text (e.g., "1,99 €") or empty string if not found.
|
||||||
"""
|
"""
|
||||||
# Common price selectors used across e-commerce sites
|
# Common price selectors used across e-commerce sites (ordered by priority)
|
||||||
price_selectors = [
|
price_selectors = [
|
||||||
# Common class names
|
# Highest priority: semantic and explicit current prices
|
||||||
"[class*='price']:not([class*='old']):not([class*='was']):not([class*='original'])",
|
"[itemprop='price']",
|
||||||
".product-price",
|
|
||||||
".price",
|
|
||||||
".current-price",
|
".current-price",
|
||||||
".sale-price",
|
".sale-price",
|
||||||
".final-price",
|
".final-price",
|
||||||
# Common data attributes
|
".product-price",
|
||||||
"[data-price]",
|
"[data-price]",
|
||||||
"[itemprop='price']",
|
# Medium priority: generic price classes (exclude old/was/original)
|
||||||
|
"[class*='price']:not([class*='old']):not([class*='was']):not([class*='original']):not([class*='before']):not([class*='regular'])",
|
||||||
# ID-based
|
# ID-based
|
||||||
"#price",
|
"#price",
|
||||||
"#product-price",
|
"#product-price",
|
||||||
"#our-price",
|
"#our-price",
|
||||||
# Specific patterns
|
# French-specific
|
||||||
"span[class*='prix']",
|
"span[class*='prix']:not([class*='ancien']):not([class*='barre'])",
|
||||||
"div[class*='prix']",
|
"div[class*='prix']:not([class*='ancien'])",
|
||||||
"span[class*='tarif']",
|
"span[class*='tarif']",
|
||||||
|
# Lower priority: generic .price (might catch old prices)
|
||||||
|
".price",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
found_prices = []
|
||||||
|
|
||||||
for selector in price_selectors:
|
for selector in price_selectors:
|
||||||
try:
|
try:
|
||||||
elements = page.locator(selector)
|
elements = page.locator(selector)
|
||||||
count = await elements.count()
|
count = await elements.count()
|
||||||
|
|
||||||
# Try first visible element
|
# Check up to 3 elements per selector
|
||||||
for i in range(min(count, 3)): # Check max 3 elements
|
for i in range(min(count, 3)):
|
||||||
try:
|
try:
|
||||||
element = elements.nth(i)
|
element = elements.nth(i)
|
||||||
if await element.is_visible(timeout=1000):
|
if await element.is_visible(timeout=1000):
|
||||||
|
# Check if element is strikethrough (old price)
|
||||||
|
try:
|
||||||
|
text_decoration = await element.evaluate("el => window.getComputedStyle(el).textDecoration")
|
||||||
|
if "line-through" in text_decoration:
|
||||||
|
logger.debug(f"Skipping strikethrough price at {selector}")
|
||||||
|
continue # Skip crossed-out prices
|
||||||
|
except Exception:
|
||||||
|
pass # If we can't check, continue anyway
|
||||||
|
|
||||||
price_text = await element.inner_text()
|
price_text = await element.inner_text()
|
||||||
# Check if it looks like a price (contains € or digits with comma/dot)
|
# Check if it looks like a price (contains € or digits with comma/dot)
|
||||||
if price_text and ('€' in price_text or (',' in price_text and any(c.isdigit() for c in price_text))):
|
if price_text and ('€' in price_text or (',' in price_text and any(c.isdigit() for c in price_text))):
|
||||||
# Clean up price text
|
# Clean up price text
|
||||||
price_text = price_text.strip()
|
price_text = price_text.strip()
|
||||||
|
found_prices.append((selector, price_text))
|
||||||
logger.info(f"Found price via selector {selector}: {price_text}")
|
logger.info(f"Found price via selector {selector}: {price_text}")
|
||||||
return price_text
|
|
||||||
|
# Return immediately if we found with high-priority selector
|
||||||
|
if selector in ["[itemprop='price']", ".current-price", ".sale-price", ".final-price", ".product-price"]:
|
||||||
|
return price_text
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# If we found prices with lower-priority selectors
|
||||||
|
if found_prices:
|
||||||
|
# When multiple prices found, the LAST one in DOM order is usually the current price
|
||||||
|
# (websites typically show: old price first, then current price)
|
||||||
|
best_price = found_prices[-1][1] # Take last (most recently added)
|
||||||
|
if len(found_prices) > 1:
|
||||||
|
prices_list = [p[1] for p in found_prices]
|
||||||
|
logger.info(f"Multiple prices found: {prices_list}, selecting last (most likely current): {best_price}")
|
||||||
|
return best_price
|
||||||
|
|
||||||
# Fallback: Use regex to find price patterns in all text
|
# Fallback: Use regex to find price patterns in all text
|
||||||
try:
|
try:
|
||||||
all_text = await page.inner_text('body')
|
all_text = await page.inner_text('body')
|
||||||
@@ -376,6 +402,17 @@ class BrowserlessService:
|
|||||||
try:
|
try:
|
||||||
content = await page.inner_text('body')
|
content = await page.inner_text('body')
|
||||||
logger.info(f"Extracted {len(content)} chars of visible text from page")
|
logger.info(f"Extracted {len(content)} chars of visible text from page")
|
||||||
|
|
||||||
|
# Normalize French prices to English format for AI
|
||||||
|
import re
|
||||||
|
# Pattern 1: digits€XX → digits.XX € (e.g., "3€99" → "3.99 €")
|
||||||
|
content = re.sub(r'(\d+)€(\d{2})\b', r'\1.\2 €', content)
|
||||||
|
# Pattern 2: digits,XX € → digits.XX € (e.g., "3,99 €" → "3.99 €")
|
||||||
|
content = re.sub(r'(\d+),(\d{2})\s*€', r'\1.\2 €', content)
|
||||||
|
# Remove spaces in thousands: 1 234.56 → 1234.56
|
||||||
|
content = re.sub(r'(\d+)\s(\d{3})', r'\1\2', content)
|
||||||
|
logger.debug("Normalized French price formats to English")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback to HTML content if inner_text fails
|
# Fallback to HTML content if inner_text fails
|
||||||
logger.warning(f"Failed to extract inner_text, falling back to HTML: {e}")
|
logger.warning(f"Failed to extract inner_text, falling back to HTML: {e}")
|
||||||
@@ -397,8 +434,18 @@ class BrowserlessService:
|
|||||||
|
|
||||||
# Prepend extracted price to content if found
|
# Prepend extracted price to content if found
|
||||||
if extracted_price:
|
if extracted_price:
|
||||||
content = f"PRIX DÉTECTÉ: {extracted_price}\n\n{content}"
|
# Convert French format to English for AI
|
||||||
logger.info(f"Prepended price to content: {extracted_price}")
|
import re
|
||||||
|
normalized_price = extracted_price
|
||||||
|
# Pattern 1: digits€digits → digits.digits € (e.g., "3€99" → "3.99 €")
|
||||||
|
normalized_price = re.sub(r'(\d+)€(\d{2})', r'\1.\2 €', normalized_price)
|
||||||
|
# Pattern 2: digits,digits → digits.digits (e.g., "3,99" → "3.99")
|
||||||
|
normalized_price = re.sub(r'(\d+),(\d{2})', r'\1.\2', normalized_price)
|
||||||
|
# Remove spaces in thousands separators if any (1 234,56 → 1234.56)
|
||||||
|
normalized_price = normalized_price.replace(' ', '')
|
||||||
|
|
||||||
|
content = f"PRIX DÉTECTÉ: {normalized_price}\n\n{content}"
|
||||||
|
logger.info(f"Prepended normalized price to content: {normalized_price} (original: {extracted_price})")
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not extract price from DOM, relying on text/image only")
|
logger.warning("Could not extract price from DOM, relying on text/image only")
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in new issue
Block a user