feat: Add initial database migration and UI label component, alongside frontend dependency installation.

This commit is contained in:
Michael committed 2025-11-23 14:37:35 +01:00
1 parent 5884aa2378
commit 8d124bf0f8
17 files changed
+859 -40

No files matched your search

@@ -0,0 +1,26 @@
"""add_category_to_items
Revision ID: add_category_field
Revises: add_is_available
Create Date: 2025-11-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_category_field'
down_revision = 'add_is_available'
branch_labels = None
depends_on = None
def upgrade():
# Add category column to items table
op.add_column('items', sa.Column('category', sa.String(255), nullable=True))
def downgrade():
# Remove category column from items table
op.drop_column('items', 'category')
+26
View File
@@ -0,0 +1,26 @@
"""add_is_available_to_items
Revision ID: add_is_available
Revises:
Create Date: 2025-11-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_is_available'
down_revision = None # Update this if there are previous migrations
branch_labels = None
depends_on = None
def upgrade():
# Add is_available column to items table
op.add_column('items', sa.Column('is_available', sa.Boolean(), nullable=True, server_default='true'))
def downgrade():
# Remove is_available column from items table
op.drop_column('items', 'is_available')
+2
View File
@@ -47,6 +47,8 @@ class Item(Base):
# New fields
current_price: float | None = Column(Float, nullable=True) # type: ignore
in_stock: bool | None = Column(Boolean, nullable=True) # type: ignore
is_available: bool | None = Column(Boolean, nullable=True, default=True) # type: ignore # Product still exists
category: str | None = Column(String, nullable=True) # type: ignore # User-defined category
tags: str | None = Column(String, nullable=True) # type: ignore
description: str | None = Column(String, nullable=True) # type: ignore
+27 -3
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app import database, schemas
from app import database, models, schemas
from app.limiter import limiter
from app.services.item_service import ItemService
from app.services.scheduler_service import process_item_check
@@ -10,8 +10,12 @@ router = APIRouter(prefix="/items", tags=["items"])
@router.get("", response_model=list[schemas.ItemResponse])
def get_items(db: Session = Depends(database.get_db)):
return ItemService.get_items(db)
def get_items(category: str | None = None, db: Session = Depends(database.get_db)):
"""Get all items, optionally filtered by category"""
items = ItemService.get_items(db)
if category:
items = [item for item in items if item.category == category]
return items
@router.post("", response_model=schemas.ItemResponse)
@@ -29,6 +33,26 @@ def delete_item(item_id: int, db: Session = Depends(database.get_db)):
return ItemService.delete_item(db, item_id)
@router.patch("/{item_id}/category")
def update_item_category(item_id: int, category: str | None, db: Session = Depends(database.get_db)):
"""Update the category of an item"""
item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
item.category = category
db.commit()
db.refresh(item)
return {"message": "Category updated", "category": category}
@router.get("/categories/list")
def get_categories(db: Session = Depends(database.get_db)):
"""Get list of distinct categories used in items"""
categories = db.query(models.Item.category).distinct().filter(models.Item.category.isnot(None)).all()
return [cat[0] for cat in categories if cat[0]]
@router.post("/{item_id}/check")
@limiter.limit("10/minute")
def check_item(
+55 -17
View File
@@ -70,8 +70,10 @@ DEFAULT_SITE_CONFIGS = {
"name": "B&M",
"search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}",
"product_selector": (
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
".products article a, a[href*='/produits/']"
# Cibler uniquement les liens produits, pas les catégories
"a.product-thumbnail[href*='/produits/']:not([href*='/c/']):not([href*='/categorie']), "
".product-miniature a[href*='/produits/']:not([href*='/c/']), "
".js-product-miniature a[href*='/produits/']"
),
"wait_selector": ".products, .product-miniature, #js-product-list, #search_results",
"category": "Discount",
@@ -190,10 +192,12 @@ DEFAULT_SITE_CONFIGS = {
"name": "Cdiscount",
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
"product_selector": (
"a.prdtBImg, .prdtBILDetails a, .product-item a, "
"a[href*='/f-']"
# Sélecteurs spécifiques Cdiscount - produits uniquement
"a.prdtBImg[href*='/f-'][href*='.html'], "
".prdtBILDetails a[href*='/f-'][href*='.html'], "
"a[href*='/f-'][href*='.html']:not([href*='/l-']):not([href*='/rayon'])"
),
"wait_selector": ".prdtBILDetails, .product-list",
"wait_selector": ".prdtBILDetails, .product-list, #lpBloc",
"category": "E-commerce",
"requires_js": True,
"priority": 12,
@@ -203,10 +207,12 @@ DEFAULT_SITE_CONFIGS = {
"name": "Darty",
"search_url": "https://www.darty.com/nav/recherche?text={query}",
"product_selector": (
".product-card a, a[href*='/product'], "
".product-list-item a"
# Cibler les fiches produits, exclure navigation et catégories
"a[href*='/nav/achat/'][href*='.html']:not([href*='/rayon']):not([href*='/promo']), "
".product-card a[href*='/nav/achat/'], "
"a.product-link[href*='/nav/achat/']"
),
"wait_selector": ".product-card, .product-list",
"wait_selector": ".product-card, .product-list, .product-item",
"category": "Électronique",
"requires_js": True,
"priority": 13,
@@ -215,10 +221,12 @@ DEFAULT_SITE_CONFIGS = {
"name": "Boulanger",
"search_url": "https://www.boulanger.com/resultats?tr={query}",
"product_selector": (
".product-list__item a, a[href*='/ref/'], "
".product-card a"
# Cibler uniquement les pages produits avec /ref/
"a[href*='/ref/'][href*='_']:not([href*='/c/']):not([href*='/rayon']), "
".product-list__item a[href*='/ref/'], "
".product-card__link[href*='/ref/']"
),
"wait_selector": ".product-list, .product-card",
"wait_selector": ".product-list, .product-card, .products-list",
"category": "Électronique",
"requires_js": True,
"priority": 14,
@@ -241,8 +249,13 @@ DEFAULT_SITE_CONFIGS = {
"fnac.com": {
"name": "Fnac",
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
"product_selector": "a.Article-title, .Article-item a.js-minifa-title",
"wait_selector": ".Article-item",
"product_selector": (
# Sélecteurs Fnac pour les produits
"a.Article-title[href*='/a'], "
".Article-item a.js-minifa-title[href*='/a'], "
"a[href*='/a'][href*='.html']:not([href*='/rayon']):not([href*='/univers'])"
),
"wait_selector": ".Article-item, .Article-list, #SearchResultList",
"category": "Culture & Tech",
"requires_js": True,
"priority": 15,
@@ -721,8 +734,9 @@ def _is_navigation_link(href: str) -> bool:
"""Vérifie si un lien est un lien de navigation (pas un produit)"""
nav_patterns = [
# Catégories et navigation
"/category", "/categories", "/categorie",
"/brand", "/brands", "/marque",
"/category", "/categories", "/categorie", "/c/",
"/rayon", "/rayons", "/univers",
"/brand", "/brands", "/marque", "/marques",
"/page/", "/pages/",
"/cart", "/panier", "/basket",
"/checkout", "/commande",
@@ -740,13 +754,24 @@ def _is_navigation_link(href: str) -> bool:
"/application", "/app/", "/apps/",
"/courses", "/course/",
"/ep-", "/endpoint",
# Promotions et offres (patterns communs)
"/promo/", "/promotions/", "/promotion/",
"/offres/", "/offre/",
"/soldes/", "/solde/",
"/bon-plan", "/bons-plans",
"/offres/", "/offre/", "/offre-",
"/deals/", "/deal/",
"/ventes-flash", "/vente-flash",
# Services et pages informatives
"/services/", "/service/",
"/magasin", "/magasins", "/stores/", "/store/",
"/blog", "/actualites", "/news",
"/recettes", "/recipes",
"/conseils", "/tips",
"/guide", "/guides",
# Patterns e-commerce spécifiques
"/l-", "/lp-", "/landing", # Landing pages (Cdiscount, etc.)
"/selection", "/selections",
"/top-", "/best-",
# Amazon spécifique
"/ref=cs_", "/logo", "/nav_", "/gp/help",
"/gp/css", "/gp/redirect", "/hz/",
@@ -763,6 +788,7 @@ def _is_navigation_link(href: str) -> bool:
return any(pattern in href_lower for pattern in nav_patterns)
def _extract_title(element) -> str:
"""Extrait le titre d'un élément"""
# Essayer différentes méthodes
@@ -855,16 +881,28 @@ def _is_non_product_url(url: str) -> bool:
return True
non_product_patterns = [
"/search", "/category", "/categories", "/brand", "/brands",
# Navigation et catégories
"/search", "/category", "/categories", "/categorie", "/c/",
"/rayon", "/rayons", "/univers",
"/brand", "/brands", "/marque", "/marques",
# Pages utilisateur
"/help", "/contact", "/about", "/account", "/cart", "/checkout",
"/login", "/register", "/wishlist", "/compare", "/reviews",
# Pages légales
"/terms", "/privacy", "/faq", "/shipping", "/returns",
# Erreurs
"/503", "/error", "/robot", "/captcha",
# Applications et services
"/application", "/courses", "/app/",
# Landing pages et promotions (patterns spécifiques e-commerce français)
"/l-", "/lp-", "/landing", # Cdiscount landing pages
"/promo/", "/soldes/", "/bon-plan", "/offre-", "/deals/",
"/selection", "/top-", "/best-",
]
return any(pattern in url_lower for pattern in non_product_patterns)
async def health_check() -> bool:
"""Vérifie la connectivité avec Browserless"""
try:
+10 -1
View File
@@ -103,7 +103,7 @@ async def process_item_check(item_id: int):
try:
logger.info(f"Checking item: {item_data['name']} ({item_data['url']})")
screenshot_path, page_text = await ScraperService.scrape_item(
screenshot_path, page_text, is_available = await ScraperService.scrape_item(
item_data["url"],
item_data["selector"],
item_id,
@@ -113,6 +113,15 @@ async def process_item_check(item_id: int):
timeout=config["scraper_timeout"],
)
# Update availability status in database
with database.SessionLocal() as session:
if item := session.query(models.Item).filter(models.Item.id == item_id).first():
item.is_available = is_available
if not is_available:
logger.warning(f"Product {item_id} marked as unavailable")
item.last_error = "Product no longer available (404 or not found)"
session.commit()
if not screenshot_path:
raise Exception("Failed to capture screenshot")
+52 -8
View File
@@ -88,10 +88,10 @@ class ScraperService:
scroll_pixels: int = 350,
text_length: int = 0,
timeout: int = 90000,
) -> tuple[str | None, str]:
) -> tuple[str | None, str, bool]:
"""
Scrapes the given URL using Browserless and Playwright.
Returns a tuple: (screenshot_path, page_text)
Returns a tuple: (screenshot_path, page_text, is_available)
Args:
url: Target URL to scrape
@@ -139,7 +139,7 @@ class ScraperService:
logger.warning(f"Tentative {attempt + 1} échouée pour {url}")
logger.error(f"Toutes les tentatives ont échoué pour {url}")
return None, ""
return None, "", False # Product unavailable if all retries failed
@staticmethod
async def _do_scrape( # noqa: PLR0913, PLR0912, PLR0915
@@ -150,12 +150,14 @@ class ScraperService:
scroll_pixels: int = 350,
text_length: int = 0,
timeout: int = 90000,
) -> tuple[str | None, str]:
) -> tuple[str | None, str, bool]:
"""Exécute le scraping réel (appelé par scrape_item avec retries)."""
async with async_playwright() as p:
browser = None
context = None
page = None
is_available = True # Par défaut, le produit est disponible
try:
logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}")
@@ -188,9 +190,24 @@ class ScraperService:
page = await context.new_page()
logger.info(f"Navigating to {url} (Timeout: {timeout}ms)")
response = None
try:
# First wait for domcontentloaded - this is the minimum we need
await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
response = await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
# Check HTTP status code
if response:
status = response.status
logger.info(f"HTTP Status: {status}")
# Detect unavailable products by HTTP status
if status in [404, 410, 451, 503]:
logger.warning(f"Product unavailable - HTTP {status}")
is_available = False
elif status >= 400:
logger.warning(f"HTTP error {status}, marking as potentially unavailable")
is_available = False
logger.info(f"Page loaded (domcontentloaded): {url}")
# Then try to wait for networkidle, but don't fail if it times out
@@ -203,6 +220,8 @@ class ScraperService:
except Exception as e:
logger.error(f"Error navigating to {url}: {e}")
# If navigation fails completely, mark as unavailable
is_available = False
# Try to take screenshot anyway if page partially loaded
pass
@@ -347,7 +366,7 @@ class ScraperService:
logger.info(f"Screenshot saved to {filename}")
except Exception as e:
logger.error(f"Screenshot failed: {e}")
return None, ""
return None, "", is_available
# Text Extraction (après le screenshot pour ne pas le perdre)
page_text = ""
@@ -359,15 +378,40 @@ class ScraperService:
# Simple truncation
page_text = raw_text[:text_length]
logger.info(f"Extracted {len(page_text)} characters")
# Check for "product not found" messages in page text
unavailable_patterns = [
"produit introuvable",
"produit indisponible",
"page introuvable",
"404",
"product not found",
"item not found",
"page not found",
"n'existe plus",
"plus disponible",
"no longer available",
"article introuvable",
"cette page n'existe pas",
"this page doesn't exist",
]
page_text_lower = page_text.lower()
for pattern in unavailable_patterns:
if pattern in page_text_lower:
logger.warning(f"Product unavailable - found pattern: '{pattern}'")
is_available = False
break
except Exception as e:
logger.warning(f"Text extraction failed (screenshot saved): {e}")
# On continue car le screenshot a été pris
return filename, page_text
return filename, page_text, is_available
except Exception as e:
logger.error(f"Error scraping {url}: {e}")
return None, ""
return None, "", False # Mark as unavailable on error
finally:
# Fermeture propre des ressources dans l'ordre inverse
+1 -1
View File
@@ -209,7 +209,7 @@ async def _scrape_with_browserless(
"""Scrape une URL avec Browserless + extraction IA"""
try:
# Scraper avec Browserless
screenshot_path, page_text = await ScraperService.scrape_item(
screenshot_path, page_text, is_available = await ScraperService.scrape_item(
url=url,
item_id=None,
smart_scroll=True,