mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-23 15:18:12 +02:00
Merge pull request #38 from R0m1k3/antigravity
feat: Add initial database migration and UI label component, alongsid…
This commit is contained in:
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')
|
||||||
@@ -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')
|
||||||
@@ -47,6 +47,8 @@ class Item(Base):
|
|||||||
# New fields
|
# New fields
|
||||||
current_price: float | None = Column(Float, nullable=True) # type: ignore
|
current_price: float | None = Column(Float, nullable=True) # type: ignore
|
||||||
in_stock: bool | None = Column(Boolean, 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
|
tags: str | None = Column(String, nullable=True) # type: ignore
|
||||||
description: str | None = Column(String, nullable=True) # type: ignore
|
description: str | None = Column(String, nullable=True) # type: ignore
|
||||||
|
|
||||||
|
|||||||
+27
-3
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import database, schemas
|
from app import database, models, schemas
|
||||||
from app.limiter import limiter
|
from app.limiter import limiter
|
||||||
from app.services.item_service import ItemService
|
from app.services.item_service import ItemService
|
||||||
from app.services.scheduler_service import process_item_check
|
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])
|
@router.get("", response_model=list[schemas.ItemResponse])
|
||||||
def get_items(db: Session = Depends(database.get_db)):
|
def get_items(category: str | None = None, db: Session = Depends(database.get_db)):
|
||||||
return ItemService.get_items(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)
|
@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)
|
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")
|
@router.post("/{item_id}/check")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def check_item(
|
def check_item(
|
||||||
|
|||||||
@@ -70,8 +70,10 @@ DEFAULT_SITE_CONFIGS = {
|
|||||||
"name": "B&M",
|
"name": "B&M",
|
||||||
"search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}",
|
"search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}",
|
||||||
"product_selector": (
|
"product_selector": (
|
||||||
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
|
# Cibler uniquement les liens produits, pas les catégories
|
||||||
".products article a, a[href*='/produits/']"
|
"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",
|
"wait_selector": ".products, .product-miniature, #js-product-list, #search_results",
|
||||||
"category": "Discount",
|
"category": "Discount",
|
||||||
@@ -190,10 +192,12 @@ DEFAULT_SITE_CONFIGS = {
|
|||||||
"name": "Cdiscount",
|
"name": "Cdiscount",
|
||||||
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
|
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
|
||||||
"product_selector": (
|
"product_selector": (
|
||||||
"a.prdtBImg, .prdtBILDetails a, .product-item a, "
|
# Sélecteurs spécifiques Cdiscount - produits uniquement
|
||||||
"a[href*='/f-']"
|
"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",
|
"category": "E-commerce",
|
||||||
"requires_js": True,
|
"requires_js": True,
|
||||||
"priority": 12,
|
"priority": 12,
|
||||||
@@ -203,10 +207,12 @@ DEFAULT_SITE_CONFIGS = {
|
|||||||
"name": "Darty",
|
"name": "Darty",
|
||||||
"search_url": "https://www.darty.com/nav/recherche?text={query}",
|
"search_url": "https://www.darty.com/nav/recherche?text={query}",
|
||||||
"product_selector": (
|
"product_selector": (
|
||||||
".product-card a, a[href*='/product'], "
|
# Cibler les fiches produits, exclure navigation et catégories
|
||||||
".product-list-item a"
|
"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",
|
"category": "Électronique",
|
||||||
"requires_js": True,
|
"requires_js": True,
|
||||||
"priority": 13,
|
"priority": 13,
|
||||||
@@ -215,10 +221,12 @@ DEFAULT_SITE_CONFIGS = {
|
|||||||
"name": "Boulanger",
|
"name": "Boulanger",
|
||||||
"search_url": "https://www.boulanger.com/resultats?tr={query}",
|
"search_url": "https://www.boulanger.com/resultats?tr={query}",
|
||||||
"product_selector": (
|
"product_selector": (
|
||||||
".product-list__item a, a[href*='/ref/'], "
|
# Cibler uniquement les pages produits avec /ref/
|
||||||
".product-card a"
|
"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",
|
"category": "Électronique",
|
||||||
"requires_js": True,
|
"requires_js": True,
|
||||||
"priority": 14,
|
"priority": 14,
|
||||||
@@ -241,8 +249,13 @@ DEFAULT_SITE_CONFIGS = {
|
|||||||
"fnac.com": {
|
"fnac.com": {
|
||||||
"name": "Fnac",
|
"name": "Fnac",
|
||||||
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
|
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
|
||||||
"product_selector": "a.Article-title, .Article-item a.js-minifa-title",
|
"product_selector": (
|
||||||
"wait_selector": ".Article-item",
|
# 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",
|
"category": "Culture & Tech",
|
||||||
"requires_js": True,
|
"requires_js": True,
|
||||||
"priority": 15,
|
"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)"""
|
"""Vérifie si un lien est un lien de navigation (pas un produit)"""
|
||||||
nav_patterns = [
|
nav_patterns = [
|
||||||
# Catégories et navigation
|
# Catégories et navigation
|
||||||
"/category", "/categories", "/categorie",
|
"/category", "/categories", "/categorie", "/c/",
|
||||||
"/brand", "/brands", "/marque",
|
"/rayon", "/rayons", "/univers",
|
||||||
|
"/brand", "/brands", "/marque", "/marques",
|
||||||
"/page/", "/pages/",
|
"/page/", "/pages/",
|
||||||
"/cart", "/panier", "/basket",
|
"/cart", "/panier", "/basket",
|
||||||
"/checkout", "/commande",
|
"/checkout", "/commande",
|
||||||
@@ -740,13 +754,24 @@ def _is_navigation_link(href: str) -> bool:
|
|||||||
"/application", "/app/", "/apps/",
|
"/application", "/app/", "/apps/",
|
||||||
"/courses", "/course/",
|
"/courses", "/course/",
|
||||||
"/ep-", "/endpoint",
|
"/ep-", "/endpoint",
|
||||||
|
# Promotions et offres (patterns communs)
|
||||||
"/promo/", "/promotions/", "/promotion/",
|
"/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/",
|
"/services/", "/service/",
|
||||||
"/magasin", "/magasins", "/stores/", "/store/",
|
"/magasin", "/magasins", "/stores/", "/store/",
|
||||||
"/blog", "/actualites", "/news",
|
"/blog", "/actualites", "/news",
|
||||||
"/recettes", "/recipes",
|
"/recettes", "/recipes",
|
||||||
"/conseils", "/tips",
|
"/conseils", "/tips",
|
||||||
|
"/guide", "/guides",
|
||||||
|
# Patterns e-commerce spécifiques
|
||||||
|
"/l-", "/lp-", "/landing", # Landing pages (Cdiscount, etc.)
|
||||||
|
"/selection", "/selections",
|
||||||
|
"/top-", "/best-",
|
||||||
# Amazon spécifique
|
# Amazon spécifique
|
||||||
"/ref=cs_", "/logo", "/nav_", "/gp/help",
|
"/ref=cs_", "/logo", "/nav_", "/gp/help",
|
||||||
"/gp/css", "/gp/redirect", "/hz/",
|
"/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)
|
return any(pattern in href_lower for pattern in nav_patterns)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_title(element) -> str:
|
def _extract_title(element) -> str:
|
||||||
"""Extrait le titre d'un élément"""
|
"""Extrait le titre d'un élément"""
|
||||||
# Essayer différentes méthodes
|
# Essayer différentes méthodes
|
||||||
@@ -855,16 +881,28 @@ def _is_non_product_url(url: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
non_product_patterns = [
|
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",
|
"/help", "/contact", "/about", "/account", "/cart", "/checkout",
|
||||||
"/login", "/register", "/wishlist", "/compare", "/reviews",
|
"/login", "/register", "/wishlist", "/compare", "/reviews",
|
||||||
|
# Pages légales
|
||||||
"/terms", "/privacy", "/faq", "/shipping", "/returns",
|
"/terms", "/privacy", "/faq", "/shipping", "/returns",
|
||||||
|
# Erreurs
|
||||||
"/503", "/error", "/robot", "/captcha",
|
"/503", "/error", "/robot", "/captcha",
|
||||||
|
# Applications et services
|
||||||
"/application", "/courses", "/app/",
|
"/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)
|
return any(pattern in url_lower for pattern in non_product_patterns)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def health_check() -> bool:
|
async def health_check() -> bool:
|
||||||
"""Vérifie la connectivité avec Browserless"""
|
"""Vérifie la connectivité avec Browserless"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def process_item_check(item_id: int):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Checking item: {item_data['name']} ({item_data['url']})")
|
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["url"],
|
||||||
item_data["selector"],
|
item_data["selector"],
|
||||||
item_id,
|
item_id,
|
||||||
@@ -113,6 +113,15 @@ async def process_item_check(item_id: int):
|
|||||||
timeout=config["scraper_timeout"],
|
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:
|
if not screenshot_path:
|
||||||
raise Exception("Failed to capture screenshot")
|
raise Exception("Failed to capture screenshot")
|
||||||
|
|
||||||
|
|||||||
@@ -88,10 +88,10 @@ class ScraperService:
|
|||||||
scroll_pixels: int = 350,
|
scroll_pixels: int = 350,
|
||||||
text_length: int = 0,
|
text_length: int = 0,
|
||||||
timeout: int = 90000,
|
timeout: int = 90000,
|
||||||
) -> tuple[str | None, str]:
|
) -> tuple[str | None, str, bool]:
|
||||||
"""
|
"""
|
||||||
Scrapes the given URL using Browserless and Playwright.
|
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:
|
Args:
|
||||||
url: Target URL to scrape
|
url: Target URL to scrape
|
||||||
@@ -139,7 +139,7 @@ class ScraperService:
|
|||||||
logger.warning(f"Tentative {attempt + 1} échouée pour {url}")
|
logger.warning(f"Tentative {attempt + 1} échouée pour {url}")
|
||||||
|
|
||||||
logger.error(f"Toutes les tentatives ont échoué pour {url}")
|
logger.error(f"Toutes les tentatives ont échoué pour {url}")
|
||||||
return None, ""
|
return None, "", False # Product unavailable if all retries failed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _do_scrape( # noqa: PLR0913, PLR0912, PLR0915
|
async def _do_scrape( # noqa: PLR0913, PLR0912, PLR0915
|
||||||
@@ -150,12 +150,14 @@ class ScraperService:
|
|||||||
scroll_pixels: int = 350,
|
scroll_pixels: int = 350,
|
||||||
text_length: int = 0,
|
text_length: int = 0,
|
||||||
timeout: int = 90000,
|
timeout: int = 90000,
|
||||||
) -> tuple[str | None, str]:
|
) -> tuple[str | None, str, bool]:
|
||||||
"""Exécute le scraping réel (appelé par scrape_item avec retries)."""
|
"""Exécute le scraping réel (appelé par scrape_item avec retries)."""
|
||||||
async with async_playwright() as p:
|
async with async_playwright() as p:
|
||||||
browser = None
|
browser = None
|
||||||
context = None
|
context = None
|
||||||
page = None
|
page = None
|
||||||
|
is_available = True # Par défaut, le produit est disponible
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}")
|
logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}")
|
||||||
|
|
||||||
@@ -188,9 +190,24 @@ class ScraperService:
|
|||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
|
|
||||||
logger.info(f"Navigating to {url} (Timeout: {timeout}ms)")
|
logger.info(f"Navigating to {url} (Timeout: {timeout}ms)")
|
||||||
|
response = None
|
||||||
try:
|
try:
|
||||||
# First wait for domcontentloaded - this is the minimum we need
|
# 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}")
|
logger.info(f"Page loaded (domcontentloaded): {url}")
|
||||||
|
|
||||||
# Then try to wait for networkidle, but don't fail if it times out
|
# Then try to wait for networkidle, but don't fail if it times out
|
||||||
@@ -203,6 +220,8 @@ class ScraperService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error navigating to {url}: {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
|
# Try to take screenshot anyway if page partially loaded
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -347,7 +366,7 @@ class ScraperService:
|
|||||||
logger.info(f"Screenshot saved to {filename}")
|
logger.info(f"Screenshot saved to {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Screenshot failed: {e}")
|
logger.error(f"Screenshot failed: {e}")
|
||||||
return None, ""
|
return None, "", is_available
|
||||||
|
|
||||||
# Text Extraction (après le screenshot pour ne pas le perdre)
|
# Text Extraction (après le screenshot pour ne pas le perdre)
|
||||||
page_text = ""
|
page_text = ""
|
||||||
@@ -359,15 +378,40 @@ class ScraperService:
|
|||||||
# Simple truncation
|
# Simple truncation
|
||||||
page_text = raw_text[:text_length]
|
page_text = raw_text[:text_length]
|
||||||
logger.info(f"Extracted {len(page_text)} characters")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Text extraction failed (screenshot saved): {e}")
|
logger.warning(f"Text extraction failed (screenshot saved): {e}")
|
||||||
# On continue car le screenshot a été pris
|
# On continue car le screenshot a été pris
|
||||||
|
|
||||||
return filename, page_text
|
return filename, page_text, is_available
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error scraping {url}: {e}")
|
logger.error(f"Error scraping {url}: {e}")
|
||||||
return None, ""
|
return None, "", False # Mark as unavailable on error
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Fermeture propre des ressources dans l'ordre inverse
|
# Fermeture propre des ressources dans l'ordre inverse
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ async def _scrape_with_browserless(
|
|||||||
"""Scrape une URL avec Browserless + extraction IA"""
|
"""Scrape une URL avec Browserless + extraction IA"""
|
||||||
try:
|
try:
|
||||||
# Scraper avec Browserless
|
# Scraper avec Browserless
|
||||||
screenshot_path, page_text = await ScraperService.scrape_item(
|
screenshot_path, page_text, is_available = await ScraperService.scrape_item(
|
||||||
url=url,
|
url=url,
|
||||||
item_id=None,
|
item_id=None,
|
||||||
smart_scroll=True,
|
smart_scroll=True,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Toaster } from 'sonner'
|
|||||||
import Layout from '@/components/layout/Layout'
|
import Layout from '@/components/layout/Layout'
|
||||||
import Dashboard from '@/pages/Dashboard'
|
import Dashboard from '@/pages/Dashboard'
|
||||||
import Search from '@/pages/Search'
|
import Search from '@/pages/Search'
|
||||||
|
import MultiSearch from '@/pages/MultiSearch'
|
||||||
import Login from '@/pages/Login'
|
import Login from '@/pages/Login'
|
||||||
import Admin from '@/pages/Admin'
|
import Admin from '@/pages/Admin'
|
||||||
import { useTheme } from '@/hooks/use-theme'
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
@@ -75,6 +76,7 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/search" element={<Search />} />
|
<Route path="/search" element={<Search />} />
|
||||||
|
<Route path="/compare" element={<MultiSearch />} />
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock } from 'lucide-react';
|
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock, Tag, History } from 'lucide-react';
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Marquee } from '@/components/ui/marquee';
|
import { Marquee } from '@/components/ui/marquee';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
|
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom, onCategoryUpdate, categories, onViewHistory }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [showCategorySelect, setShowCategorySelect] = useState(false);
|
||||||
|
|
||||||
const getStockStatus = (inStock) => {
|
const getStockStatus = (inStock) => {
|
||||||
if (inStock === true) return { label: t('dashboard.inStock'), color: 'bg-green-500/90 text-white border-green-400/50' };
|
if (inStock === true) return { label: t('dashboard.inStock'), color: 'bg-green-500/90 text-white border-green-400/50' };
|
||||||
@@ -134,6 +136,34 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.category ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
<Tag className="h-2.5 w-2.5" />
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{onCategoryUpdate && (
|
||||||
|
<Select
|
||||||
|
value={item.category || ''}
|
||||||
|
onValueChange={(value) => onCategoryUpdate(item.id, value || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-[120px] text-[10px]">
|
||||||
|
<Tag className="h-2.5 w-2.5 mr-1" />
|
||||||
|
<SelectValue placeholder="Catégorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Aucune</SelectItem>
|
||||||
|
{categories && categories.map(cat => (
|
||||||
|
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__new__">+ Nouvelle...</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||||
@@ -159,6 +189,11 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{onViewHistory && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onViewHistory(item)} title="Historique des prix">
|
||||||
|
<History className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onEdit(item)}>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onEdit(item)}>
|
||||||
<Edit2 className="h-3 w-3" />
|
<Edit2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { X, TrendingDown, TrendingUp, Minus } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
|
||||||
|
const API_URL = '/api';
|
||||||
|
|
||||||
|
export function PriceHistoryModal({ open, onClose, item }) {
|
||||||
|
const [priceHistory, setPriceHistory] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && item) {
|
||||||
|
loadPriceHistory();
|
||||||
|
}
|
||||||
|
}, [open, item]);
|
||||||
|
|
||||||
|
const loadPriceHistory = async () => {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/items/${item.id}/price-history`);
|
||||||
|
setPriceHistory(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading price history:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const prices = priceHistory.map(h => h.price);
|
||||||
|
const currentPrice = prices[prices.length - 1];
|
||||||
|
const minPrice = Math.min(...prices);
|
||||||
|
const maxPrice = Math.max(...prices);
|
||||||
|
const avgPrice = prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0;
|
||||||
|
|
||||||
|
// Calculate price change
|
||||||
|
const priceChange = prices.length > 1 ? ((currentPrice - prices[0]) / prices[0] * 100) : 0;
|
||||||
|
|
||||||
|
// Simple chart rendering
|
||||||
|
const chartHeight = 200;
|
||||||
|
const chartWidth = 600;
|
||||||
|
const padding = 40;
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
if (prices.length === 0) return null;
|
||||||
|
|
||||||
|
const minY = minPrice * 0.95;
|
||||||
|
const maxY = maxPrice * 1.05;
|
||||||
|
const rangeY = maxY - minY;
|
||||||
|
|
||||||
|
const points = priceHistory.map((point, index) => {
|
||||||
|
const x = padding + (index / (priceHistory.length - 1 || 1)) * (chartWidth - 2 * padding);
|
||||||
|
const y = chartHeight - padding - ((point.price - minY) / rangeY) * (chartHeight - 2 * padding);
|
||||||
|
return { x, y, price: point.price, date: point.timestamp };
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathData = points.map((point, index) =>
|
||||||
|
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width="100%" height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="mt-4">
|
||||||
|
{/* Grid lines */}
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||||
|
const y = padding + ratio * (chartHeight - 2 * padding);
|
||||||
|
const price = maxY - ratio * rangeY;
|
||||||
|
return (
|
||||||
|
<g key={ratio}>
|
||||||
|
<line
|
||||||
|
x1={padding}
|
||||||
|
y1={y}
|
||||||
|
x2={chartWidth - padding}
|
||||||
|
y2={y}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity="0.1"
|
||||||
|
/>
|
||||||
|
<text x={10} y={y + 4} fontSize="10" fill="currentColor" opacity="0.5">
|
||||||
|
{price.toFixed(2)}€
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Area under curve */}
|
||||||
|
<path
|
||||||
|
d={`${pathData} L ${points[points.length - 1].x} ${chartHeight - padding} L ${points[0].x} ${chartHeight - padding} Z`}
|
||||||
|
fill="currentColor"
|
||||||
|
fillOpacity="0.1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Points */}
|
||||||
|
{points.map((point, index) => (
|
||||||
|
<g key={index}>
|
||||||
|
<circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r="4"
|
||||||
|
fill="currentColor"
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
<title>{`${new Date(point.date).toLocaleDateString('fr-FR')}: ${point.price.toFixed(2)}€`}</title>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between">
|
||||||
|
<span>Historique des prix</span>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Product Info */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold line-clamp-2">{item.name}</h3>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
{new URL(item.url).hostname.replace('www.', '')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Card className="p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Prix actuel</div>
|
||||||
|
<div className="text-lg font-bold">{currentPrice?.toFixed(2) || '--'}€</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Prix minimum</div>
|
||||||
|
<div className="text-lg font-bold text-green-600">{minPrice?.toFixed(2) || '--'}€</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Prix maximum</div>
|
||||||
|
<div className="text-lg font-bold text-red-600">{maxPrice?.toFixed(2) || '--'}€</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Variation</div>
|
||||||
|
<div className={`text-lg font-bold flex items-center gap-1 ${priceChange > 0 ? 'text-red-600' : priceChange < 0 ? 'text-green-600' : ''}`}>
|
||||||
|
{priceChange > 0 && <TrendingUp className="h-4 w-4" />}
|
||||||
|
{priceChange < 0 && <TrendingDown className="h-4 w-4" />}
|
||||||
|
{priceChange === 0 && <Minus className="h-4 w-4" />}
|
||||||
|
{Math.abs(priceChange).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-[200px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
) : priceHistory.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
{renderChart()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Aucun historique de prix disponible
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History Table */}
|
||||||
|
{priceHistory.length > 0 && (
|
||||||
|
<div className="max-h-[200px] overflow-y-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-background border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2">Date</th>
|
||||||
|
<th className="text-right p-2">Prix</th>
|
||||||
|
<th className="text-right p-2">Variation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{priceHistory.map((entry, index) => {
|
||||||
|
const prevPrice = index > 0 ? priceHistory[index - 1].price : entry.price;
|
||||||
|
const change = ((entry.price - prevPrice) / prevPrice * 100);
|
||||||
|
return (
|
||||||
|
<tr key={index} className="border-b">
|
||||||
|
<td className="p-2">
|
||||||
|
{new Date(entry.timestamp).toLocaleString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2 font-medium">
|
||||||
|
{entry.price.toFixed(2)}€
|
||||||
|
</td>
|
||||||
|
<td className={`text-right p-2 ${change > 0 ? 'text-red-600' : change < 0 ? 'text-green-600' : ''}`}>
|
||||||
|
{change !== 0 ? `${change > 0 ? '+' : ''}${change.toFixed(1)}%` : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LayoutDashboard, Search, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react';
|
import { LayoutDashboard, Search, TrendingUp, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -22,6 +22,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
|
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
|
||||||
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
|
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
|
||||||
|
{ icon: TrendingUp, label: 'Comparateur', path: '/compare' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add admin link for admin users
|
// Add admin link for admin users
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Plus, RefreshCw, Search, X } from 'lucide-react';
|
import { Plus, RefreshCw, Search, X, Filter } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { ItemCard } from '@/components/dashboard/ItemCard';
|
import { ItemCard } from '@/components/dashboard/ItemCard';
|
||||||
import { ItemModal } from '@/components/dashboard/ItemModal';
|
import { ItemModal } from '@/components/dashboard/ItemModal';
|
||||||
import { DeleteConfirmationModal } from '@/components/dashboard/DeleteConfirmationModal';
|
import { DeleteConfirmationModal } from '@/components/dashboard/DeleteConfirmationModal';
|
||||||
|
import { PriceHistoryModal } from '@/components/dashboard/PriceHistoryModal';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -15,9 +17,12 @@ export default function Dashboard() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
const [historyItem, setHistoryItem] = useState(null);
|
||||||
const [zoomedImage, setZoomedImage] = useState(null);
|
const [zoomedImage, setZoomedImage] = useState(null);
|
||||||
|
|
||||||
const refreshItems = async () => {
|
const refreshItems = async () => {
|
||||||
@@ -31,8 +36,18 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/items/categories/list`);
|
||||||
|
setCategories(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching categories:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshItems();
|
refreshItems();
|
||||||
|
loadCategories();
|
||||||
const interval = setInterval(refreshItems, 10000);
|
const interval = setInterval(refreshItems, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -75,14 +90,32 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCategoryUpdate = async (itemId, category) => {
|
||||||
|
try {
|
||||||
|
await axios.patch(`${API_URL}/items/${itemId}/category?category=${encodeURIComponent(category || '')}`);
|
||||||
|
toast.success('Catégorie mise à jour');
|
||||||
|
refreshItems();
|
||||||
|
loadCategories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating category:', error);
|
||||||
|
toast.error('Erreur lors de la mise à jour');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredItems = items.filter(item => {
|
const filteredItems = items.filter(item => {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
return (
|
const matchesSearch = (
|
||||||
item.name.toLowerCase().includes(term) ||
|
item.name.toLowerCase().includes(term) ||
|
||||||
item.url.toLowerCase().includes(term) ||
|
item.url.toLowerCase().includes(term) ||
|
||||||
(item.tags && item.tags.toLowerCase().includes(term)) ||
|
(item.tags && item.tags.toLowerCase().includes(term)) ||
|
||||||
(item.description && item.description.toLowerCase().includes(term))
|
(item.description && item.description.toLowerCase().includes(term))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const matchesCategory = selectedCategory === 'all' ||
|
||||||
|
(selectedCategory === 'uncategorized' && !item.category) ||
|
||||||
|
item.category === selectedCategory;
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +125,7 @@ export default function Dashboard() {
|
|||||||
<h2 className="text-2xl font-bold tracking-tight">{t('dashboard.title')}</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t('dashboard.title')}</h2>
|
||||||
<p className="text-muted-foreground">{t('dashboard.description')}</p>
|
<p className="text-muted-foreground">{t('dashboard.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="relative w-full md:w-64">
|
<div className="relative w-full md:w-64">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -102,6 +135,19 @@ export default function Dashboard() {
|
|||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||||
|
<SelectTrigger className="w-full md:w-[180px]">
|
||||||
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
|
<SelectValue placeholder="Catégorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toutes</SelectItem>
|
||||||
|
<SelectItem value="uncategorized">Sans catégorie</SelectItem>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Button variant="outline" onClick={handleRefreshAll}>
|
<Button variant="outline" onClick={handleRefreshAll}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t('dashboard.refreshAll')}
|
{t('dashboard.refreshAll')}
|
||||||
@@ -122,6 +168,9 @@ export default function Dashboard() {
|
|||||||
onDelete={setItemToDelete}
|
onDelete={setItemToDelete}
|
||||||
onCheck={handleCheck}
|
onCheck={handleCheck}
|
||||||
onZoom={setZoomedImage}
|
onZoom={setZoomedImage}
|
||||||
|
onCategoryUpdate={handleCategoryUpdate}
|
||||||
|
onViewHistory={setHistoryItem}
|
||||||
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +195,12 @@ export default function Dashboard() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PriceHistoryModal
|
||||||
|
open={!!historyItem}
|
||||||
|
item={historyItem}
|
||||||
|
onClose={() => setHistoryItem(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Image Zoom Modal */}
|
{/* Image Zoom Modal */}
|
||||||
{zoomedImage && (
|
{zoomedImage && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'react-i18n';
|
||||||
|
import { Search as SearchIcon, Loader2, ExternalLink, TrendingUp, Package } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
const API_URL = '/api';
|
||||||
|
|
||||||
|
export default function MultiSearch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(null);
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const eventSourceRef = useRef(null);
|
||||||
|
|
||||||
|
const handleSearch = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!query.trim()) {
|
||||||
|
toast.error('Veuillez entrer une recherche');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer l'EventSource précédent si existant
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
setResults([]);
|
||||||
|
setProgress({ status: 'searching', total: 0, completed: 0 });
|
||||||
|
|
||||||
|
// Construire l'URL - recherche sur TOUS les sites actifs
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query.trim(),
|
||||||
|
max_results: 50, // Plus de résultats pour la comparaison
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer l'EventSource pour SSE
|
||||||
|
const eventSource = new EventSource(`${API_URL}/search?${params}`);
|
||||||
|
eventSourceRef.current = eventSource;
|
||||||
|
|
||||||
|
eventSource.addEventListener('progress', (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
setProgress(data);
|
||||||
|
setResults(data.results || []);
|
||||||
|
|
||||||
|
if (data.status === 'completed' || data.status === 'error') {
|
||||||
|
setIsSearching(false);
|
||||||
|
eventSource.close();
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
toast.success(`${data.results?.length || 0} produits trouvés`);
|
||||||
|
} else if (data.message) {
|
||||||
|
toast.error(data.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing SSE data:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setIsSearching(false);
|
||||||
|
eventSource.close();
|
||||||
|
toast.error('Erreur de connexion au serveur');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToMonitoring = async (result) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/items`, {
|
||||||
|
url: result.url,
|
||||||
|
name: result.title,
|
||||||
|
target_price: null,
|
||||||
|
check_interval_minutes: 60,
|
||||||
|
});
|
||||||
|
toast.success(`"${result.title}" ajouté au monitoring`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding item:', error);
|
||||||
|
toast.error("Erreur lors de l'ajout au monitoring");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent = progress
|
||||||
|
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Grouper les résultats par produit similaire (basé sur le titre)
|
||||||
|
const groupedResults = results.reduce((acc, result) => {
|
||||||
|
// Simplifier le titre pour le regroupement
|
||||||
|
const simplifiedTitle = result.title.toLowerCase().substring(0, 50);
|
||||||
|
|
||||||
|
if (!acc[simplifiedTitle]) {
|
||||||
|
acc[simplifiedTitle] = [];
|
||||||
|
}
|
||||||
|
acc[simplifiedTitle].push(result);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Comparateur de Prix</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Recherchez un produit sur tous les sites et comparez les prix
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Form */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSearch} className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher un produit sur tous les sites..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
disabled={isSearching}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isSearching || !query.trim()}>
|
||||||
|
{isSearching ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Recherche...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SearchIcon className="h-4 w-4 mr-2" />
|
||||||
|
Comparer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{progress && isSearching && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>{progress.message || 'Recherche en cours...'}</span>
|
||||||
|
<span>{progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPercent} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Table */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
Résultats de comparaison
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{results.length} produit{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''} avec prix
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-3 font-medium">Site</th>
|
||||||
|
<th className="text-left p-3 font-medium">Produit</th>
|
||||||
|
<th className="text-right p-3 font-medium">Prix</th>
|
||||||
|
<th className="text-center p-3 font-medium">Stock</th>
|
||||||
|
<th className="text-right p-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<tr key={index} className="border-b hover:bg-muted/50 transition-colors">
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{result.site_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{result.site_domain}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<p className="font-medium line-clamp-2">{result.title}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{result.price !== null ? (
|
||||||
|
<span className="text-lg font-bold text-primary">
|
||||||
|
{result.price.toFixed(2)} {result.currency || 'EUR'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">Prix non disponible</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
{result.in_stock === true && (
|
||||||
|
<Badge variant="success" className="bg-green-500/10 text-green-700 dark:text-green-400">
|
||||||
|
<Package className="h-3 w-3 mr-1" />
|
||||||
|
En stock
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{result.in_stock === false && (
|
||||||
|
<Badge variant="destructive">Rupture</Badge>
|
||||||
|
)}
|
||||||
|
{result.in_stock === null && (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(result.url, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3 mr-1" />
|
||||||
|
Voir
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddToMonitoring(result)}
|
||||||
|
>
|
||||||
|
Suivre
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isSearching && results.length === 0 && progress?.status === 'completed' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<SearchIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">Aucun produit trouvé</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Essayez avec d'autres termes de recherche
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { SearchResultCard } from '@/components/search/SearchResultCard';
|
import { SearchResultCard } from '@/components/search/SearchResultCard';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
const STORAGE_KEY = 'priceflow_search_state';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -22,6 +23,32 @@ export default function Search() {
|
|||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const eventSourceRef = useRef(null);
|
const eventSourceRef = useRef(null);
|
||||||
|
|
||||||
|
// Restore search state from sessionStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedState = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (savedState) {
|
||||||
|
try {
|
||||||
|
const { query: savedQuery, selectedSiteId: savedSiteId, results: savedResults } = JSON.parse(savedState);
|
||||||
|
if (savedQuery) setQuery(savedQuery);
|
||||||
|
if (savedSiteId) setSelectedSiteId(savedSiteId);
|
||||||
|
if (savedResults) setResults(savedResults);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restoring search state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save search state to sessionStorage whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (query || results.length > 0) {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
query,
|
||||||
|
selectedSiteId,
|
||||||
|
results
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [query, selectedSiteId, results]);
|
||||||
|
|
||||||
// Charger les sites au démarrage
|
// Charger les sites au démarrage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSites();
|
loadSites();
|
||||||
@@ -32,7 +59,7 @@ export default function Search() {
|
|||||||
const response = await axios.get(`${API_URL}/search-sites`);
|
const response = await axios.get(`${API_URL}/search-sites`);
|
||||||
const activeSites = response.data.filter(site => site.is_active);
|
const activeSites = response.data.filter(site => site.is_active);
|
||||||
setSites(activeSites);
|
setSites(activeSites);
|
||||||
// Sélectionner le premier site actif par défaut
|
// Sélectionner le premier site actif par défaut si aucun site n'est sélectionné
|
||||||
if (activeSites.length > 0 && !selectedSiteId) {
|
if (activeSites.length > 0 && !selectedSiteId) {
|
||||||
setSelectedSiteId(activeSites[0].id.toString());
|
setSelectedSiteId(activeSites[0].id.toString());
|
||||||
}
|
}
|
||||||
@@ -49,6 +76,9 @@ export default function Search() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear previous storage when starting new search
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
|
||||||
// Fermer l'EventSource précédent si existant
|
// Fermer l'EventSource précédent si existant
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
|
|||||||
@@ -639,9 +639,36 @@ export default function Settings() {
|
|||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input required value={newProfile.name} onChange={(e) => setNewProfile({ ...newProfile, name: e.target.value })} placeholder="e.g., Email Alerts" />
|
<Input required value={newProfile.name} onChange={(e) => setNewProfile({ ...newProfile, name: e.target.value })} placeholder="e.g., Email Alerts" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label>Apprise URL</Label>
|
<Label>Apprise URL</Label>
|
||||||
<Input required value={newProfile.apprise_url} onChange={(e) => setNewProfile({ ...newProfile, apprise_url: e.target.value })} placeholder="mailto://user:pass@gmail.com" />
|
<Input
|
||||||
|
required
|
||||||
|
value={newProfile.apprise_url}
|
||||||
|
onChange={(e) => setNewProfile({ ...newProfile, apprise_url: e.target.value })}
|
||||||
|
placeholder="mailto://user:pass@gmail.com"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium">Exemples d'URLs Apprise :</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-3">
|
||||||
|
<p>• <span className="font-mono">mailto://user:pass@gmail.com</span> - Email</p>
|
||||||
|
<p>• <span className="font-mono">tgram://bottoken/ChatID</span> - Telegram</p>
|
||||||
|
<p>• <span className="font-mono">discord://webhook_id/webhook_token</span> - Discord</p>
|
||||||
|
<p>• <span className="font-mono">slack://tokenA/tokenB/tokenC</span> - Slack</p>
|
||||||
|
<p>• <span className="font-mono">ntfy://topic</span> - Ntfy</p>
|
||||||
|
<p>• <span className="font-mono">gotify://hostname/token</span> - Gotify</p>
|
||||||
|
</div>
|
||||||
|
<p className="pt-1">
|
||||||
|
<a
|
||||||
|
href="https://github.com/caronc/apprise/wiki"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
📚 Documentation complète Apprise
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
check_interval_minutes INTEGER DEFAULT 60,
|
check_interval_minutes INTEGER DEFAULT 60,
|
||||||
current_price FLOAT,
|
current_price FLOAT,
|
||||||
in_stock BOOLEAN,
|
in_stock BOOLEAN,
|
||||||
|
is_available BOOLEAN DEFAULT TRUE,
|
||||||
|
category VARCHAR(255),
|
||||||
tags VARCHAR(512),
|
tags VARCHAR(512),
|
||||||
description TEXT,
|
description TEXT,
|
||||||
current_price_confidence FLOAT,
|
current_price_confidence FLOAT,
|
||||||
|
|||||||
Reference in new issue
Block a user