This commit is contained in:
2026-01-18 12:23:01 +01:00
parent ef3d0ed970
commit bb1263edb8
86 changed files with 90289 additions and 0 deletions

BIN
.coverage Normal file

Binary file not shown.

5
.env.example Normal file
View 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
View File

@@ -0,0 +1,5 @@
.venv/
backend/data/
backend/logs/
frontend/node_modules/
.env

11
.pre-commit-config.yaml Normal file
View 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
View 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
View 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
View 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 à lavancement.
- 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 larchitecture (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 lensemble.
- Toute logique métier critique est accompagnée de tests (unitaires ou dintégration) avant validation.
## Mode de travail par phases
1. **Phases exploratoires** : brainstorming, plan phase, architecture, choix stack.
2. **Phases dimplémentation MVP** : backend + DB, scraper Amazon, frontend vignettes + graph, settings.
3. **Phases dindustrialisation** : 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
View File

@@ -0,0 +1 @@
"""Package racine du backend suivi_produits."""

Binary file not shown.

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Package backend principal de suivi des produits."""

Binary file not shown.

View 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
View 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()

View 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))

View 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)

View 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"}

Binary file not shown.

View 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)

View 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

View 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
View 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()

View 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
View 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
View 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
View 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

View 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>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

View 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": "4884 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 quil 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": "2130 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": "16846 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": "7941 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'}"
]
}
}
]
}

View 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"
]
}

View 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"
}
]
}

File diff suppressed because one or more lines are too long

View 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

View 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`

View 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()

View 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: "1249,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

View 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()

View 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()

View File

@@ -0,0 +1 @@
"""Services utilitaires côté backend."""

View 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"

View File

Binary file not shown.

View 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

View 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

View 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"

View 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
View 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 : 1520/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). Lutilisateur ajoute des URLs de produits, lapp :
- 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 nexiste 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 (1520 produits/jour) + **delays aléatoires** 13s entre pages.
- Détection blocages :
- si page contient captcha / robot-check → marquer scrap “blocked” + screenshot + html dump pour debug.
- Résilience :
- retry 12 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 lesprit 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 limage (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 dinvention)
- 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 dinstallation
### 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 dintégration :
- scrap dun 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 : lapp est-elle accessible uniquement en LAN (pas dauth) 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 dauth (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 nest disponible sur la page.
- UI : lisibilité dabord (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 dun produit Amazon + snapshot
4) Afficher vignettes + graph 30j
5) Ajouter Settings + configs JSON éditables
6) Dockeriser

16
docs/ARCHITECTURE.md Normal file
View 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
View 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`

View 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
View 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 13s 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
View 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.

View 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
View 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"
}
}

View 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
View 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;

View 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();
};

View 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
View 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 dentrée de linterface React */}
<App />
</React.StrictMode>
);

View 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
View 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
View 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
View 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"