Merge pull request #263 from R0m1k3/antigravity

modf
This commit is contained in:
LogiFlow authored and GitHub committed 2026-01-08 10:42:40 +01:00
commit a1c0c9e58a
3 files changed
+128 -35

No files matched your search

+6
View File
@@ -19,6 +19,7 @@ class ItemResponse(ItemCreate):
id: int
current_price: float | None
in_stock: bool | None
is_available: bool | None = True # Product still exists on retailer site
current_price_confidence: float | None = None
in_stock_confidence: float | None = None
is_active: bool
@@ -45,6 +46,7 @@ class SettingsUpdate(BaseModel):
# === Search Sites Schemas ===
class SearchSiteCreate(BaseModel):
name: str
domain: str
@@ -93,6 +95,7 @@ class SearchSiteResponse(BaseModel):
# === Search Results Schemas ===
class SearchQuery(BaseModel):
query: str
site_ids: list[int] | None = None # If None, use all active sites
@@ -101,6 +104,7 @@ class SearchQuery(BaseModel):
class SearchResultItem(BaseModel):
"""Un produit trouvé lors de la recherche"""
url: str
title: str
price: float | None = None
@@ -115,6 +119,7 @@ class SearchResultItem(BaseModel):
class SearchProgress(BaseModel):
"""Progression de la recherche pour SSE"""
status: str # "searching", "scraping", "completed", "error"
total: int
completed: int
@@ -126,6 +131,7 @@ class SearchProgress(BaseModel):
# === Notification Schemas ===
class NotificationChannelCreate(BaseModel):
name: str
type: str # "email", "discord", "mattermost"
+101 -34
View File
@@ -93,6 +93,35 @@ def _update_db_error(item_id, error_msg):
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:
@@ -106,12 +135,9 @@ async def process_item_check(item_id: int):
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
url=item_data["url"], selector=item_data["selector"], item_id=item_id, return_html=True
)
if not screenshot_path:
@@ -120,30 +146,67 @@ async def process_item_check(item_id: int):
# 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:
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
# 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"])
@@ -157,22 +220,27 @@ async def process_item_check(item_id: int):
# 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 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 isinstance(offers, list) and offers:
offers = offers[0]
if "price" in offers:
price = float(str(offers["price"]).replace(',', '.'))
price = float(str(offers["price"]).replace(",", "."))
break
except: pass
except:
pass
except Exception as e:
logger.debug(f"JSON-LD extraction failed: {e}")
@@ -187,26 +255,23 @@ async def process_item_check(item_id: int):
# 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"
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
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")
@@ -220,25 +285,29 @@ async def process_item_check(item_id: int):
# 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']}")
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']}€")
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']}"
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:
@@ -247,10 +316,10 @@ async def process_item_check(item_id: int):
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']}"
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
@@ -259,10 +328,10 @@ async def process_item_check(item_id: int):
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']}"
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:
@@ -271,8 +340,6 @@ async def process_item_check(item_id: int):
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))
+21 -1
View File
@@ -24,8 +24,16 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom, onCategoryUp
const stockStatus = getStockStatus(item.in_stock);
// Detect if product is no longer available on retailer site
const isProductUnavailable = item.is_available === false;
return (
<Card className="group overflow-hidden transition-all duration-300 hover:shadow-md border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
<Card className={cn(
"group overflow-hidden transition-all duration-300 hover:shadow-md",
isProductUnavailable
? "border-orange-500 dark:border-orange-600 bg-orange-50/50 dark:bg-orange-950/20"
: "border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"
)}>
<div className="relative aspect-video overflow-hidden bg-zinc-100 dark:bg-zinc-950 cursor-zoom-in group/image" onClick={() => onZoom(item.screenshot_url)}>
{item.screenshot_url ? (
<>
@@ -61,6 +69,18 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom, onCategoryUp
<div className={cn("absolute right-3 top-3 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide shadow-sm backdrop-blur-md", stockStatus.color)}>
{stockStatus.label}
</div>
{/* Product Unavailable Overlay */}
{isProductUnavailable && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60">
<div className="flex flex-col items-center gap-2 text-center px-4">
<AlertTriangle className="h-8 w-8 text-orange-500" />
<span className="text-sm font-semibold text-white">
Produit retiré du site
</span>
</div>
</div>
)}
</div>
<CardContent className="p-5 relative">