Files
Priceflow/app/services/scheduler_service.py
T

539 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import logging
import re
from datetime import UTC, datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app import database, models
from app.ai_schema import AIExtractionMetadata, AIExtractionResponse
from app.services.ai_service import AIService
from app.services.item_service import ItemService
from app.services.notification_service import NotificationService
from app.services.browserless_service import browserless_service
from app.services.tracking_scraper_service import ScraperService, ScrapeConfig
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
PRICE_CHANGE_THRESHOLD_PERCENT = 20.0
LOW_CONFIDENCE_THRESHOLD = 0.7
TITLE_SIMILARITY_THRESHOLD = 0.4 # Below this, consider product changed
def _normalize_title(title: str) -> str:
"""Normalize title for comparison: lowercase, remove common suffixes/prefixes, strip."""
if not title:
return ""
title = title.lower().strip()
# Remove common website suffixes/prefixes
patterns = [
r" - b&m$",
r" \| b&m$",
r" - action$",
r" \| action$",
r" - gifi$",
r" \| gifi$",
r" - amazon$",
r" \| amazon$",
r"^b&m stores : ",
r"^gifi : ",
r"^action : ",
]
for pattern in patterns:
title = re.sub(pattern, "", title)
# Remove extra spaces and punctuation that might differ between sites
title = re.sub(r"[^\w\s]", " ", title)
title = " ".join(title.split())
return title.strip()
def _is_generic_or_error_title(title: str) -> bool:
"""Check if the title is a generic error, bot detection, or placeholder."""
if not title:
return True
t = title.lower()
error_terms = [
"access denied",
"just a moment",
"cloudflare",
"attention required",
"erreur 403",
"403 forbidden",
"security check",
"robot or human",
"bot verification",
"loading",
"chargement",
"veuillez patienter",
"site non accessible",
"maintenance",
"un instant",
]
if any(term in t for term in error_terms):
return True
# Titles that are just the domain name are often placeholders or homepages
domain_titles = ["bmstores.fr", "gifi.fr", "action.com", "amazon.fr"]
if t.strip() in domain_titles:
return True
return False
def _titles_match(stored_name: str, page_title: str) -> bool:
"""
Check if the page title still matches the stored product name.
Returns True if they match (product still exists), False if different (product replaced).
"""
if not stored_name or not page_title:
return True # Can't compare, assume match
norm_stored = _normalize_title(stored_name)
norm_page = _normalize_title(page_title)
# If one is empty after normalization, assume match
if not norm_stored or not norm_page:
return True
# Check if stored name is contained in page title or vice versa
if norm_stored in norm_page or norm_page in norm_stored:
return True
# Check word overlap ratio (Jaccard similarity)
stored_words = set(norm_stored.split())
page_words = set(norm_page.split())
if not stored_words or not page_words:
return True
intersection = stored_words & page_words
union = stored_words | page_words
similarity = len(intersection) / len(union) if union else 0
return similarity >= TITLE_SIMILARITY_THRESHOLD
def _get_thresholds():
with database.SessionLocal() as session:
settings = {s.key: s.value for s in session.query(models.Settings).all()}
return {
"price": float(settings.get("confidence_threshold_price", "0.5")),
"stock": float(settings.get("confidence_threshold_stock", "0.5")),
}
def _update_db_result(
item_id: int,
extraction: AIExtractionResponse,
metadata: AIExtractionMetadata,
thresholds: dict,
screenshot_path: str,
):
with database.SessionLocal() as session:
item = session.query(models.Item).filter(models.Item.id == item_id).first()
if not item:
return None, None
old_price, old_stock = item.current_price, item.in_stock
price, in_stock = extraction.price, extraction.in_stock
p_conf, s_conf = extraction.price_confidence, extraction.in_stock_confidence
if price is not None:
if p_conf >= thresholds["price"]:
if (
old_price
and (abs(price - old_price) / old_price * 100 > PRICE_CHANGE_THRESHOLD_PERCENT)
and p_conf < LOW_CONFIDENCE_THRESHOLD
):
item.last_error = f"Uncertain: Large price change with low confidence ({p_conf:.2f})"
else:
item.last_error = None
item.current_price = price
item.current_price_confidence = p_conf
# Auto-reset availability if price was successfully extracted
if item.is_available is False:
item.is_available = True
item.last_error = None
logger.info(f"Item {item.id} marked as available again (price extracted)")
session.add(
models.PriceHistory(
item_id=item.id,
price=price,
screenshot_path=screenshot_path,
price_confidence=p_conf,
in_stock_confidence=s_conf,
ai_model=metadata.model_name,
ai_provider=metadata.provider,
prompt_version=metadata.prompt_version,
repair_used=metadata.repair_used,
)
)
if in_stock is not None and s_conf >= thresholds["stock"]:
item.in_stock = in_stock
item.in_stock_confidence = s_conf
item.last_checked = datetime.now(UTC)
item.is_refreshing = False
if item.last_error and not item.last_error.startswith("Uncertain:"):
item.last_error = None
session.commit()
return old_price, old_stock
def _update_db_error(item_id, error_msg):
with database.SessionLocal() as session:
if item := session.query(models.Item).filter(models.Item.id == item_id).first():
item.is_refreshing = False
item.last_error = error_msg
item.last_checked = datetime.now(UTC)
session.commit()
def _update_db_product_unavailable(item_id: int, error_msg: str, screenshot_path: str | None = None):
"""Mark item as unavailable (product no longer exists on retailer site)."""
with database.SessionLocal() as session:
if item := session.query(models.Item).filter(models.Item.id == item_id).first():
item.is_refreshing = False
item.is_available = False # Mark product as unavailable
item.in_stock = False # Also mark as out of stock
item.last_error = error_msg
item.last_checked = datetime.now(UTC)
# Record in history if we have a screenshot
if screenshot_path:
session.add(
models.PriceHistory(
item_id=item.id,
price=item.current_price or 0, # Keep last known price
screenshot_path=screenshot_path,
price_confidence=0.0, # Zero confidence = unavailable
in_stock_confidence=1.0, # High confidence it's unavailable
ai_model="unavailable-detector",
ai_provider="internal",
prompt_version="unavailable-v1",
repair_used=False,
)
)
session.commit()
async def process_item_check(item_id: int):
loop = asyncio.get_running_loop()
with database.SessionLocal() as session:
item_data, config = await loop.run_in_executor(None, ItemService.get_item_data_for_checking, session, item_id)
if not item_data:
logger.error(f"process_item_check: Item ID {item_id} not found")
return
try:
logger.info(f"Checking item: {item_data['name']} ({item_data['url']})")
# Use independent ScraperService for tracking
from app.services.tracking_scraper_service import ScraperService
screenshot_path, html_content, final_url, page_title = await ScraperService.scrape_item(
url=item_data["url"], selector=item_data["selector"], item_id=item_id, return_html=True
)
if not screenshot_path:
raise Exception("Failed to capture screenshot")
# Detect Amazon login wall/redirection/block
is_amazon = "amazon" in item_data["url"]
is_blocked = False
if is_amazon:
login_terms = ["signin", "captcha", "s'identifier", "log in", "login"]
title_lower = page_title.lower()
if (
any(term in final_url.lower() for term in login_terms)
or any(term in title_lower for term in login_terms)
or "amazon.fr: s'identifier" in title_lower
):
is_blocked = True
if is_blocked:
logger.warning(f"Amazon Bot Detection triggered for item {item_id}. Title: {page_title}, URL: {final_url}")
await loop.run_in_executor(None, _update_db_error, item_id, f"Amazon Bot Detection: {page_title}")
return
# Detect Action.com product unavailability
is_action = "action.com" in item_data["url"]
is_product_unavailable = False
if is_action:
unavailable_terms = [
"showproductwarning=true",
"produit indisponible",
"malheureusement, ce produit est actuellement indisponible",
]
final_url_lower = final_url.lower()
html_lower = html_content.lower() if html_content else ""
if any(term in final_url_lower for term in unavailable_terms) or any(
term in html_lower for term in unavailable_terms
):
is_product_unavailable = True
if is_product_unavailable:
logger.warning(f"Product unavailable detected for item {item_id} on Action.com")
await loop.run_in_executor(
None, _update_db_product_unavailable, item_id, "Produit indisponible sur Action.com", screenshot_path
)
# Send notification if configured
if item_data["notification_channel"]:
await NotificationService.send_notification(
item_data["notification_channel"],
title=f"⚠️ Produit indisponible : {item_data['name']}",
body=f"Le produit '{item_data['name']}' n'est plus disponible sur Action.com.\n{item_data['url']}",
)
return
# Detect product replacement by comparing page title with stored name
# If title has changed significantly, the original product was replaced
titles_match = _titles_match(item_data["name"], page_title)
is_generic_title = _is_generic_or_error_title(page_title)
if not titles_match:
# If titles don't match, check if it's because of a generic bot detection/blocker
# We only do this check if titles_match is False to avoid false positives on valid pages
bot_terms = [
"captcha",
"robot or human",
"bot verification",
"security check",
"access denied",
"cloudflare ray id",
]
html_lower = html_content.lower() if html_content else ""
if any(term in page_title.lower() for term in bot_terms) or any(term in html_lower for term in bot_terms):
logger.warning(f"Bot detection / blocker confirmed for item {item_id} (Title: {page_title})")
await loop.run_in_executor(
None, _update_db_error, item_id, f"Accès bloqué par le site (Bot Detection) : {page_title}"
)
return
# If it's a generic or error title, don't mark as unavailable, just mark as error
if is_generic_title:
logger.warning(f"Generic/Error title detected for item {item_id}: '{page_title}'")
await loop.run_in_executor(
None, _update_db_error, item_id, f"Site inaccessible ou titre générique : {page_title}"
)
return
# If it's a 404 indication in the title, it's a real unavailability
if "404" in page_title.lower() or "page non trouvée" in page_title.lower():
logger.info(f"404 detected in title for item {item_id}")
else:
logger.warning(
f"Product replacement detected for item {item_id}. "
f"Stored: '{item_data['name']}', Page title: '{page_title}'"
)
await loop.run_in_executor(
None,
_update_db_product_unavailable,
item_id,
f"Produit retiré ou remplacé (titre: {page_title[:50]}...)"
if len(page_title) > 50
else f"Produit retiré ou remplacé (titre: {page_title})",
screenshot_path,
)
# Send notification if configured
if item_data["notification_channel"]:
await NotificationService.send_notification(
item_data["notification_channel"],
title=f"⚠️ Produit remplacé : {item_data['name']}",
body=f"Le produit '{item_data['name']}' semble avoir été remplacé.\nNouveau titre: {page_title}\n{item_data['url']}",
)
return
# If titles match and product was previously marked unavailable, reset it
if titles_match:
def _reset_availability(item_id: int):
with database.SessionLocal() as session:
if item := session.query(models.Item).filter(models.Item.id == item_id).first():
if item.is_available is False:
item.is_available = True
item.last_error = None
session.commit()
logger.info(f"Item {item_id} marked as available again (title matches)")
await loop.run_in_executor(None, _reset_availability, item_id)
# HYBRID EXTRACTION STRATEGY (Aligned with ImprovedSearchService)
# 1. Try specialized parser (if available) or JSON-LD
price = None
in_stock = True
# Site-specific parser (Gifi)
from urllib.parse import urlparse
domain = urlparse(item_data["url"]).netloc
if "gifi.fr" in domain:
from app.services.parsers.gifi_parser import GifiParser
try:
parser = GifiParser()
details = parser.parse_product_details(html_content, item_data["url"])
if details.get("price") is not None:
price = details["price"]
in_stock = details.get("in_stock", True)
logger.info(f"GifiParser found price: {price}€")
except Exception as e:
logger.debug(f"GifiParser failed: {e}")
# Simple JSON-LD extract (if not found by specific parser)
if price is None:
import json
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_content, "html.parser")
scripts = soup.find_all("script", type="application/ld+json")
for script in scripts:
if script.string:
try:
data = json.loads(script.string)
if isinstance(data, list):
data = data[0]
if data.get("@type") == "Product" and "offers" in data:
offers = data["offers"]
if isinstance(offers, list) and offers:
offers = offers[0]
if "price" in offers:
price = float(str(offers["price"]).replace(",", "."))
break
except:
pass
except Exception as e:
logger.debug(f"JSON-LD extraction failed: {e}")
# 2. Try AIPriceExtractor (Text AI - Gemma 3) - Very reliable for search
# DISABLED FOR VISION PRIORITY (User Request: Option 2)
# from app.services.ai_price_extractor import AIPriceExtractor
# if price is None:
# price = await AIPriceExtractor.extract_price(html_content, item_data["name"])
# if price:
# logger.info(f"AIPriceExtractor (Text) found price: {price}€")
# 3. Fallback/Verification with AIService (Vision AI - Gemini)
extraction = None
metadata = None
if price is not None:
# Create a synthetic AI response if we already have a high-confidence price
extraction = AIExtractionResponse(
price=price, in_stock=in_stock, price_confidence=0.95, in_stock_confidence=0.9, source_type="text"
)
metadata = AIExtractionMetadata(
model_name="hybrid-text-parser",
provider="internal-hybrid",
prompt_version="hybrid-v2",
repair_used=False,
)
else:
# Fallback to Vision AI if text extraction failed
# Clean text properly before sending to AI
from app.utils.text import clean_text
cleaned_html = clean_text(html_content)
if not (ai_result := await AIService.analyze_image(screenshot_path, page_text=cleaned_html[:10000])):
raise Exception("AI analysis (Vision) failed")
extraction, metadata = ai_result
thresholds = await loop.run_in_executor(None, _get_thresholds)
old_price, old_stock = await loop.run_in_executor(
None, _update_db_result, item_id, extraction, metadata, thresholds, screenshot_path
)
# Check for notifications
if item_data["notification_channel"] and extraction.price:
channel = item_data["notification_channel"]
logger.info(
f"📢 Notification check for item {item_id}: old_price={old_price}, new_price={extraction.price}, target_price={item_data['target_price']}"
)
notification_sent = False
# CASE 1: Target price is set -> notify only when target is reached
if item_data["target_price"]:
if extraction.price <= item_data["target_price"]:
# Only notify if we haven't already notified for this price
if not old_price or old_price > item_data["target_price"]:
logger.info(
f"✅ Target price reached for {item_data['name']}: {extraction.price}€ <= {item_data['target_price']}€"
)
await NotificationService.send_notification(
channel,
title=f"🎯 Prix cible atteint : {item_data['name']}",
body=f"Le prix de {item_data['name']} est passé à {extraction.price}€ (Cible: {item_data['target_price']}€)\n{item_data['url']}",
)
notification_sent = True
else:
logger.info(f"ℹ️ Price {extraction.price}€ not yet at target {item_data['target_price']}€")
# CASE 2: No target price -> notify on ANY price drop
else:
if old_price and extraction.price < old_price:
drop_percent = (old_price - extraction.price) / old_price * 100
logger.info(f"📉 Price drop detected for {item_data['name']}: {drop_percent:.1f}%")
await NotificationService.send_notification(
channel,
title=f"📉 Baisse de prix : {item_data['name']}",
body=f"Le prix de {item_data['name']} a baissé de {drop_percent:.1f}% !\nNouveau prix : {extraction.price}€ (Ancien: {old_price}€)\n{item_data['url']}",
)
notification_sent = True
# Also notify on significant price increases (>= 5%)
elif old_price and extraction.price > old_price:
increase_percent = (extraction.price - old_price) / old_price * 100
if increase_percent >= 5:
logger.info(f"📈 Price increase detected for {item_data['name']}: {increase_percent:.1f}%")
await NotificationService.send_notification(
channel,
title=f"📈 Hausse de prix : {item_data['name']}",
body=f"Le prix de {item_data['name']} a augmenté de {increase_percent:.1f}%.\nNouveau prix : {extraction.price}€ (Ancien: {old_price}€)\n{item_data['url']}",
)
notification_sent = True
if not notification_sent:
logger.info(f"ℹ️ No notification triggered for item {item_id} (no significant change)")
else:
if not item_data["notification_channel"]:
logger.debug(f"⚠️ Item {item_id} has no notification channel assigned.")
if not extraction.price:
logger.debug(f"⚠️ Item {item_id} has no extracted price.")
except Exception as e:
logger.error(f"Error in process_item_check: {e}")
await loop.run_in_executor(None, _update_db_error, item_id, str(e))
async def scheduled_refresh():
logger.info("Heartbeat: Checking for items due for refresh")
loop = asyncio.get_running_loop()
try:
with database.SessionLocal() as session:
due_items = await loop.run_in_executor(None, ItemService.get_due_items, session)
for item_id, _, _ in due_items:
await process_item_check(item_id)
except Exception as e:
logger.error(f"Error in scheduled refresh: {e}", exc_info=True)