1er
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
APP_ENV=development
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8008
|
||||
LOG_LEVEL=INFO
|
||||
DATABASE_URL=sqlite:///backend/data/suivi.db
|
||||
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
backend/data/
|
||||
backend/logs/
|
||||
frontend/node_modules/
|
||||
.env
|
||||
11
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
repos:
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: "v0.1.240"
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["check", "backend", "frontend"]
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: "v1.9"
|
||||
hooks:
|
||||
- id: mypy
|
||||
args: ["backend"]
|
||||
6
CHANGELOG.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 0.1.0 - Initial setup
|
||||
- création structure backend/frontend
|
||||
- ajout configurations JSON
|
||||
- définition README/TODO/kanban
|
||||
7
TODO.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# TODO
|
||||
- [ ] init repo pour backend FastAPI + logging + config
|
||||
- [ ] créer scraper Amazon via Playwright avec parser robuste
|
||||
- [ ] définir UI Gruvbox + carte produit + graphique 30j
|
||||
- [ ] intégrer scheduler APScheduler pour `scrape_all`
|
||||
- [ ] dockeriser backend + frontend + scheduler
|
||||
- [ ] ajouter page debug/logs affichant tables SQLite
|
||||
36
agent.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Agent Codex
|
||||
|
||||
## Rôle
|
||||
- Agent senior fullstack chargé du projet `suivi_produits` : architecture, backend FastAPI, scraper Playwright, stockage SQLite/JSON et UI responsive Gruvbox.
|
||||
|
||||
## Responsabilités techniques
|
||||
- **Backend** : modélisation SQLAlchemy, API FastAPI (CRUD, santé, triggers de scrap, configs statiques), logging, scheduler APScheduler, gestion des configs JSON.
|
||||
- **Scraper** : orchestrer Playwright Chromium avec contexte fr-FR, parse des pages Amazon, normalisation des données/artefacts raw, gestion des erreurs sans casser le processus.
|
||||
- **Frontend** : SPA React/Vite Gruvbox, cartes produits, graphiques 30j, settings/config éditables, responsive et accessibles.
|
||||
- **Base de données / stockage** : conception du schéma SQLite (produits, snapshots, runs), index et archivage des raw JSON + logs.
|
||||
|
||||
## Libertés sans accord explicite
|
||||
- créer ou mettre à jour fichiers (backend, frontend, docs) nécessaires à l’avancement.
|
||||
- refactorer ou enrichir le code existant si cela améliore la lisibilité, la robustesse ou la maintenabilité.
|
||||
- lancer des scripts/tests (`pytest`, `npm/pnpm install`, `npm run`, `poetry run`) pour valider les changements.
|
||||
|
||||
## Actions à demander systématiquement
|
||||
- refondre radicalement l’architecture (stack, découpage services, migration vers un autre framework).
|
||||
- supprimer ou réinitialiser des données utilisateur (tables SQLite, raw JSON historiques, logs critiques).
|
||||
- modifier le schéma de la base de données de façon irréversible (ajout/suppression de tables ou colonnes) sans validation préalable.
|
||||
|
||||
## Règles de qualité
|
||||
- Chaque scrap produit un log clair (début/fin, champs manquants, erreur) dans `backend/logs/scrap.log` avec rotation.
|
||||
- Les erreurs de scraping doivent rester résilientes (log + snapshot/artéfacts) sans faire planter l’ensemble.
|
||||
- Toute logique métier critique est accompagnée de tests (unitaires ou d’intégration) avant validation.
|
||||
|
||||
## Mode de travail par phases
|
||||
1. **Phases exploratoires** : brainstorming, plan phase, architecture, choix stack.
|
||||
2. **Phases d’implémentation MVP** : backend + DB, scraper Amazon, frontend vignettes + graph, settings.
|
||||
3. **Phases d’industrialisation** : scheduler/cron, logging, tests, docker-compose.
|
||||
4. **Phases d’évolution** : multi-stores, refinements, nouveaux modules.
|
||||
|
||||
## Politique de commit
|
||||
- Messages courts, explicites, en anglais ou français (ex : `feat: add scraper runner`), toujours en lien avec le scope réalisé.
|
||||
- Granularité : un commit = une unité cohérente (par ex. `backend : ajout endpoints produits`, `frontend : structure carte produit`).
|
||||
- Pas de commits « tout-en-un » ; étapes distinctes (config, tests, refactor) méritent commits séparés.
|
||||
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Package racine du backend suivi_produits."""
|
||||
BIN
backend/__pycache__/__init__.cpython-313.pyc
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Package backend principal de suivi des produits."""
|
||||
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
4
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Module routers."""
|
||||
from .routes_config import router as config_router
|
||||
from .routes_products import router as products_router
|
||||
from .routes_scrape import router as scrape_router
|
||||
13
backend/app/api/deps.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.db.database import SessionLocal
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
28
backend/app/api/routes_config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from backend.app.core.config import BackendConfig, CONFIG_PATH, load_config
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
@router.get("/backend", response_model=BackendConfig)
|
||||
def read_backend_config() -> BackendConfig:
|
||||
# expose la configuration backend en lecture seule
|
||||
return load_config()
|
||||
|
||||
|
||||
@router.put("/backend", response_model=BackendConfig)
|
||||
def update_backend_config(payload: dict = Body(...)) -> BackendConfig:
|
||||
current = load_config()
|
||||
try:
|
||||
# validation via Pydantic avant écriture
|
||||
updated = current.copy(update=payload)
|
||||
CONFIG_PATH.write_text(updated.json(indent=2, ensure_ascii=False))
|
||||
load_config.cache_clear()
|
||||
return load_config()
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
47
backend/app/api/routes_products.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.api.deps import get_db
|
||||
from backend.app.db import crud, schemas
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.ProductRead])
|
||||
def list_products(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)) -> list[schemas.ProductRead]:
|
||||
# on retourne la liste paginée de produits
|
||||
return crud.list_products(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.ProductRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(payload: schemas.ProductCreate, db: Session = Depends(get_db)) -> schemas.ProductRead:
|
||||
# création de produit rigoureuse via Pydantic
|
||||
return crud.create_product(db, payload)
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=schemas.ProductRead)
|
||||
def read_product(product_id: int, db: Session = Depends(get_db)) -> schemas.ProductRead:
|
||||
product = crud.get_product(db, product_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
||||
return product
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=schemas.ProductRead)
|
||||
def update_product(product_id: int, payload: schemas.ProductUpdate, db: Session = Depends(get_db)) -> schemas.ProductRead:
|
||||
product = crud.get_product(db, product_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
||||
# mise à jour partielle des champs éditables
|
||||
return crud.update_product(db, product, payload)
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product(product_id: int, db: Session = Depends(get_db)) -> None:
|
||||
product = crud.get_product(db, product_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
||||
# suppression définitive en base
|
||||
crud.remove_product(db, product)
|
||||
20
backend/app/api/routes_scrape.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from backend.app.scraper.runner import scrape_all, scrape_product
|
||||
|
||||
router = APIRouter(prefix="/scrape", tags=["scrape"])
|
||||
|
||||
|
||||
@router.post("/product/{product_id}")
|
||||
def trigger_single(product_id: int, background_tasks: BackgroundTasks):
|
||||
# on délègue le vrai travail à un background task rapide
|
||||
background_tasks.add_task(scrape_product, product_id)
|
||||
return {"statut": "planifie", "cible": product_id}
|
||||
|
||||
|
||||
@router.post("/all")
|
||||
def trigger_all(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(scrape_all)
|
||||
return {"statut": "planifie_tout"}
|
||||
BIN
backend/app/core/__pycache__/config.cpython-313.pyc
Normal file
47
backend/app/core/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
CONFIG_PATH = Path(__file__).resolve().parent.parent.parent / "config_backend.json"
|
||||
# chemin vers la conf JSON partagée entre les composants backend
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
env: str
|
||||
version: str
|
||||
base_url: str
|
||||
log_level: str
|
||||
|
||||
|
||||
class ScrapeConfig(BaseModel):
|
||||
interval_minutes: int
|
||||
headless: bool
|
||||
timeout_ms: int
|
||||
retries: int
|
||||
delay_range_ms: tuple[int, int]
|
||||
user_agent: str
|
||||
viewport: dict[str, int]
|
||||
locale: str
|
||||
timezone: str
|
||||
proxy: str | None
|
||||
|
||||
|
||||
class TaxonomyConfig(BaseModel):
|
||||
categories: list[str]
|
||||
types_by_category: dict[str, list[str]]
|
||||
|
||||
|
||||
class BackendConfig(BaseModel):
|
||||
app: AppConfig
|
||||
scrape: ScrapeConfig
|
||||
stores_enabled: list[str]
|
||||
taxonomy: TaxonomyConfig
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_config() -> BackendConfig:
|
||||
# on met en cache pour éviter de recharger le fichier à chaque requête
|
||||
return BackendConfig.parse_file(CONFIG_PATH)
|
||||
20
backend/app/core/logging.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
LOG_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
|
||||
# dossier de logs pour tracer les scrapes et erreurs
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOG_FILE = LOG_DIR / "scrap.log"
|
||||
|
||||
logger.add(
|
||||
LOG_FILE,
|
||||
rotation="10 MB",
|
||||
retention="7 days",
|
||||
level="INFO",
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
) # rotation simple pour ne pas gonfler les artefacts
|
||||
28
backend/app/core/scheduler.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from loguru import logger
|
||||
|
||||
from backend.app.core.config import load_config
|
||||
from backend.app.scraper.runner import scrape_all
|
||||
|
||||
scheduler = BackgroundScheduler(timezone=load_config().scrape.timezone)
|
||||
"""Scheduler interne APScheduler, prêt à déclencher scrape_all selon la config."""
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
if scheduler.running:
|
||||
return
|
||||
|
||||
config = load_config()
|
||||
interval = config.scrape.interval_minutes
|
||||
scheduler.add_job(
|
||||
scrape_all,
|
||||
trigger=IntervalTrigger(minutes=interval),
|
||||
id="scheduled-scrape-all",
|
||||
replace_existing=True,
|
||||
next_run_time=None,
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info("Scheduler démarré avec un intervalle de %s minutes", interval)
|
||||
45
backend/app/db/crud.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.db import models, schemas
|
||||
|
||||
|
||||
"""
|
||||
CRUD minimal pour manipuler les produits dans la base SQLite.
|
||||
"""
|
||||
|
||||
|
||||
def get_product(db: Session, product_id: int) -> models.Product | None:
|
||||
return db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||
|
||||
|
||||
def list_products(db: Session, skip: int = 0, limit: int = 100) -> list[models.Product]:
|
||||
return db.query(models.Product).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def create_product(db: Session, data: schemas.ProductCreate) -> models.Product:
|
||||
product = models.Product(**data.dict())
|
||||
db.add(product)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def update_product(db: Session, product: models.Product, changes: schemas.ProductUpdate) -> models.Product:
|
||||
for field, value in changes.dict(exclude_unset=True).items():
|
||||
setattr(product, field, value)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def remove_product(db: Session, product: models.Product) -> None:
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
30
backend/app/db/database.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from os import getenv
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||
# stockage SQLite + raw JSON dans backend/data
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DEFAULT_DATABASE_PATH = DATA_DIR / "suivi.db"
|
||||
DEFAULT_DATABASE_URL = f"sqlite:///{DEFAULT_DATABASE_PATH}"
|
||||
DATABASE_URL = getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
|
||||
|
||||
if DATABASE_URL.startswith("sqlite:///"):
|
||||
sqlite_path = Path(DATABASE_URL.replace("sqlite:///", "", 1))
|
||||
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
DEFAULT_DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
future=True,
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
# classe de base pour les modèles SQLAlchemy
|
||||
Base = declarative_base()
|
||||
70
backend/app/db/models.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from backend.app.db.database import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""Table principale des produits suivis."""
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
boutique = Column(String(32), nullable=False)
|
||||
url = Column(Text, nullable=False)
|
||||
asin = Column(String(20), nullable=False, index=True)
|
||||
titre = Column(Text, nullable=True)
|
||||
url_image = Column(Text, nullable=True)
|
||||
categorie = Column(String(64), nullable=True)
|
||||
type = Column(String(64), nullable=True)
|
||||
actif = Column(Boolean, default=True)
|
||||
cree_le = Column(DateTime, default=datetime.utcnow)
|
||||
modifie_le = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
snapshots = relationship("ProductSnapshot", back_populates="product")
|
||||
|
||||
|
||||
class ScrapeRun(Base):
|
||||
"""Journal des runs de scraping pour surveiller taux de succès."""
|
||||
__tablename__ = "scrape_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
demarre_le = Column(DateTime, default=datetime.utcnow)
|
||||
termine_le = Column(DateTime, nullable=True)
|
||||
statut = Column(String(32), default="pending")
|
||||
nb_total = Column(Integer, default=0)
|
||||
nb_ok = Column(Integer, default=0)
|
||||
nb_echec = Column(Integer, default=0)
|
||||
chemin_log = Column(Text, nullable=True)
|
||||
|
||||
snapshots = relationship("ProductSnapshot", back_populates="scrape_run")
|
||||
|
||||
|
||||
class ProductSnapshot(Base):
|
||||
"""Historique des snapshots capturés pour chaque produit."""
|
||||
__tablename__ = "product_snapshots"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
produit_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
run_scrap_id = Column(Integer, ForeignKey("scrape_runs.id"), nullable=True)
|
||||
scrape_le = Column(DateTime, default=datetime.utcnow)
|
||||
prix_actuel = Column(Float, nullable=True)
|
||||
prix_conseille = Column(Float, nullable=True)
|
||||
prix_min_30j = Column(Float, nullable=True)
|
||||
etat_stock = Column(String(256), nullable=True)
|
||||
en_stock = Column(Boolean, nullable=True)
|
||||
note = Column(Float, nullable=True)
|
||||
nombre_avis = Column(Integer, nullable=True)
|
||||
prime = Column(Boolean, nullable=True)
|
||||
choix_amazon = Column(Boolean, nullable=True)
|
||||
offre_limitee = Column(Boolean, nullable=True)
|
||||
exclusivite_amazon = Column(Boolean, nullable=True)
|
||||
chemin_json_brut = Column(Text, nullable=True)
|
||||
statut_scrap = Column(String(32), default="ok")
|
||||
message_erreur = Column(Text, nullable=True)
|
||||
|
||||
product = relationship("Product", back_populates="snapshots")
|
||||
scrape_run = relationship("ScrapeRun", back_populates="snapshots")
|
||||
63
backend/app/db/schemas.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
boutique: str
|
||||
url: HttpUrl
|
||||
asin: str
|
||||
titre: Optional[str]
|
||||
url_image: Optional[HttpUrl]
|
||||
categorie: Optional[str]
|
||||
type: Optional[str]
|
||||
actif: Optional[bool] = True
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
titre: Optional[str] = None
|
||||
url_image: Optional[HttpUrl] = None
|
||||
categorie: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
actif: Optional[bool] = None
|
||||
|
||||
|
||||
class ProductRead(ProductBase):
|
||||
id: int
|
||||
cree_le: datetime
|
||||
modifie_le: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class ProductSnapshotBase(BaseModel):
|
||||
prix_actuel: Optional[float]
|
||||
prix_conseille: Optional[float]
|
||||
prix_min_30j: Optional[float]
|
||||
etat_stock: Optional[str]
|
||||
en_stock: Optional[bool]
|
||||
note: Optional[float]
|
||||
nombre_avis: Optional[int]
|
||||
prime: Optional[bool]
|
||||
choix_amazon: Optional[bool]
|
||||
offre_limitee: Optional[bool]
|
||||
exclusivite_amazon: Optional[bool]
|
||||
statut_scrap: Optional[str]
|
||||
message_erreur: Optional[str]
|
||||
|
||||
|
||||
class ProductSnapshotRead(ProductSnapshotBase):
|
||||
id: int
|
||||
produit_id: int
|
||||
scrape_le: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
37
backend/app/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from os import getenv
|
||||
|
||||
from fastapi import FastAPI
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from backend.app.api import routes_config, routes_products, routes_scrape
|
||||
from backend.app.core.logging import logger
|
||||
from backend.app.core.scheduler import start_scheduler
|
||||
from backend.app.db.database import Base, engine
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI(title="suivi_produit")
|
||||
|
||||
app.include_router(routes_products.router)
|
||||
app.include_router(routes_scrape.router)
|
||||
app.include_router(routes_config.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# démarrer le scheduler APScheduler en chargeant la config
|
||||
start_scheduler()
|
||||
logger.info("Application démarrée (%s)", getenv("APP_ENV", "development"))
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
# endpoint de santé minimal
|
||||
return {"statut": "ok"}
|
||||
|
||||
|
||||
def main() -> FastAPI:
|
||||
return app
|
||||
36
backend/app/samples/amazon_product.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Disque SSD NVMe Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<input id="ASIN" value="B000000000">
|
||||
|
||||
<h1 id="productTitle">Disque SSD NVMe Test</h1>
|
||||
|
||||
<div id="corePriceDisplay_desktop_feature_div">
|
||||
<span class="a-offscreen">249,99 €</span>
|
||||
<span class="a-text-price"><span class="a-offscreen">329,99€</span></span>
|
||||
<span class="savingsPercentage">-25 %</span>
|
||||
</div>
|
||||
|
||||
<div id="priceBadging_feature_div" class="feature">
|
||||
<span class="basisPrice">
|
||||
Prix le plus bas des 30 derniers jours :
|
||||
<span class="a-offscreen">239,99€</span>
|
||||
</span>
|
||||
<i id="prime-badge" class="a-icon a-icon-prime" role="img" aria-label="prime"></i>
|
||||
</div>
|
||||
|
||||
<div id="availability"><span>En stock</span></div>
|
||||
|
||||
<div id="acrPopover"><span class="a-icon-alt">4,7 sur 5 etoiles</span></div>
|
||||
<span id="acrCustomerReviewText">1 234 evaluations</span>
|
||||
|
||||
<div id="acBadge_feature_div">Choix d'Amazon</div>
|
||||
<div id="dealBadge_feature_div">Offre a duree limitee</div>
|
||||
|
||||
<div>Exclusivite Amazon</div>
|
||||
</body>
|
||||
</html>
|
||||
9072
backend/app/samples/debug/sample-001_capture.html
Normal file
BIN
backend/app/samples/debug/sample-001_capture.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
9473
backend/app/samples/debug/sample-002_capture.html
Normal file
BIN
backend/app/samples/debug/sample-002_capture.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
10931
backend/app/samples/debug/sample-003_capture.html
Normal file
BIN
backend/app/samples/debug/sample-003_capture.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
9590
backend/app/samples/debug/sample-004_capture.html
Normal file
BIN
backend/app/samples/debug/sample-004_capture.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
9015
backend/app/samples/debug/sample-005_capture.html
Normal file
BIN
backend/app/samples/debug/sample-005_capture.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
9953
backend/app/samples/debug/sample-006_capture.html
Normal file
BIN
backend/app/samples/debug/sample-006_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
10411
backend/app/samples/debug/sample-007_capture.html
Normal file
BIN
backend/app/samples/debug/sample-007_capture.png
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
10349
backend/app/samples/debug/sample-008_capture.html
Normal file
BIN
backend/app/samples/debug/sample-008_capture.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
8083
backend/app/samples/debug/sample-009_capture.html
Normal file
BIN
backend/app/samples/debug/sample-009_capture.png
Normal file
|
After Width: | Height: | Size: 940 KiB |
748
backend/app/samples/scrap_result.json
Normal file
@@ -0,0 +1,748 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "sample-001",
|
||||
"url": "https://www.amazon.fr/dp/B0DQ8M74KL?th=1",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B0DQ8M74KL",
|
||||
"reference": "B0DQ8M74KL",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B0DQ8M74KL?th=1",
|
||||
"asin": "B0DQ8M74KL",
|
||||
"titre": "ASUS TUF Gaming A16-TUF608UH-RV054W 16 Pouces FHD Plus 165Hz Pc Portable (Processeur AMD Ryzen 7 260, 16GB DDR5, 512GB SSD, NVIDIA RTX 5050) Windows 11 Home – Clavier AZERTY",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SX679_.jpg",
|
||||
"prix_actuel": 1259.99,
|
||||
"prix_conseille": 1699.99,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": -26,
|
||||
"etat_stock": "En stock",
|
||||
"en_stock": true,
|
||||
"note": 4.7,
|
||||
"nombre_avis": 7,
|
||||
"choix_amazon": null,
|
||||
"offre_limitee": null,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": true,
|
||||
"a_propos": [
|
||||
"Le ASUS TUF Gaming A16 A16-TUF608UH-RV054 est propulsé par le processeur AMD Ryzen 7 260, offrant une fréquence de base de 3,8 GHz et pouvant atteindre jusqu'à 5,1 GHz; ce processeur à 8 cœurs et 16 threads garantit des performances remarquables pour le multitâche, le travail créatif et les applications exigeantes; avec une unité de traitement neuronal (NPU) AMD XDNA capable de réaliser jusqu'à 16TOPS, ce PC portable est conçu pour gérer les tâches les plus ardues",
|
||||
"Le ASUS TUF Gaming A16 A16-TUF608UH-RV054 offre un stockage ultra-rapide avec un disque SSD PCIe 4.0 NVMe M.2 de 512 Go, permettant des temps de chargement réduits et un accès rapide aux données; optimal pour stocker de grandes quantités de jeux, de logiciels et de fichiers multimédias sans compromis sur la vitesse",
|
||||
"L'écran 16 pouces du ASUS TUF Gaming A16 offre une résolution FHD plus (1920 x 1200) à 165 Hz et un temps de réponse de 3 ms, pour des visuels clairs et détaillés; sa technologie IPS assure de larges angles de vision, et l'anti-éblouissement réduit les reflets; Équipé de la carte graphique NVIDIA RTX 5050 avec 8 Go de GDDR7, ce modèle offre des performances graphiques remarquables, parfaites pour les jeux récents et les applications graphiques intensives",
|
||||
"Le ASUS TUF Gaming A16 A16-TUF608UH-RV054, avec son châssis robuste en Jaeger Gray, est conçu pour résister aux rigueurs de l'utilisation quotidienne tout en offrant un design élégant et moderne; il est équipé d'un clavier chiclet rétroéclairé RGB à zone unique, permettant une frappe confortable même dans des environnements peu éclairés; le large pavé tactile facilite la navigation et l'utilisation quotidienne",
|
||||
"Avec la technologie Wi-Fi 6E et Bluetooth 5.3, le ASUS TUF Gaming A16 assure une connectivité rapide et stable pour le streaming, les téléchargements et les connexions sans fil; profitez d'une expérience réseau sans interruption, même dans les environnements exigeants; doté de multiples ports, y compris RJ45 LAN, USB 4 Type-C, USB 2.0 Type-A, USB 3.2 Gen 2 Type-C, et deux USB 3.2 Gen 2 Type-A, il offre une connectivité étendue pour tous vos périphériques et accessoires"
|
||||
],
|
||||
"description": "Le ASUS TUF Gaming A16 A16-TUF608UH-RV054 est un PC portable puissant et polyvalent, optimal pour les gamers et les utilisateurs exigeants. Propulsé par le processeur AMD Ryzen 7 260, ce portable offre des performances remarquables avec une fréquence de base de 3,8 GHz et une capacité à atteindre jusqu'à 5,1 GHz. Grâce à ses 8 cœurs et 16 threads, et à l'unité de traitement neuronal AMD XDNA NPU capable de réaliser jusqu'à 16TOPS, il est conçu pour gérer les tâches les plus ardues. Équipé d'un stockage ultra-rapide avec un disque SSD PCIe 4.0 NVMe M.2 de 512 Go, il permet des temps de chargement réduits et un accès rapide aux données. Son écran de 16 pouces propose une résolution FHD plus (1920 x 1200) avec une fréquence de rafraîchissement de 165 Hz et un temps de réponse de 3 ms, offrant des visuels clairs et détaillés. La technologie IPS assure des angles de vision larges et la technologie Anti-glare réduit les reflets pour une expérience de jeu immersive. La carte graphique NVIDIA RTX 5050 avec 8 Go de mémoire GDDR7 assure des performances graphiques remarquables, impeccablees pour les jeux récents et les applications graphiques intensives. Conçu pour durer, ce PC portable dispose d'un châssis robuste en Jaeger Gray et d'une webcam HD intégrée de 1080P FHD pour des appels vidéo de haute qualité. Le clavier chiclet rétroéclairé RGB à zone unique et le grand pavé tactile offrent une expérience de frappe confortable, même dans des environnements peu éclairés. La technologie Wi-Fi 6E et Bluetooth 5.3 assure une connectivité rapide et stable, tandis que les multiples ports offrent une connectivité étendue pour tous vos périphériques et accessoires. En prime, profitez de 3 mois d'abonnement gratuit au Xbox Game Pass pour explorer une vaste bibliothèque de jeux. Le ASUS TUF Gaming A16 A16-TUF608UH-RV054 est le choix optimal pour ceux qui recherchent puissance, performance et fiabilité dans un PC portable de jeu",
|
||||
"carateristique": {
|
||||
"Marque": "ASUS",
|
||||
"Numéro du modèle de l'article": "90NR0KS1-M00480",
|
||||
"séries": "ASUS TUF Gaming",
|
||||
"Couleur": "GRAY",
|
||||
"Garantie constructeur": "3 ans contructeur",
|
||||
"Système d'exploitation": "Windows 11 Home",
|
||||
"Description du clavier": "Jeu",
|
||||
"Marque du processeur": "AMD",
|
||||
"Type de processeur": "Ryzen 7",
|
||||
"Vitesse du processeur": "3,8 GHz",
|
||||
"Nombre de coeurs": "8",
|
||||
"Mémoire maximale": "32 Go",
|
||||
"Taille du disque dur": "512 GB",
|
||||
"Technologie du disque dur": "SSD",
|
||||
"Interface du disque dur": "PCIE x 4",
|
||||
"Type d'écran": "LED",
|
||||
"Taille de l'écran": "16 Pouces",
|
||||
"Résolution de l'écran": "1920 x 1200 pixels",
|
||||
"Resolution": "1920x1200 Pixels",
|
||||
"Marque chipset graphique": "NVIDIA",
|
||||
"Description de la carte graphique": "NVIDIA GeForce RTX 5050 Laptop GPU - 8GB GDDR7",
|
||||
"GPU": "NVIDIA GeForce RTX 5050 Laptop GPU - 8GB GDDR7",
|
||||
"Mémoire vive de la carte graphique": "8 GB",
|
||||
"Type de mémoire vive (carte graphique)": "GDDR7",
|
||||
"Type de connectivité": "Bluetooth, Wi-Fi",
|
||||
"Type de technologie sans fil": "802.11ax, Bluetooth",
|
||||
"Bluetooth": "Oui",
|
||||
"Nombre de ports HDMI": "1",
|
||||
"Nombre de ports USB 2.0": "1",
|
||||
"Nombre de ports USB 3.0": "3",
|
||||
"Nombre de ports Ethernet": "1",
|
||||
"Type de connecteur": "Bluetooth, HDMI, USB, Wi-Fi",
|
||||
"Compatibilité du périphérique": "Casque audio, Clavier, Souris, Ecran externe, Disque dur externe, Imprimante, etc., Haut-parleur",
|
||||
"Poids du produit": "2,1 Kilogrammes",
|
||||
"Divers": "Clavier rétroéclairé",
|
||||
"Disponibilité des pièces détachées": "5 Ans",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "ASUS",
|
||||
"Numéro du modèle de l'article": "90NR0KS1-M00480",
|
||||
"séries": "ASUS TUF Gaming",
|
||||
"Couleur": "GRAY",
|
||||
"Garantie constructeur": "3 ans contructeur",
|
||||
"Système d'exploitation": "Windows 11 Home",
|
||||
"Description du clavier": "Jeu",
|
||||
"Marque du processeur": "AMD",
|
||||
"Type de processeur": "Ryzen 7",
|
||||
"Vitesse du processeur": "3,8 GHz",
|
||||
"Nombre de coeurs": "8",
|
||||
"Mémoire maximale": "32 Go",
|
||||
"Taille du disque dur": "512 GB",
|
||||
"Technologie du disque dur": "SSD",
|
||||
"Interface du disque dur": "PCIE x 4",
|
||||
"Type d'écran": "LED",
|
||||
"Taille de l'écran": "16 Pouces",
|
||||
"Résolution de l'écran": "1920 x 1200 pixels",
|
||||
"Resolution": "1920x1200 Pixels",
|
||||
"Marque chipset graphique": "NVIDIA",
|
||||
"Description de la carte graphique": "NVIDIA GeForce RTX 5050 Laptop GPU - 8GB GDDR7",
|
||||
"GPU": "NVIDIA GeForce RTX 5050 Laptop GPU - 8GB GDDR7",
|
||||
"Mémoire vive de la carte graphique": "8 GB",
|
||||
"Type de mémoire vive (carte graphique)": "GDDR7",
|
||||
"Type de connectivité": "Bluetooth, Wi-Fi",
|
||||
"Type de technologie sans fil": "802.11ax, Bluetooth",
|
||||
"Bluetooth": "Oui",
|
||||
"Nombre de ports HDMI": "1",
|
||||
"Nombre de ports USB 2.0": "1",
|
||||
"Nombre de ports USB 3.0": "3",
|
||||
"Nombre de ports Ethernet": "1",
|
||||
"Type de connecteur": "Bluetooth, HDMI, USB, Wi-Fi",
|
||||
"Compatibilité du périphérique": "Casque audio, Clavier, Souris, Ecran externe, Disque dur externe, Imprimante, etc., Haut-parleur",
|
||||
"Poids du produit": "2,1 Kilogrammes",
|
||||
"Divers": "Clavier rétroéclairé",
|
||||
"Disponibilité des pièces détachées": "5 Ans",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"ASIN": "B0DQ8M74KL",
|
||||
"Moyenne des commentaires client": "4,7 4,7 sur 5 étoiles (7) 4,7 sur 5 étoiles",
|
||||
"Classement des meilleures ventes d'Amazon": "4 884 en Informatique ( Voir les 100 premiers en Informatique ) 127 en Ordinateurs portables classiques",
|
||||
"Date de mise en ligne sur Amazon.fr": "1 juillet 2025"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: prix_conseille_reduction, prix_min_30j, prime, choix_amazon, offre_limitee",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-001_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-001_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-002",
|
||||
"url": "https://www.amazon.fr/dp/B0F32N1ZGH",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B0F32N1ZGH",
|
||||
"reference": "B0F32N1ZGH",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B0F32N1ZGH",
|
||||
"asin": "B0F32N1ZGH",
|
||||
"titre": "MSI NVIDIA GeForce RTX 5060 Ti 16G Inspire 2X OC Carte Graphique 16 Go GDDR7 (28 Gb/s/128 Bits), Refroidissement à Double Ventilateur (2 x Ventilateurs STORMFORCE) - HDMI 2.1b, DisplayPort 2.1b,",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/71fy0fAQoYL._AC_SX679_.jpg",
|
||||
"prix_actuel": 509.0,
|
||||
"prix_conseille": null,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": null,
|
||||
"etat_stock": "En stock",
|
||||
"en_stock": true,
|
||||
"note": 4.6,
|
||||
"nombre_avis": 211,
|
||||
"choix_amazon": true,
|
||||
"offre_limitee": null,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"GPU NVIDIA GEFORCE RTX 5060 Ti - Architecture de pointe Blackwell avec des capacités extrêmes de ray tracing RTX de 4ème génération (1080p/1440p) et une mémoire GDDR7 de 16 Go (28 Gb/s); Supporte les performances de fréquence d'images améliorées DLSS 4.0.",
|
||||
"LE STYLE MSI INSPIRE - La RTX 5060 Ti 16G INSPIRE 2X OC est une variante GPU overclockée en usine de la RTX 5060 Ti 16G INSPIRE 2X. Il s'agit du choix idéal pour les joueurs et les créateurs recherchant une carte graphique au rapport qualité-prix optimal.",
|
||||
"REFROIDISSEMENT DOUBLE VENTILATEUR STORMFORCE : 7 pales texturées, roulements à billes durables, ZERO FROZR (0 RPM), plaque en cuivre nickelé, caloducs, dissipateur thermique Airflow Control et pads thermiques maximisant la dissipation thermique.",
|
||||
"DESIGN COMPACT ET ÉLÉGANT - Une plaque arrière en métal renforcé renforce le châssis et un design ajourée réduit la chaleur accumulée : Le circuit imprimé comprend des circuits de limitation de puissance et des protections électriques de premier ordre.",
|
||||
"COMPACTE ET SANS LIMITES – Carte 2,5 slots (PCIe 5.0 x8), 204 mm, 657g, alimentation recommandée 650 W+ (8 broches, 180 W). Ports arrière : 3 x DisplayPort 2.1b et 1 x HDMI 2.1b (4K/480Hz)."
|
||||
],
|
||||
"description": "MSI GeForce RTX 5060 Ti 16G INSPIRE 2X OC : Grande vision, petit format La GeForce RTX 5060 Ti 16G INSPIRE 2X OC est la variante GPU overclockée en usine de sa sœur, la GeForce RTX 5060 Ti 16G INSPIRE 2X. Les cartes MSI GeForce RTX 5060 Ti 16G INSPIRE 2X OC sont équipées de solutions de refroidissement efficaces à double ventilateur et de circuits imprimés durables pour un rapport qualité-prix optimal dans les jeux et la création de contenu. La carte NVIDIA GeForce RTX 5060 Ti offre les performances et les fonctionnalités nécessaires aux joueurs passionnés et aux créateurs. Donnez vie à vos jeux et à vos projets créatifs grâce au ray tracing et aux graphismes alimentés par l'IA. Elle est dotée de l'architecture NVIDIA Blackwell et de 16 Go de mémoire G7 ultra-rapide. L'ingénierie thermique de la MSI RTX 5060 Ti 16G INSPIRE 2X OC comprend deux ventilateurs STORMFORCE, le mode ZERO FROZR, une plaque de base en cuivre nickelé, des caloducs de précision courant sur toute la longueur de la carte, un remarquable radiateur Airflow Control avec des ailettes antégrades Wave-Curved 3.0 , sans oublier de nombreux pads thermiques. Pour résumer, la MSI GeForce RTX 5060 Ti 16G INSPIRE 2X OC est une solution améliorée pour les créateurs et les joueurs à la recherche d'un excellent rapport qualité-prix dans la catégorie grand public des GPU NVIDIA de la série 50.",
|
||||
"carateristique": {
|
||||
"Marque": "MSI",
|
||||
"Numéro du modèle de l'article": "V535-007R",
|
||||
"séries": "RTX5060Ti Inspire 2X OC 16GB",
|
||||
"Couleur": "RTX 5060 Ti 16G INSPIRE 2X OC",
|
||||
"Garantie constructeur": "3 ans",
|
||||
"Resolution": "3840x2160",
|
||||
"Marque chipset graphique": "NVIDIA",
|
||||
"Fréquence du GPU": "2617 MHz",
|
||||
"Description de la carte graphique": "MSI GeForce RTX 5060 Ti 16G Inspire 2X OC avec architecture NVIDIA Blackwell, 16 Go GDDR7, fréquence d'horloge Boost de 2617 MHz et fréquence d'horloge de mémoire de 2407 MHz.",
|
||||
"GPU": "NVIDIA GeForce RTX 5060 Ti",
|
||||
"Mémoire vive de la carte graphique": "16 GB",
|
||||
"Type de mémoire vive (carte graphique)": "GDDR7",
|
||||
"Interface du matériel informatique": "PCI Express x16",
|
||||
"Compatibilité du périphérique": "Ordinateur",
|
||||
"Dimensions de l'article L x L x H": "20.4 x 117 x 0.1 centimètres",
|
||||
"Poids du produit": "657 Grammes",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "MSI",
|
||||
"Numéro du modèle de l'article": "V535-007R",
|
||||
"séries": "RTX5060Ti Inspire 2X OC 16GB",
|
||||
"Couleur": "RTX 5060 Ti 16G INSPIRE 2X OC",
|
||||
"Garantie constructeur": "3 ans",
|
||||
"Resolution": "3840x2160",
|
||||
"Marque chipset graphique": "NVIDIA",
|
||||
"Fréquence du GPU": "2617 MHz",
|
||||
"Description de la carte graphique": "MSI GeForce RTX 5060 Ti 16G Inspire 2X OC avec architecture NVIDIA Blackwell, 16 Go GDDR7, fréquence d'horloge Boost de 2617 MHz et fréquence d'horloge de mémoire de 2407 MHz.",
|
||||
"GPU": "NVIDIA GeForce RTX 5060 Ti",
|
||||
"Mémoire vive de la carte graphique": "16 GB",
|
||||
"Type de mémoire vive (carte graphique)": "GDDR7",
|
||||
"Interface du matériel informatique": "PCI Express x16",
|
||||
"Compatibilité du périphérique": "Ordinateur",
|
||||
"Dimensions de l'article L x L x H": "20.4 x 117 x 0.1 centimètres",
|
||||
"Poids du produit": "657 Grammes",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"ASIN": "B0F32N1ZGH",
|
||||
"Moyenne des commentaires client": "4,6 4,6 sur 5 étoiles (211) 4,6 sur 5 étoiles",
|
||||
"Classement des meilleures ventes d'Amazon": "480 en Informatique ( Voir les 100 premiers en Informatique ) 6 en Cartes graphiques",
|
||||
"Date de mise en ligne sur Amazon.fr": "16 avril 2025"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: prix_conseille, prix_conseille_reduction, prix_min_30j, prix_min_30j_reduction, prime, offre_limitee, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-002_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-002_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-003",
|
||||
"url": "https://www.amazon.fr/dp/B0DWFLPMM5",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B0DWFLPMM5",
|
||||
"reference": "B0DWFLPMM5",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B0DWFLPMM5",
|
||||
"asin": "B0DWFLPMM5",
|
||||
"titre": "Samsung SSD Interne 9100 Pro, NVMe 2.0 PCIe 5.0x4, Capacité 2To, Vitesse de Lecture jusqu'à 14 800 Mo/s, Les Performances de la Gen5, MZ-VAP2T0BW",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/71Mqh+maSWL._AC_SX679_.jpg",
|
||||
"prix_actuel": 249.99,
|
||||
"prix_conseille": 329.99,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": -24,
|
||||
"etat_stock": null,
|
||||
"en_stock": null,
|
||||
"note": 4.7,
|
||||
"nombre_avis": 967,
|
||||
"choix_amazon": null,
|
||||
"offre_limitee": null,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"Les performances de la Gen5 : Interface PCIe 5.0x4 et vitesse de lecture/écriture séquentielle jusqu'à 14 800/13 400 Mo/s¹, soit presque deux fois plus rapide que le 990 PRO².",
|
||||
"Une expérience amplifiée : chargements instantanés de vos jeux, performances de pointe sur vos logiciels, applications d'intelligence artificielle et de nombreuses compatibilités³.",
|
||||
"Un contrôle thermique innovant : jusqu'à 49% d'amélioration de l'efficacité énergétique par rapport au 990 PRO⁴ grâce à un contrôle thermique avancé.",
|
||||
"Logiciel de gestion Samsung Magician⁵ : pour surveiller l'état de santé du SSD et bénéficier des dernières mises à jour du micrologiciel.",
|
||||
"Contenu : 1 x Samsung SSD Interne 9100 PRO 2To, MZ-VAP2T0BW, Notice d'utilisation incluse"
|
||||
],
|
||||
"description": "Des performances pour une nouvelle ère. Exploitez les performances du PCIe® 5.0 et vivez la révolution avec le SSD 9100 PRO en atteignant des vitesses séquentielles jusqu'à 14 800/13 400 Mo/s¹, presque deux fois plus rapide que le 990 PRO². Naviguez entre le montage vidéo, l'art 3D, les jeux HD et restez prêt pour tout ce qu’il vous attend avec une capacité étendue allant jusqu'à 8 To³. Le 9100 PRO vous permet de construire des systèmes plus efficaces et plus performants sur votre PC, votre ordinateur portable ou sur votre PlayStation®5⁶.",
|
||||
"carateristique": {
|
||||
"Marque": "Samsung",
|
||||
"Numéro du modèle de l'article": "MZ-VAP2T0BW",
|
||||
"séries": "9100 PRO",
|
||||
"Couleur": "Noir",
|
||||
"Garantie constructeur": "5 ans",
|
||||
"Plate-forme du matériel informatique": "Mac, PC",
|
||||
"Taille du disque dur": "2 TB",
|
||||
"Technologie du disque dur": "Disque SSD",
|
||||
"Interface du disque dur": "PCIE x 4",
|
||||
"Type de carte mémoire": "TLC",
|
||||
"Interface du matériel informatique": "PCIE x 4",
|
||||
"Type de connecteur": "PCIe",
|
||||
"Compatibilité du périphérique": "Console de jeu, Ordinateur, Ordinateur portable",
|
||||
"Dimensions de l'article L x L x H": "8 x 2.2 x 0.3 centimètres",
|
||||
"Poids du produit": "9 Grammes",
|
||||
"Divers": "Chiffrement matériel",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "Samsung",
|
||||
"Numéro du modèle de l'article": "MZ-VAP2T0BW",
|
||||
"séries": "9100 PRO",
|
||||
"Couleur": "Noir",
|
||||
"Garantie constructeur": "5 ans",
|
||||
"Plate-forme du matériel informatique": "Mac, PC",
|
||||
"Taille du disque dur": "2 TB",
|
||||
"Technologie du disque dur": "Disque SSD",
|
||||
"Interface du disque dur": "PCIE x 4",
|
||||
"Type de carte mémoire": "TLC",
|
||||
"Interface du matériel informatique": "PCIE x 4",
|
||||
"Type de connecteur": "PCIe",
|
||||
"Compatibilité du périphérique": "Console de jeu, Ordinateur, Ordinateur portable",
|
||||
"Dimensions de l'article L x L x H": "8 x 2.2 x 0.3 centimètres",
|
||||
"Poids du produit": "9 Grammes",
|
||||
"Divers": "Chiffrement matériel",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"ASIN": "B0DWFLPMM5",
|
||||
"Moyenne des commentaires client": "4,7 4,7 sur 5 étoiles (967) 4,7 sur 5 étoiles",
|
||||
"Classement des meilleures ventes d'Amazon": "42 en Informatique ( Voir les 100 premiers en Informatique ) 2 en SSD internes",
|
||||
"Date de mise en ligne sur Amazon.fr": "10 mars 2025"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: prix_conseille_reduction, prix_min_30j, etat_stock, en_stock, prime, choix_amazon, offre_limitee, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-003_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-003_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-004",
|
||||
"url": "https://www.amazon.fr/dp/B07RW6Z692?th=1",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B07RW6Z692",
|
||||
"reference": "B07RW6Z692",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B07RW6Z692?th=1",
|
||||
"asin": "B07RW6Z692",
|
||||
"titre": "Corsair Vengeance LPX DDR4 RAM 32Go (2x16Go) 3200MHz CL16 Intel XMP 2.0 Mémoire d'ordinateur - Noir (CMK32GX4M2E3200C16)",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/61wCOVcyvFL._AC_SX679_.jpg",
|
||||
"prix_actuel": 230.54,
|
||||
"prix_conseille": null,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": null,
|
||||
"etat_stock": "En stock",
|
||||
"en_stock": true,
|
||||
"note": 4.8,
|
||||
"nombre_avis": 28247,
|
||||
"choix_amazon": true,
|
||||
"offre_limitee": null,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"Conçu pour l'overclocking haute performance : Chaque module VENGEANCE LPX est équipé d'un diffuseur de chaleur en aluminium pur pour une dissipation plus rapide de la chaleur",
|
||||
"Conçu pour être beau : Disponible en plusieurs couleurs pour s'harmoniser avec votre carte mère, vos composants ou simplement votre style",
|
||||
"Vengeance LPX est optimisé et testé pour offre des fréquences plus élevées, une plus grande bande passante et une faible consommation d'énergie",
|
||||
"Performance et compatibilité : le VENGEANCE LPX est optimisé et testé pour la compatibilité avec les dernières et offre des fréquences plus élevées",
|
||||
"Conception du diffuseur de chaleur à profil bas : la hauteur du module VENGEANCE LPX a été soigneusement conçue pour s'intégrer dans des pièces plus petites"
|
||||
],
|
||||
"description": "DDR4, 3200MHz 32GB 2 x 288 DIMM, Unbuffered, 16-20-20-38, Vengeance LPX Black Heat spreader, 1.35V, XMP 2.0",
|
||||
"carateristique": {
|
||||
"Marque": "Corsair",
|
||||
"Numéro du modèle de l'article": "CMK32GX4M2E3200C16",
|
||||
"séries": "CMK32GX4M2E3200C16",
|
||||
"Couleur": "Noir",
|
||||
"Garantie constructeur": "Garantie à vie",
|
||||
"Système d'exploitation": "Windows 10 Home",
|
||||
"Taille de la mémoire vive": "32 Go",
|
||||
"Taille du disque dur": "32 GB",
|
||||
"Type de carte mémoire": "DDR4",
|
||||
"Taille de l'écran": "15,6 Pouces",
|
||||
"Bluetooth": "Non",
|
||||
"Compatibilité du périphérique": "Ordinateur de bureau",
|
||||
"Dimensions de l'article L x L x H": "13.5 x 0.7 x 3.4 centimètres",
|
||||
"Divers": "Refroidissement : dissipateur thermique.",
|
||||
"Disponibilité des pièces détachées": "2 Ans",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "Corsair",
|
||||
"Numéro du modèle de l'article": "CMK32GX4M2E3200C16",
|
||||
"séries": "CMK32GX4M2E3200C16",
|
||||
"Couleur": "Noir",
|
||||
"Garantie constructeur": "Garantie à vie",
|
||||
"Système d'exploitation": "Windows 10 Home",
|
||||
"Taille de la mémoire vive": "32 Go",
|
||||
"Taille du disque dur": "32 GB",
|
||||
"Type de carte mémoire": "DDR4",
|
||||
"Taille de l'écran": "15,6 Pouces",
|
||||
"Bluetooth": "Non",
|
||||
"Compatibilité du périphérique": "Ordinateur de bureau",
|
||||
"Dimensions de l'article L x L x H": "13.5 x 0.7 x 3.4 centimètres",
|
||||
"Divers": "Refroidissement : dissipateur thermique.",
|
||||
"Disponibilité des pièces détachées": "2 Ans",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"ASIN": "B07RW6Z692",
|
||||
"Moyenne des commentaires client": "4,8 4,8 sur 5 étoiles (28 247) 4,8 sur 5 étoiles",
|
||||
"Classement des meilleures ventes d'Amazon": "210 en Informatique ( Voir les 100 premiers en Informatique ) 3 en Mémoire RAM",
|
||||
"Date de mise en ligne sur Amazon.fr": "8 novembre 2018"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: prix_conseille, prix_conseille_reduction, prix_min_30j, prix_min_30j_reduction, prime, offre_limitee, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-004_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-004_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-005",
|
||||
"url": "https://www.amazon.fr/dp/B08GSTF5NJ?th=1",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B08GSTF5NJ",
|
||||
"reference": "B08GSTF5NJ",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B08GSTF5NJ?th=1",
|
||||
"asin": "B08GSTF5NJ",
|
||||
"titre": "Corsair Vengeance SODIMM 32Go (2x16Go) DDR4 3200MHz C22 Mémoire pour Ordinateur Portable/Notebook - Noir",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/51mcs2TCKzL._AC_SX679_.jpg",
|
||||
"prix_actuel": 203.74,
|
||||
"prix_conseille": null,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": null,
|
||||
"etat_stock": null,
|
||||
"en_stock": null,
|
||||
"note": 4.7,
|
||||
"nombre_avis": 8626,
|
||||
"choix_amazon": null,
|
||||
"offre_limitee": null,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"Améliorez la mémoire DDR4 de votre ordinateur portable professionnel ou de jeu les modules de mémoire DDR4 atteignent des fréquences plus élevées et offrent une capacité exceptionnelle, une consommation d'énergie et des performances réduites",
|
||||
"Installation facile un tournevis suffit pour insérer les modules dans la plupart des ordinateurs portables",
|
||||
"Fonctionnement à pleine vitesse les modules VENGEANCE SODIMM peuvent être automatiquement réglés à pleine vitesse sur les systèmes compatibles pour des temps de chargement plus rapides",
|
||||
"Fiabilité rigoureusement testés",
|
||||
"Remarque Nous vous recommandons vivement de ne PAS combiner plusieurs kits de mémoire CORSAIR DDR4. Nos kits de mémoire ne sont validés pour leurs performances nominales qu'en utilisant uniquement les modules fournis dans ce kit spécifique (boîte)"
|
||||
],
|
||||
"description": "Le kit de mémoire Corsair Vengeance SODIMM haute performance, 3200 MHz CL22 1.2V, vous permet d'augmenter automatiquement les performances de votre sans reconfiguration du BIOS. Conception mince et attrayante pour assurer la compatibilité physique avec tous les ordinateurs portables Intel Core ™ i7 de 8e génération ou plus récents et AMD Ryzen série 4000. Chaque module est construit en utilisant une DRAM soigneusement sélectionnée pour permettre une excellente stabilité et est couvert par la garantie à vie limitée de Corsair.",
|
||||
"carateristique": {
|
||||
"Marque": "Corsair",
|
||||
"Numéro du modèle de l'article": "CMSX32GX4M2A3200C22",
|
||||
"séries": "Corsair Vengeance Performance",
|
||||
"Couleur": "multicolour",
|
||||
"Garantie constructeur": "Garantie à vie",
|
||||
"Taille de la mémoire vive": "32 Go",
|
||||
"Compatibilité du périphérique": "Ordinateur portable",
|
||||
"Dimensions de l'article L x L x H": "60 x 60 x 85 centimètres",
|
||||
"Poids du produit": "8 Grammes",
|
||||
"Divers": "Portable/notebook",
|
||||
"Disponibilité des pièces détachées": "2 Ans",
|
||||
"Mises à jour logicielles garanties jusqu’à": "13 avril 2030"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "Corsair",
|
||||
"Numéro du modèle de l'article": "CMSX32GX4M2A3200C22",
|
||||
"séries": "Corsair Vengeance Performance",
|
||||
"Couleur": "multicolour",
|
||||
"Garantie constructeur": "Garantie à vie",
|
||||
"Taille de la mémoire vive": "32 Go",
|
||||
"Compatibilité du périphérique": "Ordinateur portable",
|
||||
"Dimensions de l'article L x L x H": "60 x 60 x 85 centimètres",
|
||||
"Poids du produit": "8 Grammes",
|
||||
"Divers": "Portable/notebook",
|
||||
"Disponibilité des pièces détachées": "2 Ans",
|
||||
"Mises à jour logicielles garanties jusqu’à": "13 avril 2030",
|
||||
"ASIN": "B08GSTF5NJ",
|
||||
"Moyenne des commentaires client": "4,7 4,7 sur 5 étoiles (8 626) 4,7 sur 5 étoiles",
|
||||
"Classement des meilleures ventes d'Amazon": "2 130 en Informatique ( Voir les 100 premiers en Informatique ) 41 en Mémoire RAM",
|
||||
"Date de mise en ligne sur Amazon.fr": "25 août 2020"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: prix_conseille, prix_conseille_reduction, prix_min_30j, prix_min_30j_reduction, etat_stock, en_stock, prime, choix_amazon, offre_limitee, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-005_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-005_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-006",
|
||||
"url": "https://www.amazon.fr/dp/B0CB4FDJT5?th=1",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B0CB4FDJT5",
|
||||
"reference": "B0CB4FDJT5",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B0CB4FDJT5?th=1",
|
||||
"asin": "B0CB4FDJT5",
|
||||
"titre": "MSI Modern MD2412P Écran Bureautique 23.8\" Full HD - Dalle IPS 1920x1080, 100Hz, Confort Oculaire, Montage VESA, Haut-Parleurs Intégrés, Display Kit, Réglable 4 Directions - HDMI 1.4b, USB Type-C",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/71bjTFOkcDL._AC_SX679_.jpg",
|
||||
"prix_actuel": 119.99,
|
||||
"prix_conseille": 149.99,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": -20,
|
||||
"etat_stock": "En stock",
|
||||
"en_stock": true,
|
||||
"note": 4.5,
|
||||
"nombre_avis": 3872,
|
||||
"choix_amazon": true,
|
||||
"offre_limitee": true,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"23.8\" FHD - Dalle IPS 23.8\" (angle de vision large 178°), résolution Full HD (1920x1080); Taux de rafraîchissement élevé 100Hz (1ms MPRT/4ms GtG) pour une expérience visuelle quotidienne améliorée avec des fréquences d'images plus fluides & plus rapides",
|
||||
"QUALITÉ D'IMAGE - Supporte une gamme de couleurs sRGB 114% (6 bits + FRC, 16.7 millions de couleurs), luminosité 300 nits & rapport de contraste 1000:1; L'app MSI Display Kit permet des paramètres d'affichage et de productivité supplémentaires",
|
||||
"CONFORT VISUEL - Les caractéristiques certifiées TÜV Rheinland Eye Comfort incluent des filtres de réduction de la lumière bleue & une technologie anti-scintillement; Traitement de surface antireflet et paramètres de mode éco par défaut",
|
||||
"ULTRA-FLEXIBLE - Orientation verticale ou horizontale, pied de support ajustable dans 4 directions; Supports VESA 75mm inclus pour montage mural ou sur bras (ex. MSI VESA Arm MT81); Haut-Parleurs 2W intégrés, pratiques pour les conférences téléphoniques",
|
||||
"MULTI CONNECTIVITÉ - Supporte plusieurs sources via ports HDMI 1.4b (1920x1080/100Hz, HDMI-CEC pour consoles), USB Type-C, supporte FreeSync; Inclus également verrou Kensington & sortie casque"
|
||||
],
|
||||
"description": "Le moniteur de bureau MSI Modern MD2412P est doté d'une dalle IPS Full HD (1920x1080) nette de 23.8\" avec une technologie respectueuse des yeux et d'un pied de support ajustable dans 4 directions qui prend en charge les orientations horizontales & verticales de l'écran. Conçu pour améliorer la productivité du travail, le moniteur dispose d'un taux de rafraîchissement élevé de 100Hz (1ms MPRT, temps de réponse GtG de 4ms, prise en charge de la synchronisation adaptative FreeSync) et de plusieurs fonctionnalités prenant soin des yeux, y compris la technologie anti-scintillement & réduction de la lumière bleue certifiée TÜV Rheinland Eye Comfort, ainsi qu'une finition d'écran antireflet pour réduire la fatigue oculaire. Les caractéristiques supplémentaires incluent un pied de support réglable en inclinaison, hauteur, pivotement et rotation (montage VESA 75mm) pour les formats de moniteur portrait ou paysage, des haut-parleurs 2W intégrés pour les conférences téléphoniques, et la prise en charge de l'application Display Kit pour des paramètres de couleur et d'affichage avancés. Les options de connectivité incluent les ports HDMI 1.4b (HDMI-CEC) et USB Type-C pour diverses configurations d'affichage ou plusieurs entrées d'appareils. Compatible avec PC, Mac, ordinateur portable, appareils mobiles et consoles de jeu.",
|
||||
"carateristique": {
|
||||
"Marque": "MSI",
|
||||
"Numéro du modèle de l'article": "9S6-3PA59H-060",
|
||||
"séries": "Modern MD2412P",
|
||||
"Couleur": "Noir",
|
||||
"Garantie constructeur": "2 ans constructeur",
|
||||
"Type d'écran": "IPS",
|
||||
"Taille de l'écran": "23,8 Pouces",
|
||||
"Résolution de l'écran": "1920 x 1080",
|
||||
"Resolution": "1920 x 1080 Pixels",
|
||||
"Étirement": "16:9",
|
||||
"Interface du matériel informatique": "HDMI, USB Type C",
|
||||
"Nombre de ports HDMI": "1",
|
||||
"Type de connecteur": "HDMI, USB Type C",
|
||||
"Compatibilité du périphérique": "Bureau, Haut-parleur, Ordinateur, Ordinateur portable, Smartphone, Tablette",
|
||||
"Dimensions de l'article L x L x H": "20 x 54.1 x 49 centimètres",
|
||||
"Poids du produit": "4,65 Kilogrammes",
|
||||
"Divers": "Filtre à lumière bleue, Haut-parleur intégré, Réglage de l'inclinaison, Écran anti-reflet",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "MSI",
|
||||
"Numéro du modèle de l'article": "9S6-3PA59H-060",
|
||||
"séries": "Modern MD2412P",
|
||||
"Couleur": "Noir",
|
||||
"Garantie constructeur": "2 ans constructeur",
|
||||
"Type d'écran": "IPS",
|
||||
"Taille de l'écran": "23,8 Pouces",
|
||||
"Résolution de l'écran": "1920 x 1080",
|
||||
"Resolution": "1920 x 1080 Pixels",
|
||||
"Étirement": "16:9",
|
||||
"Interface du matériel informatique": "HDMI, USB Type C",
|
||||
"Nombre de ports HDMI": "1",
|
||||
"Type de connecteur": "HDMI, USB Type C",
|
||||
"Compatibilité du périphérique": "Bureau, Haut-parleur, Ordinateur, Ordinateur portable, Smartphone, Tablette",
|
||||
"Dimensions de l'article L x L x H": "20 x 54.1 x 49 centimètres",
|
||||
"Poids du produit": "4,65 Kilogrammes",
|
||||
"Divers": "Filtre à lumière bleue, Haut-parleur intégré, Réglage de l'inclinaison, Écran anti-reflet",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"ASIN": "B0CB4FDJT5",
|
||||
"Moyenne des commentaires client": "4,5 4,5 sur 5 étoiles (3 872) 4,5 sur 5 étoiles",
|
||||
"Classement des meilleures ventes d'Amazon": "43 en Informatique ( Voir les 100 premiers en Informatique ) 1 en Écrans PC",
|
||||
"Date de mise en ligne sur Amazon.fr": "4 juillet 2023"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: prix_conseille_reduction, prix_min_30j, prime, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-006_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-006_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-007",
|
||||
"url": "https://www.amazon.fr/dp/B0FGHX59G2",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B0FGHX59G2",
|
||||
"reference": "B0FGHX59G2",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B0FGHX59G2",
|
||||
"asin": "B0FGHX59G2",
|
||||
"titre": "UGREEN Revodok Pro Docking Station USB C 2 DisplayPort 4K 120Hz et 1 HDMI 4K 60Hz Triple Écran Extension 14 en 1 Hub Station d'Accueil avec Multi USB 10Gbps Ethernet Gigabit 100W PD Charge SD TF 3.5mm",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/61AuBDPwDvL._AC_SY355_.jpg",
|
||||
"prix_actuel": 118.99,
|
||||
"prix_conseille": 127.49,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": -30,
|
||||
"prix_min_30j_reduction": -7,
|
||||
"etat_stock": "En stock",
|
||||
"en_stock": true,
|
||||
"note": 4.6,
|
||||
"nombre_avis": 46,
|
||||
"choix_amazon": null,
|
||||
"offre_limitee": true,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"[ 14 en 1 Extension ] Cette docking station USB C vous offre 2*DisplayPort 4K 120Hz, 1*HDMI 4K@60Hz, 2*USB-C 10Gbps, 1* USB-A 10Gbps, 2*USB-A 5Gbps, 1*Ethernet Gigabit, 1*USB-C PD Out 27W, lecteur de carte SD/TF, 1*audio 3.5mm et 1*DC IN 24V/5.83A 140W. Station d'accueil multifonctionnelle, répondant à tous vos besoins en matière de connectivité. Note : veuillez confirmer que le port USB C de votre ordinateur est à fonction complète(DP Alt Mode/PD Charge/Data Transfert).",
|
||||
"[ Triple Affichage ] Avec 2 DisplayPort 4K@120Hz et 1 HDMI 4K@60Hz, ce dock USB C vous permet de connecter 3 écrans externes pour faire du multitâche sans effort. Connectez un écran prenant en charge jusqu'à 4K@120Hz. Connectez simultanément trois écrans externes avec les résolutions suivantes:4K@60Hz×2 + 4K@30Hz×1. Le PC Windows supporte plusieurs écrans externes affichant des contenus différents, tandis que le PC macOS ne supporte que tous les écrans externes affichant le même contenu.",
|
||||
"[ Data Transfert Rapide ] Station d'accueil USB C est équipée de 3 data ports USB 3.2 (2C1A) prenant en charge le transfert de données à vitesse rapide de 10Gbps, parfait pour connecter le disque dur, la clé USB, et vous pouvez connecter la souris ou le clavier sur deux ports USB-A 3.0. De plus, le dock USB C avec lecteur de cartes SD et TF de 170MB/s max répond à vos besoins de transmission différents. Note : les data ports ne supportent pas la sortie du signal vidéo et la charge.",
|
||||
"[ Fonctions Supplémentaires ] La station d'accueil pour pc portable est équipée d'un port Ethernet RJ45 1000Mbps, d'un port USB-C PD100W upstream (pour Hôte), d'un port USB-C PD 27W downstream (pour périphérique) et d'un port 3.5mm de sortie audio ou d'entrée micro, répondant à vos besoins de connectivité pour la mise en réseau, le chargement, l'audio, etc. Note : les ports de charge USB-C ne supportent pas les transferts de données et la sortie du signal vidéo.",
|
||||
"[ Petits Conseils ] 1. Nous vous déconseillons de connecter le téléphone mobile ou la tablette pour l'utilisation. 2. Veuillez confirmer que le port USB C de votre ordinateur supporte le DP Alt Mode pour faire fonctionner les portsDP et le port HDMI. 3. Les ordinateurs macOS supportent uniquement le mode SST, tandis que Windows prend en charge le mode MST. 4. Avant utilisation, veuillez connecter l'adaptateur secteur fourni pour alimenter le dock."
|
||||
],
|
||||
"description": null,
|
||||
"carateristique": {
|
||||
"Marque": "UGREEN",
|
||||
"Nombre total de ports USB": "8",
|
||||
"Appareils compatibles": "Dell Latitude 7370",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "UGREEN",
|
||||
"Nombre total de ports USB": "8",
|
||||
"Appareils compatibles": "Dell Latitude 7370",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"Moyenne des commentaires client": "4,6 4,6 sur 5 étoiles (46) 4,6 sur 5 étoiles",
|
||||
"Numéro du modèle de l'article": "CM843",
|
||||
"ASIN": "B0FGHX59G2",
|
||||
"Classement des meilleures ventes d'Amazon": "32 en Stations d'accueil pour ordinateur portable",
|
||||
"Date de mise en ligne sur Amazon.fr": "25 août 2025"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: description, prix_min_30j, prime, choix_amazon, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-007_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-007_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-008",
|
||||
"url": "https://www.amazon.fr/dp/B0BYNZXFM2",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B0BYNZXFM2",
|
||||
"reference": "B0BYNZXFM2",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B0BYNZXFM2",
|
||||
"asin": "B0BYNZXFM2",
|
||||
"titre": "Anker Prime External Battery 20000 mAh Power Bank, Portable Charger 200 W, Affichage numérique Intelligent, 2 Ports USB-C et 1 USB-A compatibles avec iPhone 17/16, Samsung, MacBook, Dell, etc.",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/61bwy5NsMVL._AC_SX679_.jpg",
|
||||
"prix_actuel": 99.99,
|
||||
"prix_conseille": null,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": null,
|
||||
"etat_stock": "En stock",
|
||||
"en_stock": true,
|
||||
"note": 4.2,
|
||||
"nombre_avis": 3713,
|
||||
"choix_amazon": null,
|
||||
"offre_limitee": null,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"Sortie totale de 200 W : grâce à deux ports USB-C haute puissance et un port USB-A totalisant une sortie de 200 W, chargez rapidement deux ordinateurs portables simultanément à 100 W chacun pour une efficacité maximale.",
|
||||
"Recharge en un éclair : la recharge rapide de 100 W via le port USB-C permet de recharger complètement la banque d'alimentation en 1 heure et 15 minutes.",
|
||||
"Alimentation en voyage : avec une taille compacte de 4,9 x 2,1 x 1,9 po, la banque d'alimentation de 20 000 mAh est conçue pour se glisser facilement dans votre sac, ce qui la rend pratique pour les voyages et vous assure de toujours disposer d'une alimentation fiable lors de vos déplacements.",
|
||||
"Informations en temps réel : restez informé grâce à l'affichage numérique intelligent qui fournit des informations en temps réel sur la capacité restante de la batterie et la puissance de sortie, vous offrant un contrôle et une visibilité complets sur la banque d'alimentation.",
|
||||
"Contenu de la boîte : banque d'alimentation Anker Prime 20 000 mAh (200 W), câble de charge USB-C vers USB-C 2 pi/0,6 m, pochette de voyage, guide de démarrage rapide, garantie de 24 mois sans souci et service client convivial."
|
||||
],
|
||||
"description": null,
|
||||
"carateristique": {
|
||||
"Marque": "Anker",
|
||||
"Couleur": "Black",
|
||||
"Distance focale": "USB Type A, USB Type C",
|
||||
"Caractéristiques spéciales": "Charge rapide, Portable, Écran numérique",
|
||||
"Nombre total de ports USB": "3",
|
||||
"Appareils compatibles": "Ordinateur portable, Smartphone",
|
||||
"Type de batterie": "Lithium-ion",
|
||||
"Garantie constructeur": "2 ans constructeur",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "Anker",
|
||||
"Couleur": "Black",
|
||||
"Distance focale": "USB Type A, USB Type C",
|
||||
"Caractéristiques spéciales": "Charge rapide, Portable, Écran numérique",
|
||||
"Nombre total de ports USB": "3",
|
||||
"Appareils compatibles": "Ordinateur portable, Smartphone",
|
||||
"Type de batterie": "Lithium-ion",
|
||||
"Garantie constructeur": "2 ans constructeur",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"Moyenne des commentaires client": "4,2 4,2 sur 5 étoiles (3 713) 4,2 sur 5 étoiles",
|
||||
"Numéro du modèle de l'article": "A1336",
|
||||
"ASIN": "B0BYNZXFM2",
|
||||
"Classement des meilleures ventes d'Amazon": "16 846 en High-Tech ( Voir les 100 premiers en High-Tech ) 347 en Blocs d'alimentation portatifs pour téléphone portable",
|
||||
"Date de mise en ligne sur Amazon.fr": "31 juillet 2023"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: description, prix_conseille, prix_conseille_reduction, prix_min_30j, prix_min_30j_reduction, prime, choix_amazon, offre_limitee, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-008_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-008_capture.html'}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sample-009",
|
||||
"url": "https://www.amazon.fr/dp/B0CWLSQ8FS",
|
||||
"url_canonique": "https://www.amazon.fr/dp/B0CWLSQ8FS",
|
||||
"reference": "B0CWLSQ8FS",
|
||||
"statut": "ok",
|
||||
"donnees": {
|
||||
"url": "https://www.amazon.fr/dp/B0CWLSQ8FS",
|
||||
"asin": "B0CWLSQ8FS",
|
||||
"titre": "Crucial Basics Mémoire pour Ordinateur de Bureau DDR4 3200 MT/s CL22 UDIMM 288 Broches 1,2 V 8 Go",
|
||||
"url_image_principale": "https://m.media-amazon.com/images/I/51h-KCaB5vL._AC_SX679_.jpg",
|
||||
"prix_actuel": 80.0,
|
||||
"prix_conseille": null,
|
||||
"prix_min_30j": null,
|
||||
"prix_conseille_reduction": null,
|
||||
"prix_min_30j_reduction": null,
|
||||
"etat_stock": "Habituellement expédié sous 5 à 6 jours",
|
||||
"en_stock": null,
|
||||
"note": 3.7,
|
||||
"nombre_avis": 27,
|
||||
"choix_amazon": null,
|
||||
"offre_limitee": null,
|
||||
"prime": null,
|
||||
"exclusivite_amazon": null,
|
||||
"a_propos": [
|
||||
"Crucial Basics Mémoire pour ordinateur de bureau DDR4 3200 MT/s CL22 UDIMM 288 broches 1,2 V 8 Go",
|
||||
"Une mise à niveau de la mémoire devrait vous permettre de charger des programmes plus rapidement, d'améliorer la réactivité de votre ordinateur portable et d'offrir des applications multitâches et d'exécution fluides et des applications gourmandes en données.",
|
||||
"Vous verrez également de grandes améliorations dans le processus de travail encore plus simple mais important avec les navigateurs Web et les feuilles de calcul.",
|
||||
"L'installation de la mémoire est aussi simple que d'ouvrir votre ordinateur, de localiser les emplacements de mémoire et d'insérer les modules. C'est tout.",
|
||||
"Pas de mises à jour, pas de nouvelles versions, rien à télécharger — juste des performances plus rapides qui prolongent la durée de vie de votre PC"
|
||||
],
|
||||
"description": "Crucial Basics Mémoire pour ordinateur de bureau DDR4 3200 MT/s CL22 UDIMM 288 broches 1,2 V 8 Go",
|
||||
"carateristique": {
|
||||
"Marque": "Crucial",
|
||||
"Numéro du modèle de l'article": "CB8GU3200",
|
||||
"séries": "Basics",
|
||||
"Couleur": "vert",
|
||||
"Taille de la mémoire vive": "8 Go",
|
||||
"Compatibilité du périphérique": "Ordinateur",
|
||||
"Dimensions de l'article L x L x H": "13.3 x 0.1 x 3.1 centimètres",
|
||||
"Poids du produit": "13 Grammes",
|
||||
"Divers": "Facile à installer, Meilleure performance",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible"
|
||||
},
|
||||
"details": {
|
||||
"Marque": "Crucial",
|
||||
"Numéro du modèle de l'article": "CB8GU3200",
|
||||
"séries": "Basics",
|
||||
"Couleur": "vert",
|
||||
"Taille de la mémoire vive": "8 Go",
|
||||
"Compatibilité du périphérique": "Ordinateur",
|
||||
"Dimensions de l'article L x L x H": "13.3 x 0.1 x 3.1 centimètres",
|
||||
"Poids du produit": "13 Grammes",
|
||||
"Divers": "Facile à installer, Meilleure performance",
|
||||
"Disponibilité des pièces détachées": "Information indisponible sur les pièces détachées",
|
||||
"Mises à jour logicielles garanties jusqu’à": "Information non disponible",
|
||||
"ASIN": "B0CWLSQ8FS",
|
||||
"Moyenne des commentaires client": "3,7 3,7 sur 5 étoiles (27) 3,7 sur 5 étoiles",
|
||||
"Classement des meilleures ventes d'Amazon": "7 941 en Informatique ( Voir les 100 premiers en Informatique ) 176 en Mémoire RAM",
|
||||
"Date de mise en ligne sur Amazon.fr": "30 juillet 2024"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"statut": "succes",
|
||||
"erreurs": [],
|
||||
"notes": [
|
||||
"Optionnels manquants: prix_conseille, prix_conseille_reduction, prix_min_30j, prix_min_30j_reduction, en_stock, prime, choix_amazon, offre_limitee, exclusivite_amazon",
|
||||
"debug={'screenshot': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-009_capture.png', 'html': '/home/gilles/Documents/vscode/suivi_produit/backend/app/samples/debug/sample-009_capture.html'}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
25
backend/app/samples/scrape_fields.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"required": [
|
||||
"titre",
|
||||
"prix_actuel"
|
||||
|
||||
],
|
||||
"optional": [
|
||||
"a_propos",
|
||||
"description",
|
||||
"carateristique",
|
||||
"details",
|
||||
"prix_conseille",
|
||||
"prix_conseille_reduction",
|
||||
"prix_min_30j",
|
||||
"prix_min_30j_reduction",
|
||||
"etat_stock",
|
||||
"en_stock",
|
||||
"note",
|
||||
"nombre_avis",
|
||||
"prime",
|
||||
"choix_amazon",
|
||||
"offre_limitee",
|
||||
"exclusivite_amazon"
|
||||
]
|
||||
}
|
||||
60
backend/app/samples/scrape_test.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"tests": [
|
||||
{
|
||||
"id": "sample-001",
|
||||
"url": "https://www.amazon.fr/dp/B0DQ8M74KL?th=1",
|
||||
"pause_s": 1,
|
||||
"notes": "ASUS TUF Gaming A16"
|
||||
},
|
||||
{
|
||||
"id": "sample-002",
|
||||
"url": "https://www.amazon.fr/dp/B0F32N1ZGH",
|
||||
"pause_s": 1,
|
||||
"notes": "MSI NVIDIA GeForce RTX 5060 Ti 16G Inspire 2X OC"
|
||||
},
|
||||
{
|
||||
"id": "sample-003",
|
||||
"url": "https://www.amazon.fr/dp/B0DWFLPMM5",
|
||||
"pause_s": 1,
|
||||
"notes": "Samsung SSD Interne 9100 Pro"
|
||||
},
|
||||
{
|
||||
"id": "sample-004",
|
||||
"url": "https://www.amazon.fr/dp/B07RW6Z692?th=1",
|
||||
"pause_s": 1,
|
||||
"notes": "Corsair Vengeance LPX DDR4 RAM 32Go"
|
||||
},
|
||||
{
|
||||
"id": "sample-005",
|
||||
"url": "https://www.amazon.fr/dp/B08GSTF5NJ?th=1",
|
||||
"pause_s": 1,
|
||||
"notes": "Corsair Vengeance SODIMM 32Go"
|
||||
},
|
||||
{
|
||||
"id": "sample-006",
|
||||
"url": "https://www.amazon.fr/dp/B0CB4FDJT5?th=1",
|
||||
"pause_s": 1,
|
||||
"notes": "MSI Modern MD2412P Écran Bureautique 23.8"
|
||||
},
|
||||
{
|
||||
"id": "sample-007",
|
||||
"url": "https://www.amazon.fr/dp/B0FGHX59G2",
|
||||
"pause_s": 1,
|
||||
"notes": "UGREEN Revodok Pro Docking Station USB C "
|
||||
},
|
||||
{
|
||||
"id": "sample-008",
|
||||
"url": "https://www.amazon.fr/dp/B0BYNZXFM2",
|
||||
"pause_s": 1,
|
||||
"notes": "Anker Prime External Battery 20000 mAh"
|
||||
},
|
||||
{
|
||||
"id": "sample-009",
|
||||
"url": "https://www.amazon.fr/dp/B0CWLSQ8FS",
|
||||
"pause_s": 1,
|
||||
"notes": "Crucial Basics Mémoire pour Ordinateur de Bureau DDR4"
|
||||
}
|
||||
|
||||
|
||||
]
|
||||
}
|
||||
1
backend/app/samples/storage_state.json
Normal file
BIN
backend/app/scraper/__pycache__/normalize.cpython-313.pyc
Normal file
BIN
backend/app/scraper/amazon/__pycache__/parser.cpython-313.pyc
Normal file
438
backend/app/scraper/amazon/parser.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from loguru import logger
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from backend.app.scraper.normalize import (
|
||||
parse_price_fr,
|
||||
parse_rating_count,
|
||||
parse_rating_value,
|
||||
parse_stock_status,
|
||||
)
|
||||
|
||||
|
||||
def detect_blocked(html: str) -> bool:
|
||||
# détection simple des blocages / captcha
|
||||
lowered = html.lower()
|
||||
if "captcha" in lowered or "robot" in lowered:
|
||||
return True
|
||||
if "saisissez les caractères" in lowered or "vérification" in lowered:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_text(page: Page, selector: str) -> str | None:
|
||||
try:
|
||||
locator = page.locator(selector)
|
||||
if locator.count() == 0:
|
||||
return None
|
||||
value = locator.first.inner_text().strip()
|
||||
return value or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_attr(page: Page, selector: str, attr: str) -> str | None:
|
||||
try:
|
||||
locator = page.locator(selector)
|
||||
if locator.count() == 0:
|
||||
return None
|
||||
return locator.first.get_attribute(attr)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_asin_from_url(url: str) -> str | None:
|
||||
match = re.search(r"/dp/([A-Z0-9]{10})", url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _safe_text_soup(soup: BeautifulSoup, selector: str) -> str | None:
|
||||
node = soup.select_one(selector)
|
||||
if not node:
|
||||
return None
|
||||
value = node.get_text(strip=True)
|
||||
return value or None
|
||||
|
||||
|
||||
def _safe_attr_soup(soup: BeautifulSoup, selector: str, attr: str) -> str | None:
|
||||
node = soup.select_one(selector)
|
||||
if not node:
|
||||
return None
|
||||
return node.get(attr)
|
||||
|
||||
|
||||
def _has_selector_soup(soup: BeautifulSoup, selector: str) -> bool:
|
||||
return soup.select_one(selector) is not None
|
||||
|
||||
|
||||
def _compose_price_from_parts(whole: str | None, fraction: str | None, symbol: str | None) -> str | None:
|
||||
if not whole:
|
||||
return None
|
||||
whole_digits = re.sub(r"[^\d]", "", whole)
|
||||
if not whole_digits:
|
||||
return None
|
||||
fraction_digits = re.sub(r"[^\d]", "", fraction or "")
|
||||
if not fraction_digits:
|
||||
fraction_digits = "00"
|
||||
fraction_digits = fraction_digits[:2].ljust(2, "0")
|
||||
symbol = (symbol or "€").strip()
|
||||
return f"{whole_digits},{fraction_digits} {symbol}"
|
||||
|
||||
|
||||
def _extract_lowest_30d_text_soup(soup: BeautifulSoup) -> str | None:
|
||||
containers = []
|
||||
container = soup.select_one("#priceBadging_feature_div")
|
||||
if container:
|
||||
containers.append(container)
|
||||
containers.extend(soup.select(".basisPrice"))
|
||||
for node in containers:
|
||||
text = node.get_text(" ", strip=True)
|
||||
if text and re.search(r"prix.+(30|trente).+jour", text.lower()):
|
||||
price_node = node.select_one(".a-offscreen")
|
||||
if price_node:
|
||||
price_text = price_node.get_text(" ", strip=True)
|
||||
if price_text:
|
||||
return price_text
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _extract_about_bullets(soup: BeautifulSoup) -> list[str] | None:
|
||||
container = soup.select_one("#feature-bullets")
|
||||
if not container:
|
||||
return None
|
||||
items = []
|
||||
for node in container.select("ul li span.a-list-item"):
|
||||
text = node.get_text(" ", strip=True)
|
||||
if text:
|
||||
items.append(text)
|
||||
return items or None
|
||||
|
||||
|
||||
def _extract_description(soup: BeautifulSoup) -> str | None:
|
||||
node = soup.select_one("#productDescription")
|
||||
if not node:
|
||||
return None
|
||||
text = node.get_text(" ", strip=True)
|
||||
return text or None
|
||||
|
||||
|
||||
def _extract_table_kv(table) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for row in table.select("tr"):
|
||||
key = row.select_one("th")
|
||||
value = row.select_one("td")
|
||||
if not key or not value:
|
||||
continue
|
||||
key_text = key.get_text(" ", strip=True)
|
||||
value_text = value.get_text(" ", strip=True)
|
||||
if key_text and value_text:
|
||||
data[key_text] = value_text
|
||||
return data
|
||||
|
||||
|
||||
def _extract_tables_from_selector(soup: BeautifulSoup, selector: str) -> list:
|
||||
section = soup.select_one(selector)
|
||||
if not section:
|
||||
return []
|
||||
if section.name == "table":
|
||||
return [section]
|
||||
return section.select("table")
|
||||
|
||||
|
||||
def _extract_carateristique(soup: BeautifulSoup) -> dict[str, str] | None:
|
||||
selectors = [
|
||||
"[data-csa-c-content-id='voyager-expander-btn']",
|
||||
"#productDetails_techSpec_section_1",
|
||||
"#productDetails_techSpec_section_2",
|
||||
]
|
||||
specs: dict[str, str] = {}
|
||||
for selector in selectors:
|
||||
tables = _extract_tables_from_selector(soup, selector)
|
||||
for table in tables:
|
||||
specs.update(_extract_table_kv(table))
|
||||
return specs or None
|
||||
|
||||
|
||||
def _extract_details(soup: BeautifulSoup) -> dict[str, str] | None:
|
||||
container = soup.select_one("[data-csa-c-content-id='voyager-expander-btn']")
|
||||
carateristique_tables = set(container.select("table")) if container else set()
|
||||
selectors = [
|
||||
"#productDetails_techSpec_section_1",
|
||||
"#productDetails_detailBullets_sections1",
|
||||
"#productDetails_detailBullets_sections2",
|
||||
"#productDetails",
|
||||
]
|
||||
details: dict[str, str] = {}
|
||||
seen_tables = set()
|
||||
for selector in selectors:
|
||||
for table in _extract_tables_from_selector(soup, selector):
|
||||
if table in carateristique_tables or table in seen_tables:
|
||||
continue
|
||||
seen_tables.add(table)
|
||||
details.update(_extract_table_kv(table))
|
||||
return details or None
|
||||
|
||||
|
||||
def _parse_percent(text: str | None) -> int | None:
|
||||
if not text:
|
||||
return None
|
||||
match = re.search(r"(-?\d+)", text.replace("\u00a0", " "))
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def extract_product_data_from_html(html: str, url: str) -> dict[str, Any]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
title = _safe_text_soup(soup, "#productTitle")
|
||||
|
||||
image_main_url = _safe_attr_soup(soup, "#landingImage", "src")
|
||||
if not image_main_url:
|
||||
image_main_url = _safe_attr_soup(soup, "#imgTagWrapperId img", "src")
|
||||
|
||||
price_text = _safe_text_soup(soup, "#corePriceDisplay_desktop_feature_div .a-offscreen")
|
||||
if not price_text:
|
||||
price_text = _safe_text_soup(soup, "#priceblock_ourprice")
|
||||
if not price_text:
|
||||
price_text = _safe_text_soup(soup, "#priceblock_dealprice")
|
||||
if not price_text:
|
||||
whole = _safe_text_soup(soup, ".a-price .a-price-whole")
|
||||
fraction = _safe_text_soup(soup, ".a-price .a-price-fraction")
|
||||
symbol = _safe_text_soup(soup, ".a-price .a-price-symbol")
|
||||
price_text = _compose_price_from_parts(whole, fraction, symbol)
|
||||
if not price_text:
|
||||
price_text = _safe_attr_soup(soup, "#twister-plus-price-data-price", "value")
|
||||
|
||||
price_list_text = _safe_text_soup(
|
||||
soup, "#corePriceDisplay_desktop_feature_div .a-text-price span.a-offscreen"
|
||||
)
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text_soup(soup, "#priceblock_strikeprice")
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text_soup(soup, ".srpPriceBlock .a-offscreen")
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text_soup(soup, ".srpPriceBlockAUI .a-offscreen")
|
||||
|
||||
stock_text = _safe_text_soup(soup, "#availability span")
|
||||
if not stock_text:
|
||||
stock_text = _safe_text_soup(soup, "#availability")
|
||||
|
||||
in_stock, stock_text = parse_stock_status(stock_text)
|
||||
|
||||
rating_text = _safe_text_soup(soup, "#acrPopover .a-icon-alt")
|
||||
rating_count_text = _safe_text_soup(soup, "#acrCustomerReviewText")
|
||||
|
||||
amazon_choice = _safe_text_soup(soup, "#acBadge_feature_div")
|
||||
limited_time_deal = _safe_text_soup(soup, "#dealBadge_feature_div")
|
||||
prime_eligible = None
|
||||
if _has_selector_soup(soup, "#primeBadge"):
|
||||
prime_eligible = True
|
||||
elif _has_selector_soup(soup, "#priceBadging_feature_div #prime-badge"):
|
||||
prime_eligible = True
|
||||
elif _has_selector_soup(soup, "#priceBadging_feature_div i.a-icon-prime"):
|
||||
prime_eligible = True
|
||||
elif _has_selector_soup(soup, "#corePriceDisplay_desktop_feature_div i.a-icon-prime"):
|
||||
prime_eligible = True
|
||||
elif _has_selector_soup(soup, "#priceBadging_feature_div #prime-badge"):
|
||||
prime_eligible = True
|
||||
elif _has_selector_soup(soup, "i#prime-badge"):
|
||||
prime_eligible = True
|
||||
elif _has_selector_soup(soup, "i.a-icon-prime[aria-label*='prime']"):
|
||||
prime_eligible = True
|
||||
amazon_exclusive = "Exclusivité Amazon" if "Exclusivité Amazon" in soup.get_text() else None
|
||||
|
||||
lowest_30d_text = _extract_lowest_30d_text_soup(soup)
|
||||
lowest_30d_price = None
|
||||
if lowest_30d_text:
|
||||
lowest_30d_price = parse_price_fr(lowest_30d_text)
|
||||
if lowest_30d_price is not None:
|
||||
candidate_list = parse_price_fr(price_list_text)
|
||||
if candidate_list == lowest_30d_price:
|
||||
price_list_text = None
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text_soup(soup, ".srpPriceBlock .a-offscreen")
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text_soup(soup, ".srpPriceBlockAUI .a-offscreen")
|
||||
|
||||
reduction_savings_text = _safe_text_soup(
|
||||
soup, "#corePriceDisplay_desktop_feature_div .savingsPercentage"
|
||||
)
|
||||
reduction_conseille_text = _safe_text_soup(soup, ".srpSavingsPercentageBlock")
|
||||
reduction_min_30j = _parse_percent(reduction_savings_text)
|
||||
reduction_conseille = _parse_percent(reduction_conseille_text)
|
||||
|
||||
a_propos = _extract_about_bullets(soup)
|
||||
description = _extract_description(soup)
|
||||
carateristique = _extract_carateristique(soup)
|
||||
details = _extract_details(soup)
|
||||
|
||||
asin = _safe_attr_soup(soup, "input#ASIN", "value") or _extract_asin_from_url(url)
|
||||
|
||||
data = {
|
||||
"url": url,
|
||||
"asin": asin,
|
||||
"titre": title,
|
||||
"url_image_principale": image_main_url,
|
||||
"prix_actuel": parse_price_fr(price_text),
|
||||
"prix_conseille": parse_price_fr(price_list_text),
|
||||
"prix_min_30j": lowest_30d_price,
|
||||
"prix_conseille_reduction": reduction_conseille,
|
||||
"prix_min_30j_reduction": reduction_min_30j,
|
||||
"etat_stock": stock_text,
|
||||
"en_stock": in_stock,
|
||||
"note": parse_rating_value(rating_text),
|
||||
"nombre_avis": parse_rating_count(rating_count_text),
|
||||
"choix_amazon": bool(amazon_choice) if amazon_choice is not None else None,
|
||||
"offre_limitee": bool(limited_time_deal) if limited_time_deal is not None else None,
|
||||
"prime": True if prime_eligible else None,
|
||||
"exclusivite_amazon": bool(amazon_exclusive) if amazon_exclusive is not None else None,
|
||||
"a_propos": a_propos,
|
||||
"description": description,
|
||||
"carateristique": carateristique,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
missing = [key for key in ("titre", "prix_actuel", "note") if not data.get(key)]
|
||||
if missing:
|
||||
logger.warning("Champs manquants (html): {}", ", ".join(missing))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def extract_product_data(page: Page, url: str) -> dict[str, Any]:
|
||||
# champ titre
|
||||
title = _safe_text(page, "#productTitle")
|
||||
|
||||
# image principale
|
||||
image_main_url = _safe_attr(page, "#landingImage", "src")
|
||||
if not image_main_url:
|
||||
image_main_url = _safe_attr(page, "#imgTagWrapperId img", "src")
|
||||
|
||||
# prix actuel
|
||||
price_text = _safe_text(page, "#corePriceDisplay_desktop_feature_div .a-offscreen")
|
||||
if not price_text:
|
||||
price_text = _safe_text(page, "#priceblock_ourprice")
|
||||
if not price_text:
|
||||
price_text = _safe_text(page, "#priceblock_dealprice")
|
||||
if not price_text:
|
||||
whole = _safe_text(page, ".a-price .a-price-whole")
|
||||
fraction = _safe_text(page, ".a-price .a-price-fraction")
|
||||
symbol = _safe_text(page, ".a-price .a-price-symbol")
|
||||
price_text = _compose_price_from_parts(whole, fraction, symbol)
|
||||
if not price_text:
|
||||
price_text = _safe_attr(page, "#twister-plus-price-data-price", "value")
|
||||
|
||||
# prix barré / conseillé
|
||||
price_list_text = _safe_text(page, "#corePriceDisplay_desktop_feature_div .a-text-price span.a-offscreen")
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text(page, "#priceblock_strikeprice")
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text(page, ".srpPriceBlock .a-offscreen")
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text(page, ".srpPriceBlockAUI .a-offscreen")
|
||||
|
||||
# stock
|
||||
stock_text = _safe_text(page, "#availability span")
|
||||
if not stock_text:
|
||||
stock_text = _safe_text(page, "#availability")
|
||||
|
||||
in_stock, stock_text = parse_stock_status(stock_text)
|
||||
|
||||
# rating
|
||||
rating_text = _safe_text(page, "#acrPopover .a-icon-alt")
|
||||
rating_count_text = _safe_text(page, "#acrCustomerReviewText")
|
||||
|
||||
# badges
|
||||
amazon_choice = _safe_text(page, "#acBadge_feature_div")
|
||||
limited_time_deal = _safe_text(page, "#dealBadge_feature_div")
|
||||
prime_eligible = None
|
||||
if page.locator("#primeBadge").count() > 0:
|
||||
prime_eligible = True
|
||||
elif page.locator("#priceBadging_feature_div #prime-badge").count() > 0:
|
||||
prime_eligible = True
|
||||
elif page.locator("#priceBadging_feature_div i.a-icon-prime").count() > 0:
|
||||
prime_eligible = True
|
||||
elif page.locator("#corePriceDisplay_desktop_feature_div i.a-icon-prime").count() > 0:
|
||||
prime_eligible = True
|
||||
elif page.locator("#priceBadging_feature_div #prime-badge").count() > 0:
|
||||
prime_eligible = True
|
||||
elif page.locator("i#prime-badge").count() > 0:
|
||||
prime_eligible = True
|
||||
elif page.locator("i.a-icon-prime[aria-label*='prime']").count() > 0:
|
||||
prime_eligible = True
|
||||
|
||||
amazon_exclusive = _safe_text(page, "text=Exclusivité Amazon")
|
||||
|
||||
# prix plus bas 30 jours
|
||||
lowest_30d_text = None
|
||||
if page.locator(".basisPrice").count() > 0:
|
||||
basis_text = page.locator(".basisPrice").first.inner_text()
|
||||
if basis_text and re.search(r"prix.+(30|trente).+jour", basis_text.lower()):
|
||||
lowest_30d_text = _safe_text(page, ".basisPrice .a-offscreen") or basis_text
|
||||
if not lowest_30d_text and page.locator("#priceBadging_feature_div").count() > 0:
|
||||
badging_text = page.locator("#priceBadging_feature_div").first.inner_text()
|
||||
if badging_text and re.search(r"prix.+(30|trente).+jour", badging_text.lower()):
|
||||
lowest_30d_text = _safe_text(page, "#priceBadging_feature_div .a-offscreen") or badging_text
|
||||
if lowest_30d_text and not re.search(r"prix.+(30|trente).+jour", lowest_30d_text.lower()):
|
||||
lowest_30d_text = None
|
||||
lowest_30d_price = None
|
||||
if lowest_30d_text and "prix" in lowest_30d_text.lower():
|
||||
lowest_30d_price = parse_price_fr(lowest_30d_text)
|
||||
if lowest_30d_price is not None:
|
||||
candidate_list = parse_price_fr(price_list_text)
|
||||
if candidate_list == lowest_30d_price:
|
||||
price_list_text = None
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text(page, ".srpPriceBlock .a-offscreen")
|
||||
if not price_list_text:
|
||||
price_list_text = _safe_text(page, ".srpPriceBlockAUI .a-offscreen")
|
||||
|
||||
reduction_savings_text = _safe_text(page, "#corePriceDisplay_desktop_feature_div .savingsPercentage")
|
||||
reduction_conseille_text = _safe_text(page, ".srpSavingsPercentageBlock")
|
||||
reduction_min_30j = _parse_percent(reduction_savings_text)
|
||||
reduction_conseille = _parse_percent(reduction_conseille_text)
|
||||
|
||||
asin = _safe_attr(page, "input#ASIN", "value") or _extract_asin_from_url(url)
|
||||
|
||||
soup = BeautifulSoup(page.content(), "html.parser")
|
||||
a_propos = _extract_about_bullets(soup)
|
||||
description = _extract_description(soup)
|
||||
carateristique = _extract_carateristique(soup)
|
||||
details = _extract_details(soup)
|
||||
|
||||
data = {
|
||||
"url": url,
|
||||
"asin": asin,
|
||||
"titre": title,
|
||||
"url_image_principale": image_main_url,
|
||||
"prix_actuel": parse_price_fr(price_text),
|
||||
"prix_conseille": parse_price_fr(price_list_text),
|
||||
"prix_min_30j": lowest_30d_price,
|
||||
"prix_conseille_reduction": reduction_conseille,
|
||||
"prix_min_30j_reduction": reduction_min_30j,
|
||||
"etat_stock": stock_text,
|
||||
"en_stock": in_stock,
|
||||
"note": parse_rating_value(rating_text),
|
||||
"nombre_avis": parse_rating_count(rating_count_text),
|
||||
"choix_amazon": bool(amazon_choice) if amazon_choice is not None else None,
|
||||
"offre_limitee": bool(limited_time_deal) if limited_time_deal is not None else None,
|
||||
"prime": True if prime_eligible else None,
|
||||
"exclusivite_amazon": bool(amazon_exclusive) if amazon_exclusive is not None else None,
|
||||
"a_propos": a_propos,
|
||||
"description": description,
|
||||
"carateristique": carateristique,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
return data
|
||||
23
backend/app/scraper/amazon/selectors.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Sélecteurs Amazon (FR)
|
||||
|
||||
## Identifiants stables
|
||||
- `#productTitle`
|
||||
- `#acrCustomerReviewText`
|
||||
- `#availability`
|
||||
|
||||
## Prix
|
||||
- `#corePriceDisplay_desktop_feature_div .a-offscreen`
|
||||
- `#priceblock_ourprice`
|
||||
- `#priceblock_dealprice`
|
||||
- `#corePriceDisplay_desktop_feature_div .a-text-price span.a-offscreen`
|
||||
- `#priceblock_strikeprice`
|
||||
|
||||
## Images
|
||||
- `#landingImage`
|
||||
- `#imgTagWrapperId img`
|
||||
|
||||
## Badges
|
||||
- `#acBadge_feature_div`
|
||||
- `#dealBadge_feature_div`
|
||||
- `#primeBadge`
|
||||
- `text=Exclusivité Amazon`
|
||||
23
backend/app/scraper/browser.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright, Browser, BrowserContext
|
||||
|
||||
|
||||
async def build_browser_context(headless: bool, viewport: dict[str, int], locale: str, timezone: str) -> BrowserContext:
|
||||
playwright = await async_playwright().start()
|
||||
browser: Browser = await playwright.chromium.launch(headless=headless)
|
||||
context = await browser.new_context(
|
||||
viewport=viewport,
|
||||
locale=locale,
|
||||
timezone_id=timezone,
|
||||
)
|
||||
context.set_default_timeout(30000)
|
||||
return context
|
||||
|
||||
|
||||
async def close_context(context: BrowserContext) -> None:
|
||||
await context.close()
|
||||
await context.browser.close()
|
||||
await context._playwright.stop()
|
||||
61
backend/app/scraper/normalize.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_price_fr(text: str | None) -> Optional[float]:
|
||||
if not text:
|
||||
return None
|
||||
# Exemple: "1 249,99 €" -> 1249.99 (gère espaces insécables)
|
||||
match = re.search(r"([0-9][0-9\s\.\u00a0\u202f]*(?:[,.][0-9]{2})?)", text)
|
||||
if not match:
|
||||
return None
|
||||
cleaned = match.group(1).replace(" ", "").replace("\u00a0", "").replace("\u202f", "")
|
||||
if "," in cleaned:
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
elif cleaned.count(".") == 1 and len(cleaned.split(".")[-1]) == 2:
|
||||
# conserve le point comme séparateur décimal
|
||||
pass
|
||||
else:
|
||||
cleaned = cleaned.replace(".", "")
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_rating_value(text: str | None) -> Optional[float]:
|
||||
if not text:
|
||||
return None
|
||||
match = re.search(r"([0-9]+(?:[\.,][0-9]+)?)", text)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return float(match.group(1).replace(",", "."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_rating_count(text: str | None) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
digits = re.sub(r"[^0-9]", "", text)
|
||||
if not digits:
|
||||
return None
|
||||
try:
|
||||
return int(digits)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_stock_status(text: str | None) -> tuple[Optional[bool], Optional[str]]:
|
||||
if not text:
|
||||
return None, None
|
||||
cleaned = " ".join(text.split())
|
||||
lowered = cleaned.lower()
|
||||
if "en stock" in lowered or "disponible" in lowered:
|
||||
return True, cleaned
|
||||
if "indisponible" in lowered or "rupture" in lowered:
|
||||
return False, cleaned
|
||||
return None, cleaned
|
||||
270
backend/app/scraper/run_scrape_tests.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from loguru import logger
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from backend.app.core.config import load_config
|
||||
from backend.app.scraper.amazon.parser import extract_product_data
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "samples"
|
||||
TESTS_PATH = SAMPLES_DIR / "scrape_test.json"
|
||||
RESULTS_PATH = SAMPLES_DIR / "scrap_result.json"
|
||||
FIELDS_PATH = SAMPLES_DIR / "scrape_fields.json"
|
||||
STORAGE_STATE_PATH = SAMPLES_DIR / "storage_state.json"
|
||||
DEBUG_DIR = SAMPLES_DIR / "debug"
|
||||
|
||||
DEFAULT_REQUIRED_FIELDS = ("titre", "prix_actuel")
|
||||
DEFAULT_OPTIONAL_FIELDS = (
|
||||
"prix_conseille",
|
||||
"prix_min_30j",
|
||||
"etat_stock",
|
||||
"en_stock",
|
||||
"note",
|
||||
"nombre_avis",
|
||||
"prime",
|
||||
"choix_amazon",
|
||||
"offre_limitee",
|
||||
"exclusivite_amazon",
|
||||
)
|
||||
|
||||
|
||||
def load_fields_config() -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
if not FIELDS_PATH.exists():
|
||||
return DEFAULT_REQUIRED_FIELDS, DEFAULT_OPTIONAL_FIELDS
|
||||
payload = json.loads(FIELDS_PATH.read_text(encoding="utf-8"))
|
||||
required = tuple(payload.get("required", DEFAULT_REQUIRED_FIELDS))
|
||||
optional = tuple(payload.get("optional", DEFAULT_OPTIONAL_FIELDS))
|
||||
return required, optional
|
||||
|
||||
|
||||
def canonicalize_url(url: str) -> str:
|
||||
if not url:
|
||||
return url
|
||||
parsed = urlparse(url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||||
|
||||
|
||||
def extract_reference(url: str) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
match = re.search(r"/dp/([A-Z0-9]{10})", url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def build_debug(statut: str, erreurs: list[str] | None = None, notes: list[str] | None = None) -> dict:
|
||||
return {
|
||||
"statut": statut,
|
||||
"erreurs": erreurs or [],
|
||||
"notes": notes or [],
|
||||
}
|
||||
|
||||
|
||||
def build_result(
|
||||
test_id: str,
|
||||
url: str,
|
||||
statut: str,
|
||||
data: dict | None = None,
|
||||
debug: dict | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": test_id,
|
||||
"url": url,
|
||||
"url_canonique": canonicalize_url(url),
|
||||
"reference": extract_reference(url),
|
||||
"statut": statut,
|
||||
"donnees": data,
|
||||
"debug": debug,
|
||||
}
|
||||
|
||||
|
||||
def save_debug_artifacts(page, test_id: str, suffix: str) -> dict:
|
||||
debug_files = {}
|
||||
try:
|
||||
screenshot_path = DEBUG_DIR / f"{test_id}_{suffix}.png"
|
||||
html_path = DEBUG_DIR / f"{test_id}_{suffix}.html"
|
||||
page.screenshot(path=str(screenshot_path), full_page=True)
|
||||
html_path.write_text(page.content(), encoding="utf-8")
|
||||
debug_files = {
|
||||
"screenshot": str(screenshot_path),
|
||||
"html": str(html_path),
|
||||
}
|
||||
logger.info("Artifacts debug: {}", debug_files)
|
||||
except Exception:
|
||||
logger.warning("Impossible de générer les artifacts de debug.")
|
||||
return debug_files
|
||||
|
||||
|
||||
def evaluate_data(
|
||||
data: dict,
|
||||
required_fields: tuple[str, ...],
|
||||
optional_fields: tuple[str, ...],
|
||||
) -> tuple[str, dict]:
|
||||
missing_required = [field for field in required_fields if not data.get(field)]
|
||||
missing_optional = [field for field in optional_fields if data.get(field) is None]
|
||||
|
||||
if missing_required:
|
||||
notes = []
|
||||
if missing_optional:
|
||||
notes.append(f"Optionnels manquants: {', '.join(missing_optional)}")
|
||||
return "partiel", build_debug(
|
||||
"partiel",
|
||||
erreurs=[f"Obligatoires manquants: {', '.join(missing_required)}"],
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
if missing_optional:
|
||||
return "ok", build_debug(
|
||||
"succes",
|
||||
notes=[f"Optionnels manquants: {', '.join(missing_optional)}"],
|
||||
)
|
||||
|
||||
return "ok", build_debug("succes")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logger.remove()
|
||||
logger.add(sys.stdout, level="INFO")
|
||||
DEBUG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.loads(TESTS_PATH.read_text(encoding="utf-8"))
|
||||
tests = payload.get("tests", [])
|
||||
if not tests:
|
||||
logger.warning("Aucun test trouvé dans {}", TESTS_PATH)
|
||||
return
|
||||
|
||||
config = load_config()
|
||||
required_fields, optional_fields = load_fields_config()
|
||||
min_delay = int(os.getenv("SCRAPE_TEST_MIN_DELAY", "1"))
|
||||
max_delay = int(os.getenv("SCRAPE_TEST_MAX_DELAY", "5"))
|
||||
max_tests = int(os.getenv("SCRAPE_TEST_MAX", "0"))
|
||||
headful_on_block = os.getenv("SCRAPE_TEST_HEADFUL_ON_BLOCK", "0") == "1"
|
||||
wait_on_block = int(os.getenv("SCRAPE_TEST_WAIT_ON_BLOCK", "60"))
|
||||
results = []
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=config.scrape.headless)
|
||||
context_kwargs = {
|
||||
"locale": config.scrape.locale,
|
||||
"timezone_id": config.scrape.timezone,
|
||||
"user_agent": config.scrape.user_agent,
|
||||
"viewport": config.scrape.viewport,
|
||||
}
|
||||
if STORAGE_STATE_PATH.exists():
|
||||
context_kwargs["storage_state"] = str(STORAGE_STATE_PATH)
|
||||
logger.info("Session persistée chargée: {}", STORAGE_STATE_PATH)
|
||||
context = browser.new_context(**context_kwargs)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(config.scrape.timeout_ms)
|
||||
|
||||
try:
|
||||
for index, test in enumerate(tests, start=1):
|
||||
if max_tests > 0 and index > max_tests:
|
||||
logger.info("Limite atteinte ({} tests), arrêt de la session.", max_tests)
|
||||
break
|
||||
|
||||
test_id = test.get("id")
|
||||
url = test.get("url")
|
||||
pause_s = test.get("pause_s", 0)
|
||||
|
||||
if not url:
|
||||
logger.warning("Test {} sans URL", test_id)
|
||||
continue
|
||||
|
||||
logger.info("Scraping {} ({})", test_id, url)
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=config.scrape.timeout_ms)
|
||||
debug_files = save_debug_artifacts(page, test_id, "capture")
|
||||
data = extract_product_data(page, url)
|
||||
if not data.get("titre"):
|
||||
logger.warning("Titre absent, suspicion de blocage pour {}", test_id)
|
||||
if headful_on_block:
|
||||
logger.info("Ouverture headful pour résolution manuelle.")
|
||||
manual_browser = playwright.chromium.launch(headless=False)
|
||||
manual_context_kwargs = dict(context_kwargs)
|
||||
manual_context = manual_browser.new_context(**manual_context_kwargs)
|
||||
manual_page = manual_context.new_page()
|
||||
manual_page.goto(url, wait_until="domcontentloaded", timeout=config.scrape.timeout_ms)
|
||||
save_debug_artifacts(manual_page, test_id, "manual")
|
||||
logger.info("Résoudre le captcha puis appuyer sur Entrée.")
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
logger.info("Pas d'entrée disponible, attente {}s.", wait_on_block)
|
||||
time.sleep(wait_on_block)
|
||||
data = extract_product_data(manual_page, url)
|
||||
if not data.get("titre"):
|
||||
results.append(
|
||||
build_result(
|
||||
test_id,
|
||||
url,
|
||||
"bloque",
|
||||
debug=build_debug("bloque", notes=[f"debug={debug_files}"]),
|
||||
)
|
||||
)
|
||||
else:
|
||||
status, debug = evaluate_data(data, required_fields, optional_fields)
|
||||
if status == "partiel":
|
||||
logger.warning("Champs manquants: {}", debug.get("erreurs"))
|
||||
debug["notes"].append(f"debug={debug_files}")
|
||||
results.append(build_result(test_id, url, status, data=data, debug=debug))
|
||||
logger.info("OK {} (titre={})", test_id, data.get("titre"))
|
||||
try:
|
||||
manual_context.storage_state(path=str(STORAGE_STATE_PATH))
|
||||
logger.info("Session persistée sauvegardée: {}", STORAGE_STATE_PATH)
|
||||
except Exception:
|
||||
logger.warning("Impossible de sauvegarder la session persistée.")
|
||||
manual_context.close()
|
||||
manual_browser.close()
|
||||
else:
|
||||
results.append(
|
||||
build_result(
|
||||
test_id,
|
||||
url,
|
||||
"bloque",
|
||||
debug=build_debug("bloque", notes=[f"debug={debug_files}"]),
|
||||
)
|
||||
)
|
||||
else:
|
||||
status, debug = evaluate_data(data, required_fields, optional_fields)
|
||||
if status == "partiel":
|
||||
logger.warning("Champs manquants: {}", debug.get("erreurs"))
|
||||
debug["notes"].append(f"debug={debug_files}")
|
||||
results.append(build_result(test_id, url, status, data=data, debug=debug))
|
||||
logger.info("OK {} (titre={})", test_id, data.get("titre"))
|
||||
|
||||
if pause_s:
|
||||
logger.info("Pause {}s", pause_s)
|
||||
time.sleep(pause_s)
|
||||
|
||||
# délai supplémentaire entre pages pour limiter les blocages
|
||||
jitter = random.uniform(min_delay, max_delay)
|
||||
logger.info("Délai anti-blocage: {:.1f}s", jitter)
|
||||
time.sleep(jitter)
|
||||
finally:
|
||||
try:
|
||||
context.storage_state(path=str(STORAGE_STATE_PATH))
|
||||
logger.info("Session persistée sauvegardée: {}", STORAGE_STATE_PATH)
|
||||
except Exception:
|
||||
logger.warning("Impossible de sauvegarder la session persistée.")
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
RESULTS_PATH.write_text(json.dumps({"results": results}, ensure_ascii=False, indent=2))
|
||||
logger.info("Résultats sauvegardés dans {}", RESULTS_PATH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
242
backend/app/scraper/runner.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
from playwright.sync_api import sync_playwright
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.core.config import load_config
|
||||
from backend.app.db import database, models
|
||||
from backend.app.scraper.amazon.parser import detect_blocked, extract_product_data
|
||||
|
||||
|
||||
def _create_run(session: Session) -> models.ScrapeRun:
|
||||
run = models.ScrapeRun(demarre_le=datetime.utcnow(), statut="en_cours")
|
||||
session.add(run)
|
||||
session.commit()
|
||||
session.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
def _finalize_run(run: models.ScrapeRun, session: Session, status: str) -> None:
|
||||
run.statut = status
|
||||
run.termine_le = datetime.utcnow()
|
||||
session.add(run)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _save_raw_json(payload: dict, product_id: int) -> Path:
|
||||
base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "raw"
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
folder = base_dir / timestamp
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{product_id}_{datetime.utcnow().strftime('%H%M%S')}.json"
|
||||
path = folder / filename
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return path
|
||||
|
||||
|
||||
def _save_debug_artifacts(page, product_id: int) -> tuple[Path, Path]:
|
||||
base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "screenshots"
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
screenshot_path = base_dir / f"{product_id}_{stamp}.png"
|
||||
html_path = base_dir / f"{product_id}_{stamp}.html"
|
||||
page.screenshot(path=str(screenshot_path), full_page=True)
|
||||
html_path.write_text(page.content())
|
||||
return screenshot_path, html_path
|
||||
|
||||
|
||||
def _create_snapshot(
|
||||
session: Session,
|
||||
product: models.Product,
|
||||
run: models.ScrapeRun,
|
||||
data: dict,
|
||||
status: str,
|
||||
raw_json_path: Path | None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
snapshot = models.ProductSnapshot(
|
||||
produit_id=product.id,
|
||||
run_scrap_id=run.id,
|
||||
prix_actuel=data.get("prix_actuel"),
|
||||
prix_conseille=data.get("prix_conseille"),
|
||||
prix_min_30j=data.get("prix_min_30j"),
|
||||
etat_stock=data.get("etat_stock"),
|
||||
en_stock=data.get("en_stock"),
|
||||
note=data.get("note"),
|
||||
nombre_avis=data.get("nombre_avis"),
|
||||
prime=data.get("prime"),
|
||||
choix_amazon=data.get("choix_amazon"),
|
||||
offre_limitee=data.get("offre_limitee"),
|
||||
exclusivite_amazon=data.get("exclusivite_amazon"),
|
||||
chemin_json_brut=str(raw_json_path) if raw_json_path else None,
|
||||
statut_scrap=status,
|
||||
message_erreur=error_message,
|
||||
)
|
||||
session.add(snapshot)
|
||||
session.commit()
|
||||
|
||||
|
||||
def scrape_product(product_id: int) -> None:
|
||||
logger.info("Déclenchement du scraping pour le produit %s", product_id)
|
||||
session = database.SessionLocal()
|
||||
run = _create_run(session)
|
||||
try:
|
||||
product = session.get(models.Product, product_id)
|
||||
if not product:
|
||||
logger.warning("Produit %s introuvable", product_id)
|
||||
_finalize_run(run, session, "echec")
|
||||
return
|
||||
config = load_config()
|
||||
run.nb_total = 1
|
||||
session.commit()
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=config.scrape.headless)
|
||||
context = browser.new_context(
|
||||
locale=config.scrape.locale,
|
||||
timezone_id=config.scrape.timezone,
|
||||
user_agent=config.scrape.user_agent,
|
||||
viewport=config.scrape.viewport,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(config.scrape.timeout_ms)
|
||||
try:
|
||||
page.goto(product.url, wait_until="domcontentloaded", timeout=config.scrape.timeout_ms)
|
||||
|
||||
html = page.content()
|
||||
if detect_blocked(html):
|
||||
screenshot_path, html_path = _save_debug_artifacts(page, product.id)
|
||||
data = {"url": product.url, "asin": product.asin, "bloque": True}
|
||||
raw_path = _save_raw_json(data, product.id)
|
||||
_create_snapshot(
|
||||
session,
|
||||
product,
|
||||
run,
|
||||
data,
|
||||
status="bloque",
|
||||
raw_json_path=raw_path,
|
||||
error_message=f"Bloque: {screenshot_path.name} / {html_path.name}",
|
||||
)
|
||||
run.nb_echec = 1
|
||||
_finalize_run(run, session, "partiel")
|
||||
return
|
||||
|
||||
data = extract_product_data(page, product.url)
|
||||
raw_path = _save_raw_json(data, product.id)
|
||||
required = ["titre", "prix_actuel", "note"]
|
||||
missing = [field for field in required if not data.get(field)]
|
||||
status = "champs_manquants" if missing else "ok"
|
||||
_create_snapshot(
|
||||
session,
|
||||
product,
|
||||
run,
|
||||
data,
|
||||
status=status,
|
||||
raw_json_path=raw_path,
|
||||
error_message=", ".join(missing) if missing else None,
|
||||
)
|
||||
run.nb_ok = 1 if not missing else 0
|
||||
run.nb_echec = 0 if not missing else 1
|
||||
_finalize_run(run, session, "succes" if not missing else "partiel")
|
||||
|
||||
delay_min, delay_max = config.scrape.delay_range_ms
|
||||
time.sleep(random.uniform(delay_min, delay_max) / 1000.0)
|
||||
finally:
|
||||
# fermeture propre du navigateur
|
||||
context.close()
|
||||
browser.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception("Erreur pendant le scraping de %s", product_id)
|
||||
_finalize_run(run, session, "erreur")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def scrape_all(product_ids: Iterable[int] | None = None) -> None:
|
||||
logger.info("Déclenchement du scraping global")
|
||||
session = database.SessionLocal()
|
||||
run = _create_run(session)
|
||||
try:
|
||||
config = load_config()
|
||||
products = session.query(models.Product).all()
|
||||
if product_ids:
|
||||
products = [product for product in products if product.id in product_ids]
|
||||
run.nb_total = len(products)
|
||||
session.commit()
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=config.scrape.headless)
|
||||
context = browser.new_context(
|
||||
locale=config.scrape.locale,
|
||||
timezone_id=config.scrape.timezone,
|
||||
user_agent=config.scrape.user_agent,
|
||||
viewport=config.scrape.viewport,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(config.scrape.timeout_ms)
|
||||
|
||||
nb_ok = 0
|
||||
nb_echec = 0
|
||||
|
||||
try:
|
||||
for product in products:
|
||||
page.goto(product.url, wait_until="domcontentloaded", timeout=config.scrape.timeout_ms)
|
||||
html = page.content()
|
||||
if detect_blocked(html):
|
||||
screenshot_path, html_path = _save_debug_artifacts(page, product.id)
|
||||
data = {"url": product.url, "asin": product.asin, "bloque": True}
|
||||
raw_path = _save_raw_json(data, product.id)
|
||||
_create_snapshot(
|
||||
session,
|
||||
product,
|
||||
run,
|
||||
data,
|
||||
status="bloque",
|
||||
raw_json_path=raw_path,
|
||||
error_message=f"Bloque: {screenshot_path.name} / {html_path.name}",
|
||||
)
|
||||
nb_echec += 1
|
||||
continue
|
||||
|
||||
data = extract_product_data(page, product.url)
|
||||
raw_path = _save_raw_json(data, product.id)
|
||||
required = ["titre", "prix_actuel", "note"]
|
||||
missing = [field for field in required if not data.get(field)]
|
||||
status = "champs_manquants" if missing else "ok"
|
||||
_create_snapshot(
|
||||
session,
|
||||
product,
|
||||
run,
|
||||
data,
|
||||
status=status,
|
||||
raw_json_path=raw_path,
|
||||
error_message=", ".join(missing) if missing else None,
|
||||
)
|
||||
if missing:
|
||||
nb_echec += 1
|
||||
else:
|
||||
nb_ok += 1
|
||||
|
||||
delay_min, delay_max = config.scrape.delay_range_ms
|
||||
time.sleep(random.uniform(delay_min, delay_max) / 1000.0)
|
||||
|
||||
run.nb_ok = nb_ok
|
||||
run.nb_echec = nb_echec
|
||||
_finalize_run(run, session, "succes" if nb_echec == 0 else "partiel")
|
||||
finally:
|
||||
# fermeture propre du navigateur
|
||||
context.close()
|
||||
browser.close()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Erreur du scraping global")
|
||||
_finalize_run(run, session, "erreur")
|
||||
finally:
|
||||
session.close()
|
||||
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Services utilitaires côté backend."""
|
||||
BIN
backend/app/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/pricing.cpython-313.pyc
Normal file
12
backend/app/services/pricing.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def price_trend(prices: list[float]) -> str:
|
||||
if not prices:
|
||||
return "stable"
|
||||
first, last = prices[0], prices[-1]
|
||||
if last > first:
|
||||
return "up"
|
||||
if last < first:
|
||||
return "down"
|
||||
return "stable"
|
||||
0
backend/app/tests/__init__.py
Normal file
BIN
backend/app/tests/__pycache__/__init__.cpython-313.pyc
Normal file
16
backend/app/tests/test_normalize.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from backend.app.scraper.normalize import parse_price_fr, parse_rating_count, parse_rating_value
|
||||
|
||||
|
||||
def test_parse_price_fr():
|
||||
assert parse_price_fr("1 249,99 €") == 1249.99
|
||||
assert parse_price_fr("249,99") == 249.99
|
||||
|
||||
|
||||
def test_parse_rating_value():
|
||||
assert parse_rating_value("4,7 sur 5") == 4.7
|
||||
assert parse_rating_value("4.0") == 4.0
|
||||
|
||||
|
||||
def test_parse_rating_count():
|
||||
assert parse_rating_count("1 234 évaluations") == 1234
|
||||
assert parse_rating_count("987") == 987
|
||||
22
backend/app/tests/test_parser_samples.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.scraper.amazon.parser import extract_product_data_from_html
|
||||
|
||||
|
||||
def test_extract_product_data_from_sample_html():
|
||||
html_path = Path(__file__).resolve().parent.parent / "samples" / "amazon_product.html"
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
data = extract_product_data_from_html(html, "https://www.amazon.fr/dp/B000000000")
|
||||
|
||||
assert data["asin"] == "B000000000"
|
||||
assert data["titre"] == "Disque SSD NVMe Test"
|
||||
assert data["prix_actuel"] == 249.99
|
||||
assert data["prix_conseille"] == 329.99
|
||||
assert data["note"] == 4.7
|
||||
assert data["nombre_avis"] == 1234
|
||||
assert data["en_stock"] is True
|
||||
assert data["choix_amazon"] is True
|
||||
assert data["offre_limitee"] is True
|
||||
assert data["prime"] is True
|
||||
assert data["exclusivite_amazon"] is True
|
||||
assert data["prix_min_30j"] == 239.99
|
||||
13
backend/app/tests/test_pricing.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from backend.app.services.pricing import price_trend
|
||||
|
||||
|
||||
def test_price_trend_up():
|
||||
assert price_trend([100, 200]) == "up"
|
||||
|
||||
|
||||
def test_price_trend_stable():
|
||||
assert price_trend([100, 100]) == "stable"
|
||||
|
||||
|
||||
def test_price_trend_down():
|
||||
assert price_trend([300, 200]) == "down"
|
||||
30
backend/config_backend.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"app": {
|
||||
"env": "dev",
|
||||
"version": "0.1.0",
|
||||
"base_url": "http://localhost:8008",
|
||||
"log_level": "INFO"
|
||||
},
|
||||
"scrape": {
|
||||
"interval_minutes": 60,
|
||||
"headless": true,
|
||||
"timeout_ms": 30000,
|
||||
"retries": 1,
|
||||
"delay_range_ms": [1000, 3000],
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
|
||||
"viewport": { "width": 1366, "height": 768 },
|
||||
"locale": "fr-FR",
|
||||
"timezone": "Europe/Paris",
|
||||
"proxy": null
|
||||
},
|
||||
"stores_enabled": ["amazon_fr"],
|
||||
"taxonomy": {
|
||||
"categories": ["SSD", "CPU", "GPU", "RAM"],
|
||||
"types_by_category": {
|
||||
"SSD": ["NVMe", "SATA"],
|
||||
"CPU": ["Desktop", "Mobile"],
|
||||
"GPU": ["Gaming", "Workstation"],
|
||||
"RAM": ["DDR4", "DDR5"]
|
||||
}
|
||||
}
|
||||
}
|
||||
420
consigne codex.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Prompt Codex — Projet **suivi_produits** (Amazon.fr → extensible multi-stores)
|
||||
|
||||
## Rôle de Codex
|
||||
Tu es un **agent senior** en développement **Python web**, **Playwright**, **SQL/SQLite**, **architecture logicielle**, **UI frontend** (responsive).
|
||||
tu me parlera en francais dans la discussion et le code devra etre commenté regulieremnt en francais aussi.
|
||||
Tu dois :
|
||||
1) faire un **brainstorming** pragmatique, 2) proposer un **plan** en phases, 3) définir une **structure de projet** claire, 4) implémenter un **MVP** testable, 5) industrialiser (tests, logs, cron, docker-compose), 6) préparer l’**évolution multi-boutiques**.
|
||||
|
||||
Contraintes clés :
|
||||
- Scraping : **Python + Playwright** (robuste, peu de produits : 15–20/jour).
|
||||
- Stockage : **JSON** (raw scrap) + persistance en **SQLite** (historique prix + métriques).
|
||||
- Config :
|
||||
- `config_backend.json` (paramètres scraping + backend + catégories/types)
|
||||
- `config_frontend.json` (paramètres UI + colonnes + thème + bouton mode text/icon)
|
||||
- Logs : fichier de log des scrapes (rotation simple).
|
||||
- UI : **Gruvbox vintage dark**, moderne (ombres, arrondis, typo lisible), responsive. icon fa
|
||||
- Déploiement : test en mode .env puis deploiement final dans **docker-compose** (backend + frontend) + cron/worker pour scrapes périodiques.
|
||||
- Repo : sur mon serveur Gitea, nom : **suivi_produits** : https://gitea.maison43.duckdns.org/gilles/suivi_produit
|
||||
|
||||
---
|
||||
|
||||
## Objectif produit (fonctionnel)
|
||||
Application self-hosted pour suivre l’évolution de produits Amazon.fr (puis autres stores). L’utilisateur ajoute des URLs de produits, l’app :
|
||||
- scrape les données clés (prix, stock, note, badges, image, etc.)
|
||||
- stocke un **snapshot** à chaque scraping
|
||||
- affiche des **vignettes produit** + **graphique historique** clair (tendance, min/max, %)
|
||||
- propose actions : **Scrap**, **Edit**, **Delete**, **Détail**
|
||||
- lance un **scraping planifié** (cron) sur tous les produits à intervalle défini.
|
||||
|
||||
---
|
||||
|
||||
## Données à capturer (Amazon.fr)
|
||||
### Champs de base (toujours)
|
||||
- `url` (canonique) + `asin`
|
||||
- `title` (nom produit)
|
||||
- `image_main_url` (image principale)
|
||||
- `price_current` (valeur numérique + devise)
|
||||
- `stock_status` (texte) + `in_stock` (bool)
|
||||
- `rating_value` (float) + `rating_count` (int)
|
||||
|
||||
### Champs conditionnels (si présents)
|
||||
- `price_list` / prix conseillé / prix barré (si affiché)
|
||||
- `discount_percent` (si affiché ou calculé)
|
||||
- `lowest_30d_price` (si mention “prix le plus bas des 30 derniers jours”)
|
||||
- `amazon_choice` (badge)
|
||||
- `limited_time_deal` (offre à durée limitée)
|
||||
- `prime_eligible` (badge prime / livraison prime)
|
||||
- `amazon_exclusive` (mention exclusivité)
|
||||
|
||||
### Calculs à faire côté app (pas “inventer”)
|
||||
> Important : **ne pas “calculer une réduction” si le champ source n’existe pas**. on ne calcule rien sauf demande explicite de ma part ( peut etre tendance dans courbe historique)
|
||||
|
||||
---
|
||||
|
||||
## Méthode de scraping (sûre/efficace)
|
||||
### Stratégie Playwright
|
||||
- Navigateur Chromium.
|
||||
- Context :
|
||||
- locale `fr-FR`
|
||||
- timezone `Europe/Paris`
|
||||
- viewport réaliste (ex. 1366×768 ou 1920×1080)
|
||||
- user-agent récent
|
||||
- Rythme : faible (15–20 produits/jour) + **delays aléatoires** 1–3s entre pages.
|
||||
- Détection blocages :
|
||||
- si page contient captcha / robot-check → marquer scrap “blocked” + screenshot + html dump pour debug.
|
||||
- Résilience :
|
||||
- retry 1–2 fois max avec backoff, sinon échec contrôlé.
|
||||
|
||||
### Sélecteurs (approche robuste)
|
||||
- Priorité : **IDs stables** (ex : `#productTitle`, `#acrCustomerReviewText`, `#availability`)
|
||||
- Prix : gérer variantes (prix fractionné, promo, etc.)
|
||||
- Fallback : si sélecteur absent, log “missing field”, ne pas planter.
|
||||
|
||||
### Artifacts de debug
|
||||
À chaque scrap :
|
||||
- sauvegarder un JSON “raw” normalisé
|
||||
- en cas d’échec : `page.screenshot()` + `page.content()` dans un dossier `debug/` horodaté.
|
||||
|
||||
---
|
||||
|
||||
## Architecture cible
|
||||
### Backend
|
||||
- API HTTP (proposé : **FastAPI**) :
|
||||
- CRUD produits
|
||||
- déclenchement scrap (produit / tous)
|
||||
- lecture historique + agrégats (min/max/tendance)
|
||||
- lecture/écriture configs frontend/backend
|
||||
- Worker de scraping (Playwright) séparé en module “scraper”
|
||||
- Scheduler (cron interne ou cron container) qui appelle `scrape_all`
|
||||
|
||||
### Frontend
|
||||
- SPA simple (proposé : **Vite + React** ou **Svelte**) ou HTML server-side minimal (selon simplicité).
|
||||
- Thème **Gruvbox vintage dark** :
|
||||
- fond #282828, cartes #3c3836, texte #ebdbb2
|
||||
- accent orange #fe8019, jaune #fabd2f, vert #b8bb26
|
||||
- Responsive : nombre de colonnes configurable.
|
||||
- utilisation de popup lors de l'ajout de produit ou acces a setting
|
||||
### Stockage
|
||||
- le stockage se fais uniquement lors de l'enregistrement du produit
|
||||
- SQLite : tables normalisées (produits, snapshots, tags/catégories/types)
|
||||
- JSON “raw” : archivage optionnel (dossier `data/raw/YYYY-MM/...json`)
|
||||
|
||||
---
|
||||
|
||||
## Structure de projet (proposée et a adpater)
|
||||
```
|
||||
suivi_produits/
|
||||
README.md
|
||||
TODO.md
|
||||
CHANGELOG.md
|
||||
kanban.md
|
||||
docs/
|
||||
backend/
|
||||
app/
|
||||
main.py
|
||||
api/
|
||||
routes_products.py
|
||||
routes_scrape.py
|
||||
routes_config.py
|
||||
routes_stats.py
|
||||
core/
|
||||
config.py # charge config_backend.json
|
||||
logging.py
|
||||
scheduler.py # déclenche scrapes (optionnel)
|
||||
db/
|
||||
database.py # sqlite connection
|
||||
models.py # SQLAlchemy models
|
||||
schemas.py # pydantic
|
||||
crud.py
|
||||
migrations/ # si besoin plus tard
|
||||
scraper/
|
||||
amazon/
|
||||
parser.py # extraction DOM -> dict normalisé
|
||||
selectors.md # doc sélecteurs
|
||||
browser.py # création context Playwright
|
||||
runner.py # scrap 1 / scrap all
|
||||
normalize.py # parsing prix, notes, booléens
|
||||
services/
|
||||
pricing.py # calculs 30j si données
|
||||
images.py
|
||||
tests/
|
||||
test_normalize.py
|
||||
test_parser_samples.py
|
||||
samples/
|
||||
amazon_product.html # snapshots HTML (tests)
|
||||
config_backend.json
|
||||
logs/
|
||||
scrap.log
|
||||
data/
|
||||
raw/
|
||||
screenshots/
|
||||
|
||||
frontend/
|
||||
src/
|
||||
app/
|
||||
components/
|
||||
styles/
|
||||
api/
|
||||
public/
|
||||
config_frontend.json
|
||||
|
||||
docker/
|
||||
docker-compose.yml
|
||||
backend.Dockerfile
|
||||
frontend.Dockerfile
|
||||
nginx.conf (option)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schéma ASCII (UI globale)
|
||||
Objectif : reproduire l’esprit de la capture (vignette + section prix + graphe), en améliorant la lisibilité.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ suivi_produits [Add Product] [Refresh] [Settings] FE vX BE vY │
|
||||
│ (header fixed) [debug] (⋯) │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Grid (cols = config_frontend.json) │
|
||||
│ │
|
||||
│ ┌──────────────────────────── Card Produit ──────────────────────────┐ │
|
||||
│ │ Boutique + Titre (2 lignes) │ │
|
||||
│ │ Amazon │ │
|
||||
│ │ Samsung SSD Interne 9100 Pro… │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────┐ ┌──────────────────────────────────────────┐ │ │
|
||||
│ │ │ Image │ │ ACTUEL 249€99 │ │ │
|
||||
│ │ │ (non rognée) │ │ PRIX CONSEILLÉ 329€99 (si présent) │ │ │
|
||||
│ │ │ │ │ RÉDUCTION -24% (si présent) │ │ │
|
||||
│ │ └───────────────┘ │ STOCK Disponible │ │ │
|
||||
│ │ │ NOTE 4,7 (967) │ │ │
|
||||
│ │ │ CHOIX AMAZON Oui/Non │ │ │
|
||||
│ │ │ PRIME Oui/Non │ │ │
|
||||
│ │ │ DEAL Oui/Non │ │ │
|
||||
│ │ │ Ref: ASIN [Lien produit] │ │ │
|
||||
│ │ └──────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────── Graph 30j (clair) ─────────────────────┐ │ │
|
||||
│ │ │ ligne + points, axes lisibles, tooltip │ │ │
|
||||
│ │ └──────────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Min 249€99 Max 249€99 Tendance → stable +0.0% Dernier: now │ │
|
||||
│ │ Catégorie: SSD Type: NVMe │ │
|
||||
│ │ │ │
|
||||
│ │ [Scrap] [Edit] [Delete] [Détail] │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
page de debug et log qui affiche le contenue des differentes tables sqlite dans des section distincte, une section log qui affiche les log json de scrap
|
||||
---
|
||||
|
||||
## Vignette produit : exigences UI
|
||||
- Image **non tronquée** : object-fit `contain`, fond neutre, padding.
|
||||
- Section prix alignée au niveau de l’image (descendue comme sur la capture).
|
||||
- “Boutique + titre” sur 2 lignes, pleine largeur, icône boutique.
|
||||
- Badges : Amazon Choice / Prime / Deal / Exclusive (chips).
|
||||
- Graph 30j :
|
||||
- axes + labels lisibles
|
||||
- points visibles
|
||||
- min/max/tendance affichés sous le graphe
|
||||
- couleurs + flèches :
|
||||
- baisse : vert + flèche ↓
|
||||
- stable : jaune/orange + →
|
||||
- hausse : rouge + ↑
|
||||
- Responsive :
|
||||
- `columns` paramétrable (desktop)
|
||||
- mobile : 1 colonne + layout empilé
|
||||
|
||||
---
|
||||
|
||||
## `config_backend.json` (spécification)
|
||||
Contenu attendu :
|
||||
- `app`: { `env`, `version`, `base_url`, `log_level` }
|
||||
- `scrape`:
|
||||
- `interval_minutes` (pour cron/worker)
|
||||
- `headless` (bool)
|
||||
- `timeout_ms`
|
||||
- `retries`
|
||||
- `delay_range_ms`: [min,max]
|
||||
- `user_agent`
|
||||
- `viewport`: {w,h}
|
||||
- `locale`, `timezone`
|
||||
- `proxy` (option) => non pas de proxy
|
||||
- `stores_enabled`: ["amazon_fr"]
|
||||
- `taxonomy`:
|
||||
- `categories`: ["SSD", "CPU", ...]
|
||||
- `types_by_category`: { "SSD": ["NVMe", "SATA"], ... }
|
||||
|
||||
---
|
||||
|
||||
## `config_frontend.json` (spécification)
|
||||
- `ui`:
|
||||
- `theme`: "gruvbox_vintage_dark"
|
||||
- 'button_mode': " text/icon"
|
||||
- `columns_desktop`: 3 (ex) => slider
|
||||
- `card_density`: "comfortable"|"compact"
|
||||
- `show_fields`: { flags }
|
||||
- `refresh_auto_seconds`
|
||||
- `versions`: { `frontend`, `backend_expected` }
|
||||
|
||||
---
|
||||
|
||||
## Schéma base de données (SQLite)
|
||||
Proposer un schéma minimal + extensible.
|
||||
|
||||
### Tables
|
||||
1) `products`
|
||||
- `id` (PK)
|
||||
- `store` (ex: amazon_fr)
|
||||
- `url`
|
||||
- `asin`
|
||||
- `title`
|
||||
- `image_url`
|
||||
- `category`
|
||||
- `type`
|
||||
- `is_active` (bool)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
2) `scrape_runs`
|
||||
- `id` (PK)
|
||||
- `started_at`, `ended_at`
|
||||
- `status` (success/partial/failed)
|
||||
- `items_total`, `items_ok`, `items_failed`
|
||||
- `log_path` (option)
|
||||
|
||||
3) `product_snapshots`
|
||||
- `id` (PK)
|
||||
- `product_id` (FK → products.id)
|
||||
- `scraped_at`
|
||||
- `price_current`
|
||||
- `price_list` (nullable)
|
||||
- `lowest_30d_price` (nullable)
|
||||
- `stock_text` (nullable)
|
||||
- `in_stock` (nullable)
|
||||
- `rating_value` (nullable)
|
||||
- `rating_count` (nullable)
|
||||
- `prime_eligible` (nullable)
|
||||
- `amazon_choice` (nullable)
|
||||
- `limited_time_deal` (nullable)
|
||||
- `amazon_exclusive` (nullable)
|
||||
- `raw_json_path` (nullable)
|
||||
- `scrape_status` (ok/blocked/missing_fields/error)
|
||||
- `error_message` (nullable)
|
||||
|
||||
4) (option) `tags`
|
||||
- `id`, `name`
|
||||
|
||||
5) (option) `product_tags`
|
||||
- `product_id`, `tag_id`
|
||||
|
||||
### Relations
|
||||
- `products (1) ─── (N) product_snapshots`
|
||||
- `scrape_runs (1) ─── (N) product_snapshots` (optionnel si on lie snapshots à run)
|
||||
|
||||
### Indices
|
||||
- index `(product_id, scraped_at)`
|
||||
- index `asin`
|
||||
|
||||
---
|
||||
page web : logs et debug
|
||||
|
||||
## Plan de développement (phases)
|
||||
### Phase 0 — Setup repo & conventions
|
||||
- initialiser repo `suivi_produits`
|
||||
- ajouter `README.md`, `TODO.md`, `CHANGELOG.md`, `kanban.md`
|
||||
- définir conventions : formatage, lint, tests
|
||||
|
||||
### Phase 1 — MVP Backend + DB
|
||||
- FastAPI + SQLite + SQLAlchemy
|
||||
- endpoints :
|
||||
- `GET /health`
|
||||
- `GET/POST/PUT/DELETE /products`
|
||||
- `POST /scrape/product/{id}`
|
||||
- `POST /scrape/all`
|
||||
- `GET /products/{id}/history?days=30`
|
||||
- `GET/PUT /config/backend`
|
||||
|
||||
### Phase 2 — Scraper Amazon (Playwright)
|
||||
- module `scraper/amazon/parser.py`
|
||||
- normalisation des prix (ex: "249,99 €" → 249.99)
|
||||
- gestion champs optionnels (pas d’invention)
|
||||
- logs + debug artifacts
|
||||
- tests unitaires sur HTML samples (fichiers dans `samples/`)
|
||||
|
||||
### Phase 3 — Frontend (vignettes + graph)
|
||||
- grille responsive, colonnes paramétrables
|
||||
- composant CardProduit
|
||||
- graphique 30j (lib : chart.js / echarts / recharts selon stack)
|
||||
- page Settings : frontend + backend config éditables
|
||||
|
||||
### Phase 4 — Scheduler / Cron
|
||||
- job toutes les X minutes/heures (config)
|
||||
- stockage des snapshots
|
||||
- endpoint stats (dernier scrap, erreurs, taux de succès)
|
||||
|
||||
### Phase 5 — Docker Compose
|
||||
- dockeriser backend + frontend
|
||||
- volume persistant SQLite + logs + raw json
|
||||
- doc d’installation
|
||||
|
||||
### Phase 6 — Évolution multi-stores
|
||||
- abstraction `StoreScraper` (interface)
|
||||
- un module par boutique : `scraper/<store_name>/...`
|
||||
- routing : store → scraper
|
||||
|
||||
---
|
||||
|
||||
## Tests (exigences)
|
||||
- Tests unitaires :
|
||||
- parsing prix FR
|
||||
- extraction rating/count
|
||||
- présence/absence champs optionnels
|
||||
- Tests d’intégration :
|
||||
- scrap d’un produit réel (option “manual”) avec variable `RUN_LIVE_SCRAPE=1`
|
||||
- sinon, replay HTML sample
|
||||
|
||||
---
|
||||
|
||||
## Livrables attendus
|
||||
- Code complet backend + frontend (MVP fonctionnel)
|
||||
- `docker-compose.yml`
|
||||
- docs :
|
||||
- `README.md` (install, run, config)
|
||||
- `TODO.md` (backlog)
|
||||
- `CHANGELOG.md` (semver simple)
|
||||
- `kanban.md` (colonnes : Backlog / Doing / Review / Done)
|
||||
- Schéma DB + migrations (si besoin)
|
||||
|
||||
---
|
||||
|
||||
## Questions à poser (bloquantes ou importantes)
|
||||
1) Frontend : tu préfères **React** (Vite) ou **Svelte** ? (sinon choisis la voie la plus simple et robuste)
|
||||
2) Déclenchement cron : tu veux un **cron linux** (container cron) ou un **scheduler interne** (APScheduler) ?
|
||||
3) Auth : l’app est-elle accessible uniquement en LAN (pas d’auth) ou tu veux un login simple ? => non
|
||||
4) Stockage raw JSON : tu veux conserver **tous** les raw ou uniquement les derniers N jours ? => oui
|
||||
5) Mode “captcha” : en cas de blocage, tu veux :
|
||||
- (a) abandon + log + retry plus tard => oui
|
||||
- (b) ouvrir navigateur headful pour résolution manuelle
|
||||
|
||||
|
||||
Si pas de réponse, prends des décisions raisonnables : React+Vite, scheduler interne APScheduler, pas d’auth (LAN), raw conservé 30 jours, stratégie (a).
|
||||
|
||||
---
|
||||
|
||||
## Notes de qualité (non négociables)
|
||||
- Ne jamais faire planter un scrap si un champ manque.
|
||||
- Ne pas calculer des métriques si aucune donnée n’est disponible sur la page.
|
||||
- UI : lisibilité d’abord (contraste, spacing, hiérarchie typographique).
|
||||
- Logs : chaque scrap doit laisser une trace claire (start/end, erreurs, champs manquants).
|
||||
|
||||
---
|
||||
|
||||
## Démarrage immédiat (premières tâches)
|
||||
1) Créer squelette repo + outils (poetry/pip-tools, pre-commit, ruff, mypy)
|
||||
2) Implémenter DB + CRUD produits
|
||||
3) Implémenter scrap d’un produit Amazon + snapshot
|
||||
4) Afficher vignettes + graph 30j
|
||||
5) Ajouter Settings + configs JSON éditables
|
||||
6) Dockeriser
|
||||
16
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Architecture cible
|
||||
|
||||
## Backend
|
||||
- FastAPI (routes, services, scheduler)
|
||||
- SQLite via SQLAlchemy
|
||||
- Scraper Playwright avec module par boutique
|
||||
- Logs/artefacts dans `backend/logs` et `backend/data`
|
||||
|
||||
## Frontend
|
||||
- SPA (React/Vite par défaut)
|
||||
- Thème Gruvbox dark, responsive
|
||||
- Configurable via `frontend/config_frontend.json`
|
||||
|
||||
## Déploiement
|
||||
- Docker Compose pour backend+frontend
|
||||
- Cron interne (APScheduler) pour `scrape_all`
|
||||
83
docs/db_structure.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Structure base de données
|
||||
|
||||
## Vue d'ensemble
|
||||
- `products` : produit suivi (URL canonique, store, catégories, statut).
|
||||
- `scrape_runs` : trace des runs pour mesurer taux réussite / blocages.
|
||||
- `product_snapshots` : historique des données extraites (prix, stock, badges, raw JSON).
|
||||
|
||||
## Diagramme
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ products │ 1 N │ product_ │
|
||||
│ (stores) │──────>│ snapshots │
|
||||
│ id PK │ │ id PK │
|
||||
│ store │ │ product_id FK│
|
||||
│ url │ │ scraped_at │
|
||||
│ asin │ │ price_current│
|
||||
│ ... │ │ stock_text │
|
||||
└─────────────┘ │ ... │
|
||||
└──────────────┘
|
||||
|
||||
┌──────────────┐
|
||||
│ scrape_runs │
|
||||
│ id PK │
|
||||
│ started_at │
|
||||
│ ended_at │
|
||||
│ status │
|
||||
│ items_ok │
|
||||
│ items_failed │
|
||||
│ items_total │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
> Commentaire : `products` lie les snapshots ; `scrape_runs` peut être lié aux snapshots si on enregistre `scrape_run_id`.
|
||||
> Commentaire : les indexes `product_id`, `scraped_at` et `asin` servent à accélérer les historiques et recherches.
|
||||
|
||||
## Schéma base de données (SQLite)
|
||||
Proposer un schéma minimal + extensible.
|
||||
|
||||
### Tables
|
||||
1) `products`
|
||||
- `id` (PK)
|
||||
- `store` (ex: amazon_fr)
|
||||
- `url`
|
||||
- `asin`
|
||||
- `title`
|
||||
- `image_url`
|
||||
- `category`
|
||||
- `type`
|
||||
- `is_active` (bool)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
2) `scrape_runs`
|
||||
- `id` (PK)
|
||||
- `started_at`, `ended_at`
|
||||
- `status` (success/partial/failed)
|
||||
- `items_total`, `items_ok`, `items_failed`
|
||||
- `log_path` (option)
|
||||
|
||||
3) `product_snapshots`
|
||||
- `id` (PK)
|
||||
- `product_id` (FK → products.id)
|
||||
- `scraped_at`
|
||||
- `price_current`
|
||||
- `price_list` (nullable)
|
||||
- `lowest_30d_price` (nullable)
|
||||
- `stock_text` (nullable)
|
||||
- `in_stock` (nullable)
|
||||
- `rating_value` (nullable)
|
||||
- `rating_count` (nullable)
|
||||
- `prime_eligible` (nullable)
|
||||
- `amazon_choice` (nullable)
|
||||
- `limited_time_deal` (nullable)
|
||||
- `amazon_exclusive` (nullable)
|
||||
- `raw_json_path` (nullable)
|
||||
- `scrape_status` (ok/blocked/missing_fields/error)
|
||||
- `error_message` (nullable)
|
||||
|
||||
4) (option) `tags`
|
||||
- `id`, `name`
|
||||
|
||||
5) (option) `product_tags`
|
||||
- `product_id`, `tag_id`
|
||||
53
docs/frontend_structure.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Structure frontend
|
||||
|
||||
## Layout principal
|
||||
- Header fixe (logo + actions + versions)
|
||||
- Grille responsive de cartes produits.
|
||||
- Zone debug/logs avec sections SQLite et journaux JSON. (repliable)
|
||||
|
||||
## Diagramme ASCII (UI globale)
|
||||
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ suivi_produits [Add Product] [Refresh] [Settings] FE vX BE vY │
|
||||
│ (header fixed) [debug] (⋯) │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Grid (cols = config_frontend.json) │
|
||||
│ │
|
||||
│ ┌──────────────────────────── Card Produit ──────────────────────────┐ │
|
||||
│ │ Boutique + Titre (2 lignes) │ │
|
||||
│ │ Amazon │ │
|
||||
│ │ Samsung SSD Interne 9100 Pro… │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────┐ ┌──────────────────────────────────────────┐ │ │
|
||||
│ │ │ Image │ │ ACTUEL 249€99 │ │ │
|
||||
│ │ │ (non rognée) │ │ PRIX CONSEILLÉ 329€99 (si présent) │ │ │
|
||||
│ │ │ │ │ RÉDUCTION -24% (si présent) │ │ │
|
||||
│ │ └───────────────┘ │ STOCK Disponible │ │ │
|
||||
│ │ │ NOTE 4,7 (967) │ │ │
|
||||
│ │ │ CHOIX AMAZON Oui/Non │ │ │
|
||||
│ │ │ PRIME Oui/Non │ │ │
|
||||
│ │ │ DEAL Oui/Non │ │ │
|
||||
│ │ │ Ref: ASIN [Lien produit] │ │ │
|
||||
│ │ └──────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────── Graph 30j (clair) ─────────────────────┐ │ │
|
||||
│ │ │ ligne + points, axes lisibles, tooltip │ │ │
|
||||
│ │ └──────────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Min 249€99 Max 249€99 Tendance → stable +0.0% Dernier: now │ │
|
||||
│ │ Catégorie: SSD Type: NVMe │ │
|
||||
│ │ │ │
|
||||
│ │ [Scrap] [Edit] [Delete] [Détail] │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
page de debug et log qui affiche le contenue des differentes tables sqlite dans des section distincte, une section log qui affiche les log json de scrap
|
||||
---
|
||||
|
||||
|
||||
|
||||
> Commentaire : le render React maintient `columns_desktop` depuis `config_frontend.json` et passe en 1 colonne sur mobile.
|
||||
> Commentaire : chaque `Card` inclut image non rognée, badges, actions (Scrap/Edit/Delete/Détail) et graphique 30j.
|
||||
53
docs/scrap.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Procédure de scraping Amazon.fr
|
||||
|
||||
## Objectif
|
||||
- Extraire les champs requis (prix, stock, note, badges, image) sans casser le pipeline si un champ manque.
|
||||
- Produire un snapshot propre + artefacts de debug si blocage.
|
||||
|
||||
## Pré-requis
|
||||
- Playwright installé (Chromium).
|
||||
- Config lue depuis `backend/config_backend.json`.
|
||||
- Lancement via `scrape_product` ou `scrape_all`.
|
||||
|
||||
## Étapes de scraping
|
||||
1. **Initialiser le navigateur**
|
||||
- Chromium, locale `fr-FR`, timezone `Europe/Paris`, viewport réaliste.
|
||||
- `user-agent` défini dans la config.
|
||||
|
||||
2. **Charger la page produit**
|
||||
- Délai aléatoire 1–3s entre requêtes.
|
||||
- Timeout contrôlé.
|
||||
|
||||
3. **Détecter blocage/captcha**
|
||||
- Si captcha / robot-check :
|
||||
- Marquer `scrape_status = blocked`.
|
||||
- Sauvegarder `screenshot` + `page.content()` dans `backend/data/screenshots`.
|
||||
- Log détaillé + retour sans crash.
|
||||
|
||||
4. **Extraire les champs**
|
||||
- Priorité aux IDs stables (ex: `#productTitle`, `#acrCustomerReviewText`, `#availability`).
|
||||
- Prix : gérer variantes (prix fractionné, promo).
|
||||
- Champs optionnels : si absent → `null` + log "missing field".
|
||||
|
||||
5. **Normaliser les valeurs**
|
||||
- Prix : `"249,99 €"` → `249.99`.
|
||||
- Notes : `"4,7 sur 5"` → `4.7`.
|
||||
- Stock : `in_stock` booléen + texte brut.
|
||||
|
||||
6. **Sauvegarder le snapshot**
|
||||
- Insérer un enregistrement `product_snapshots`.
|
||||
- Écrire un JSON raw dans `backend/data/raw/YYYY-MM/...`.
|
||||
|
||||
## Logs & erreurs
|
||||
- Chaque scrap doit tracer : start, fields manquants, status, fin.
|
||||
- Échec contrôlé si un champ est absent (jamais de crash global).
|
||||
|
||||
## Champs obligatoires
|
||||
- `url`, `asin`, `title`, `image_main_url`, `price_current`, `stock_status`, `rating_value`, `rating_count`.
|
||||
|
||||
## Champs optionnels
|
||||
- `price_list`, `discount_percent`, `lowest_30d_price`, `amazon_choice`, `limited_time_deal`, `prime_eligible`, `amazon_exclusive`.
|
||||
|
||||
## Notes
|
||||
- Pas de calcul inventé (pas de réduction sans source).
|
||||
- Stratégie captcha par défaut : abandon + log + retry plus tard.
|
||||
22
docs/tools.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Outils utilisés
|
||||
|
||||
## Backend
|
||||
- **Python 3.11+** : langage principal pour API FastAPI, gestion SQLite et scraping Playwright.
|
||||
- **Poetry** : gestionnaire des dépendances/tests (`poetry run pytest`) et packaging.
|
||||
- **FastAPI + Uvicorn** : framework asynchrone + ASGI pour exposer les endpoints REST.
|
||||
- **SQLAlchemy** : ORM pour modéliser `products`, `scrape_runs`, `product_snapshots`.
|
||||
- **Playwright** : navigateur Chromium pour le scraping Amazon.fr.
|
||||
- **Loguru** : logs structurés avec rotation simple.
|
||||
- **APScheduler** : scheduler interne pour déclencher `scrape_all` selon `interval_minutes`.
|
||||
|
||||
## Frontend
|
||||
- **React + Vite** : SPA rapide, bundler moderne et hot reload.
|
||||
- **Chart.js** : visualisation des historiques (courbes 30j).
|
||||
- **FontAwesome** : icônes et badges (prime, amazon choice, deal, etc.).
|
||||
- **Sass** : styles Gruvbox (variables, mixins, responsive grids).
|
||||
|
||||
## Outils DevOps & QA
|
||||
- **Docker Compose** (à venir) : orchestrera backend + frontend + scheduler.
|
||||
- **.env / dotenv** : configuration flexible (`.env.example`).
|
||||
- **Pytest / Ruff / Mypy** : tests unitaires + lint + typage.
|
||||
- **Pre-commit** : hooks `ruff` et `mypy` pour garantir la qualité avant commit.
|
||||
19
frontend/config_frontend.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"ui": {
|
||||
"theme": "gruvbox_vintage_dark",
|
||||
"button_mode": "text/icon",
|
||||
"columns_desktop": 3,
|
||||
"card_density": "comfortable",
|
||||
"show_fields": {
|
||||
"price": true,
|
||||
"stock": true,
|
||||
"ratings": true,
|
||||
"badges": true
|
||||
},
|
||||
"refresh_auto_seconds": 300
|
||||
},
|
||||
"versions": {
|
||||
"frontend": "0.1.0",
|
||||
"backend_expected": "0.1.0"
|
||||
}
|
||||
}
|
||||
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "suivi_produit_frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"chart.js": "^4.4.0",
|
||||
"zustand": "^4.4.0",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.1.3",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"sass": "^2.0.0"
|
||||
}
|
||||
}
|
||||
12
frontend/public/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>suivi_produits</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
frontend/src/App.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
const App = () => (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<div className="brand">suivi_produits</div>
|
||||
<div className="actions">
|
||||
<button className="btn">Add Product</button>
|
||||
<button className="btn">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-grid">
|
||||
{/* état vide avant ajout de produit */}
|
||||
<section className="empty-state">
|
||||
<p>Aucun produit pour l'instant, ajoutez un lien Amazon.fr !</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default App;
|
||||
7
frontend/src/api/client.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8008";
|
||||
|
||||
export const fetchProducts = async () => {
|
||||
// point d'entrée simple vers l'API FastAPI
|
||||
const response = await fetch(`${BASE_URL}/products`);
|
||||
return response.json();
|
||||
};
|
||||
14
frontend/src/components/ProductCard.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
const ProductCard = ({ product }) => (
|
||||
<article className="product-card">
|
||||
{/* vignette produit : image + infos principales */}
|
||||
<div className="product-image" />
|
||||
<div className="product-info">
|
||||
<h3>{product.titre}</h3>
|
||||
<p>Prix actuel : {product.prix_actuel ?? "-"} €</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default ProductCard;
|
||||
11
frontend/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/global.scss";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
{/* point d’entrée de l’interface React */}
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
56
frontend/src/styles/global.scss
Normal file
@@ -0,0 +1,56 @@
|
||||
$bg: #282828;
|
||||
$text: #ebdbb2;
|
||||
$card: #3c3836;
|
||||
$accent: #fe8019;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
background: $bg;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
background: $card;
|
||||
border: none;
|
||||
color: $text;
|
||||
padding: 8px 16px;
|
||||
margin-left: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: $card;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
9
frontend/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
15
kanban.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Kanban
|
||||
|
||||
## Backlog
|
||||
- Initialiser FastAPI + SQLite
|
||||
- Parser Amazon + tests
|
||||
- UI vignettes + graphique
|
||||
|
||||
## Doing
|
||||
- En cours : structure repo
|
||||
|
||||
## Review
|
||||
-
|
||||
|
||||
## Done
|
||||
-
|
||||
34
pyproject.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[tool.poetry]
|
||||
name = "suivi_produit"
|
||||
version = "0.1.0"
|
||||
description = "Suivi de produits Amazon avec scraper Playwright + UI Gruvbox"
|
||||
authors = ["gilles <gilles@example.com>"]
|
||||
readme = "README.md"
|
||||
packages = [
|
||||
{ include = "backend", from = "backend" }
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
fastapi = "^0.109.0"
|
||||
uvicorn = { extras = ["standard"], version = "^0.24.0" }
|
||||
sqlalchemy = "^2.0"
|
||||
pydantic = "^2.5"
|
||||
apscheduler = "^3.11"
|
||||
playwright = "^1.40"
|
||||
loguru = "^0.7"
|
||||
python-dotenv = "^1.0"
|
||||
beautifulsoup4 = "^4.12"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.4"
|
||||
ruff = "^0.1"
|
||||
mypy = "^1.9"
|
||||
pre-commit = "^3.5"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
backend = "backend.app.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.5.1"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||