before claude
@@ -53,11 +53,14 @@ Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/)
|
||||
- Web UI: popup ajout produit central + favicon
|
||||
- API: logs Uvicorn exposes pour l UI
|
||||
- Parsing prix: gestion des separateurs de milliers (espace, NBSP, point)
|
||||
- API/DB: description + msrp + images/specs exposes, reduction calculee
|
||||
- API/DB: exposition des champs Amazon enrichis (note, badge, stock texte, modele)
|
||||
- Web UI: carte produit analytique avec resume, historique plein format et actions compactes
|
||||
- Web UI: slider colonnes responsive + modal ajout produit scrollable avec footer sticky
|
||||
|
||||
### Corrigé
|
||||
- Migration Alembic: down_revision aligne sur 20260114_02
|
||||
- Amazon: extraction images via data-a-dynamic-image + filtrage logos
|
||||
- API: suppression du calcul automatique des reductions (valeurs explicites uniquement)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -152,6 +152,8 @@ Guide de migration JSON -> DB: `MIGRATION_GUIDE.md`
|
||||
|
||||
L'API est protegee par un token simple.
|
||||
|
||||
Note: l endpoint `/products` expose des champs Amazon explicites (asin, note, badge Choix d Amazon, stock_text/in_stock, model_number/model_name, main_image/gallery_images). Les reductions ne sont plus calculees cote API.
|
||||
|
||||
```bash
|
||||
export PW_API_TOKEN=change_me
|
||||
docker compose up -d api
|
||||
|
||||
1
TODO.md
@@ -170,6 +170,7 @@ Liste des tâches priorisées pour le développement de PriceWatch.
|
||||
- [x] Tests performance (100+ produits)
|
||||
- [x] CRUD produits
|
||||
- [x] Historique prix
|
||||
- [ ] Ajouter migration DB pour les nouveaux champs Amazon (note, badge, stock texte, modele)
|
||||
|
||||
### Documentation
|
||||
- [x] Migration guide (JSON -> DB)
|
||||
|
||||
@@ -76,6 +76,81 @@ def _serialize_decimal(value):
|
||||
return value
|
||||
|
||||
|
||||
def fetch_product_history(product_id: int) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""Récupère l'historique complet des scraps pour un produit."""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ph.id,
|
||||
ph.price,
|
||||
ph.shipping_cost,
|
||||
ph.stock_status,
|
||||
ph.fetch_method,
|
||||
ph.fetch_status,
|
||||
ph.fetched_at
|
||||
FROM price_history ph
|
||||
WHERE ph.product_id = %s
|
||||
ORDER BY ph.fetched_at DESC
|
||||
""",
|
||||
(product_id,),
|
||||
)
|
||||
fetched = cur.fetchall()
|
||||
for item in fetched:
|
||||
serialized = {key: _serialize_decimal(value) for key, value in item.items()}
|
||||
if serialized.get("fetched_at"):
|
||||
serialized["fetched_at"] = serialized["fetched_at"].strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
rows.append(serialized)
|
||||
return rows, None
|
||||
except Exception as exc:
|
||||
return rows, str(exc)
|
||||
|
||||
|
||||
def fetch_all_price_history(limit: int = 500) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""Récupère toutes les entrées de price_history avec infos produit."""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ph.id,
|
||||
ph.product_id,
|
||||
p.source,
|
||||
p.reference,
|
||||
p.title,
|
||||
ph.price,
|
||||
ph.shipping_cost,
|
||||
ph.stock_status,
|
||||
ph.fetch_method,
|
||||
ph.fetch_status,
|
||||
ph.fetched_at
|
||||
FROM price_history ph
|
||||
LEFT JOIN products p ON p.id = ph.product_id
|
||||
ORDER BY ph.fetched_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
fetched = cur.fetchall()
|
||||
for item in fetched:
|
||||
serialized = {key: _serialize_decimal(value) for key, value in item.items()}
|
||||
if serialized.get("fetched_at"):
|
||||
serialized["fetched_at"] = serialized["fetched_at"].strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
rows.append(serialized)
|
||||
return rows, None
|
||||
except Exception as exc:
|
||||
return rows, str(exc)
|
||||
|
||||
|
||||
def fetch_products_list(limit: int = 200) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
@@ -260,6 +335,68 @@ TEMPLATE = """
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Historique complet des scraps</h2>
|
||||
<div class="browser-panel">
|
||||
<div class="browser-controls">
|
||||
<button id="load-history">Charger l'historique du produit sélectionné</button>
|
||||
<span class="muted" id="history-message"></span>
|
||||
</div>
|
||||
<div class="history-table-container" style="max-height: 400px; overflow-y: auto; margin-top: 12px;">
|
||||
<table id="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Prix</th>
|
||||
<th>Frais port</th>
|
||||
<th>Stock</th>
|
||||
<th>Méthode</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-body">
|
||||
<tr><td colspan="6" class="muted">Sélectionnez un produit puis cliquez sur "Charger l'historique"</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Parcourir la table price_history</h2>
|
||||
<div class="browser-panel">
|
||||
<div class="browser-controls">
|
||||
<button id="load-price-history">Charger price_history</button>
|
||||
<button id="ph-prev" disabled>Précédent</button>
|
||||
<button id="ph-next" disabled>Suivant</button>
|
||||
<strong class="browser-indicator" id="ph-indicator">0 / 0</strong>
|
||||
<span class="muted" id="ph-message"></span>
|
||||
</div>
|
||||
<dl class="browser-display" id="ph-details">
|
||||
<dt>ID</dt>
|
||||
<dd id="ph-id">-</dd>
|
||||
<dt>Product ID</dt>
|
||||
<dd id="ph-product-id">-</dd>
|
||||
<dt>Store</dt>
|
||||
<dd id="ph-source">-</dd>
|
||||
<dt>Référence</dt>
|
||||
<dd id="ph-reference">-</dd>
|
||||
<dt>Titre produit</dt>
|
||||
<dd id="ph-title">-</dd>
|
||||
<dt>Prix</dt>
|
||||
<dd id="ph-price">-</dd>
|
||||
<dt>Frais de port</dt>
|
||||
<dd id="ph-shipping">-</dd>
|
||||
<dt>Stock</dt>
|
||||
<dd id="ph-stock">-</dd>
|
||||
<dt>Méthode</dt>
|
||||
<dd id="ph-method">-</dd>
|
||||
<dt>Statut</dt>
|
||||
<dd id="ph-status">-</dd>
|
||||
<dt>Date scraping</dt>
|
||||
<dd id="ph-fetched-at">-</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -348,6 +485,177 @@ TEMPLATE = """
|
||||
renderProduct();
|
||||
}
|
||||
});
|
||||
|
||||
// Historique des scraps
|
||||
const loadHistoryBtn = document.getElementById("load-history");
|
||||
const historyMessage = document.getElementById("history-message");
|
||||
const historyBody = document.getElementById("history-body");
|
||||
|
||||
const setHistoryStatus = (text) => {
|
||||
historyMessage.textContent = text || "";
|
||||
};
|
||||
|
||||
const formatStock = (status) => {
|
||||
const stockMap = {
|
||||
"in_stock": "✓ En stock",
|
||||
"out_of_stock": "✗ Rupture",
|
||||
"limited": "⚠ Limité",
|
||||
"preorder": "⏳ Précommande",
|
||||
"unknown": "? Inconnu"
|
||||
};
|
||||
return stockMap[status] || status || "-";
|
||||
};
|
||||
|
||||
const formatMethod = (method) => {
|
||||
return method === "playwright" ? "🎭 Playwright" : "📡 HTTP";
|
||||
};
|
||||
|
||||
const formatStatus = (status) => {
|
||||
const statusMap = {
|
||||
"success": "✓ Succès",
|
||||
"partial": "⚠ Partiel",
|
||||
"failed": "✗ Échec"
|
||||
};
|
||||
return statusMap[status] || status || "-";
|
||||
};
|
||||
|
||||
const renderHistory = (history) => {
|
||||
if (!history.length) {
|
||||
historyBody.innerHTML = '<tr><td colspan="6" class="muted">Aucun historique disponible pour ce produit.</td></tr>';
|
||||
return;
|
||||
}
|
||||
historyBody.innerHTML = history.map(entry => `
|
||||
<tr>
|
||||
<td>${entry.fetched_at || "-"}</td>
|
||||
<td>${entry.price !== null ? entry.price + " €" : "-"}</td>
|
||||
<td>${entry.shipping_cost !== null ? entry.shipping_cost + " €" : "-"}</td>
|
||||
<td>${formatStock(entry.stock_status)}</td>
|
||||
<td>${formatMethod(entry.fetch_method)}</td>
|
||||
<td>${formatStatus(entry.fetch_status)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!products.length) {
|
||||
setHistoryStatus("Chargez d'abord les produits.");
|
||||
return;
|
||||
}
|
||||
const current = products[cursor];
|
||||
if (!current || !current.id) {
|
||||
setHistoryStatus("Aucun produit sélectionné.");
|
||||
return;
|
||||
}
|
||||
setHistoryStatus(`Chargement de l'historique pour le produit #${current.id}…`);
|
||||
try {
|
||||
const response = await fetch(`/product/${current.id}/history.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Réponse invalide");
|
||||
}
|
||||
setHistoryStatus(`${data.length} entrée(s) pour "${(current.title || "Sans titre").slice(0, 30)}…"`);
|
||||
renderHistory(data);
|
||||
} catch (err) {
|
||||
setHistoryStatus(`Erreur: ${err.message}`);
|
||||
historyBody.innerHTML = '<tr><td colspan="6" class="muted">Erreur lors du chargement.</td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
loadHistoryBtn.addEventListener("click", fetchHistory);
|
||||
|
||||
// Parcourir price_history
|
||||
const loadPhBtn = document.getElementById("load-price-history");
|
||||
const phPrevBtn = document.getElementById("ph-prev");
|
||||
const phNextBtn = document.getElementById("ph-next");
|
||||
const phIndicator = document.getElementById("ph-indicator");
|
||||
const phMessage = document.getElementById("ph-message");
|
||||
let priceHistoryData = [];
|
||||
let phCursor = 0;
|
||||
|
||||
const setPhStatus = (text) => {
|
||||
phMessage.textContent = text || "";
|
||||
};
|
||||
|
||||
const renderPriceHistory = () => {
|
||||
const els = {
|
||||
id: document.getElementById("ph-id"),
|
||||
productId: document.getElementById("ph-product-id"),
|
||||
source: document.getElementById("ph-source"),
|
||||
reference: document.getElementById("ph-reference"),
|
||||
title: document.getElementById("ph-title"),
|
||||
price: document.getElementById("ph-price"),
|
||||
shipping: document.getElementById("ph-shipping"),
|
||||
stock: document.getElementById("ph-stock"),
|
||||
method: document.getElementById("ph-method"),
|
||||
status: document.getElementById("ph-status"),
|
||||
fetchedAt: document.getElementById("ph-fetched-at"),
|
||||
};
|
||||
|
||||
if (!priceHistoryData.length) {
|
||||
phIndicator.textContent = "0 / 0";
|
||||
Object.values(els).forEach((el) => (el.textContent = "-"));
|
||||
phPrevBtn.disabled = true;
|
||||
phNextBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = priceHistoryData[phCursor];
|
||||
phIndicator.textContent = `${phCursor + 1} / ${priceHistoryData.length}`;
|
||||
|
||||
els.id.textContent = current.id || "-";
|
||||
els.productId.textContent = current.product_id || "-";
|
||||
els.source.textContent = current.source || "-";
|
||||
els.reference.textContent = current.reference || "-";
|
||||
els.title.textContent = current.title ? (current.title.length > 60 ? current.title.slice(0, 60) + "…" : current.title) : "-";
|
||||
els.price.textContent = current.price !== null ? current.price + " €" : "-";
|
||||
els.shipping.textContent = current.shipping_cost !== null ? current.shipping_cost + " €" : "-";
|
||||
els.stock.textContent = formatStock(current.stock_status);
|
||||
els.method.textContent = formatMethod(current.fetch_method);
|
||||
els.status.textContent = formatStatus(current.fetch_status);
|
||||
els.fetchedAt.textContent = current.fetched_at || "-";
|
||||
|
||||
phPrevBtn.disabled = phCursor === 0;
|
||||
phNextBtn.disabled = phCursor >= priceHistoryData.length - 1;
|
||||
};
|
||||
|
||||
const fetchPriceHistory = async () => {
|
||||
setPhStatus("Chargement…");
|
||||
try {
|
||||
const response = await fetch("/price_history.json");
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Réponse invalide");
|
||||
}
|
||||
priceHistoryData = data;
|
||||
phCursor = 0;
|
||||
setPhStatus(`Chargé ${priceHistoryData.length} entrée(s)`);
|
||||
renderPriceHistory();
|
||||
} catch (err) {
|
||||
setPhStatus(`Erreur: ${err.message}`);
|
||||
priceHistoryData = [];
|
||||
renderPriceHistory();
|
||||
}
|
||||
};
|
||||
|
||||
loadPhBtn.addEventListener("click", fetchPriceHistory);
|
||||
phPrevBtn.addEventListener("click", () => {
|
||||
if (phCursor > 0) {
|
||||
phCursor -= 1;
|
||||
renderPriceHistory();
|
||||
}
|
||||
});
|
||||
phNextBtn.addEventListener("click", () => {
|
||||
if (phCursor + 1 < priceHistoryData.length) {
|
||||
phCursor += 1;
|
||||
renderPriceHistory();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -377,5 +685,21 @@ def products_json():
|
||||
return jsonify(products)
|
||||
|
||||
|
||||
@app.route("/product/<int:product_id>/history.json")
|
||||
def product_history_json(product_id: int):
|
||||
history, error = fetch_product_history(product_id)
|
||||
if error:
|
||||
return jsonify({"error": error}), 500
|
||||
return jsonify(history)
|
||||
|
||||
|
||||
@app.route("/price_history.json")
|
||||
def all_price_history_json():
|
||||
history, error = fetch_all_price_history()
|
||||
if error:
|
||||
return jsonify({"error": error}), 500
|
||||
return jsonify(history)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=80)
|
||||
|
||||
@@ -33,6 +33,19 @@ services:
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
worker:
|
||||
build: .
|
||||
command: python -m pricewatch.app.cli.main worker
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PW_DB_HOST: postgres
|
||||
PW_REDIS_HOST: redis
|
||||
TZ: Europe/Paris
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
frontend:
|
||||
build: ./webui
|
||||
@@ -75,6 +88,23 @@ services:
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
ports:
|
||||
- "8072:80"
|
||||
environment:
|
||||
TZ: Europe/Paris
|
||||
PGADMIN_DEFAULT_EMAIL: admin@pricewatch.dev
|
||||
PGADMIN_DEFAULT_PASSWORD: pricewatch
|
||||
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
|
||||
volumes:
|
||||
- pricewatch_pgadmin:/var/lib/pgadmin
|
||||
- ./pgadmin-servers.json:/pgadmin4/servers.json:ro
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pricewatch_pgdata:
|
||||
pricewatch_redisdata:
|
||||
pricewatch_pgadmin:
|
||||
|
||||
14
pgadmin-servers.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"Servers": {
|
||||
"1": {
|
||||
"Name": "PriceWatch PostgreSQL",
|
||||
"Group": "Servers",
|
||||
"Host": "postgres",
|
||||
"Port": 5432,
|
||||
"MaintenanceDB": "pricewatch",
|
||||
"Username": "pricewatch",
|
||||
"PassFile": "/pgadmin4/pgpass",
|
||||
"SSLMode": "prefer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,8 @@ Guide de migration JSON -> DB: `MIGRATION_GUIDE.md`
|
||||
|
||||
L'API est protegee par un token simple.
|
||||
|
||||
Note: l endpoint `/products` expose des champs Amazon explicites (asin, note, badge Choix d Amazon, stock_text/in_stock, model_number/model_name, main_image/gallery_images). Les reductions ne sont plus calculees cote API.
|
||||
|
||||
```bash
|
||||
export PW_API_TOKEN=change_me
|
||||
docker compose up -d api
|
||||
@@ -204,8 +206,54 @@ docker compose up -d api
|
||||
Exemples:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" http://localhost:8000/products
|
||||
curl http://localhost:8000/health
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" http://localhost:8001/products
|
||||
curl http://localhost:8001/health
|
||||
```
|
||||
|
||||
Filtres (exemples rapides):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" \\
|
||||
"http://localhost:8001/products?price_min=100&stock_status=in_stock"
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" \\
|
||||
"http://localhost:8001/products/1/prices?fetch_status=success&fetched_after=2026-01-14T00:00:00"
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" \\
|
||||
"http://localhost:8001/logs?fetch_status=failed&fetched_before=2026-01-15T00:00:00"
|
||||
```
|
||||
|
||||
Exports (CSV/JSON):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" \\
|
||||
"http://localhost:8001/products/export?format=csv"
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" \\
|
||||
"http://localhost:8001/logs/export?format=json"
|
||||
```
|
||||
|
||||
CRUD (examples rapides):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" -X POST http://localhost:8001/products \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"source":"amazon","reference":"REF1","url":"https://example.com"}'
|
||||
```
|
||||
|
||||
Webhooks (exemples rapides):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" -X POST http://localhost:8001/webhooks \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"event":"price_changed","url":"https://example.com/webhook","enabled":true}'
|
||||
curl -H "Authorization: Bearer $PW_API_TOKEN" -X POST http://localhost:8001/webhooks/1/test
|
||||
```
|
||||
|
||||
## Web UI (Phase 4)
|
||||
|
||||
Interface Vue 3 dense avec themes Gruvbox/Monokai, header fixe, sidebar filtres, et split compare.
|
||||
|
||||
```bash
|
||||
docker compose up -d frontend
|
||||
# Acces: http://localhost:3000
|
||||
```
|
||||
|
||||
## Configuration (scrap_url.yaml)
|
||||
|
||||
@@ -22,6 +22,7 @@ pricewatch/app/scraping/pipeline.py
|
||||
pricewatch/app/scraping/pw_fetch.py
|
||||
pricewatch/app/stores/__init__.py
|
||||
pricewatch/app/stores/base.py
|
||||
pricewatch/app/stores/price_parser.py
|
||||
pricewatch/app/stores/amazon/__init__.py
|
||||
pricewatch/app/stores/amazon/store.py
|
||||
pricewatch/app/stores/cdiscount/__init__.py
|
||||
|
||||
@@ -22,6 +22,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from pricewatch.app.api.schemas import (
|
||||
BackendLogEntry,
|
||||
ClassificationOptionsOut,
|
||||
ClassificationRuleCreate,
|
||||
ClassificationRuleOut,
|
||||
ClassificationRuleUpdate,
|
||||
EnqueueRequest,
|
||||
EnqueueResponse,
|
||||
HealthStatus,
|
||||
@@ -52,7 +56,8 @@ from pricewatch.app.core.config import get_config
|
||||
from pricewatch.app.core.logging import get_logger
|
||||
from pricewatch.app.core.schema import ProductSnapshot
|
||||
from pricewatch.app.db.connection import check_db_connection, get_session
|
||||
from pricewatch.app.db.models import PriceHistory, Product, ScrapingLog, Webhook
|
||||
from pricewatch.app.db.models import ClassificationRule, PriceHistory, Product, ScrapingLog, Webhook
|
||||
from pricewatch.app.db.repository import ProductRepository
|
||||
from pricewatch.app.scraping.pipeline import ScrapingPipeline
|
||||
from pricewatch.app.tasks.scrape import scrape_product
|
||||
from pricewatch.app.tasks.scheduler import RedisUnavailableError, check_redis_connection, ScrapingScheduler
|
||||
@@ -188,6 +193,7 @@ def create_product(
|
||||
url=payload.url,
|
||||
title=payload.title,
|
||||
category=payload.category,
|
||||
type=payload.type,
|
||||
description=payload.description,
|
||||
currency=payload.currency,
|
||||
msrp=payload.msrp,
|
||||
@@ -241,6 +247,129 @@ def update_product(
|
||||
return _product_to_out(session, product)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/classification/rules",
|
||||
response_model=list[ClassificationRuleOut],
|
||||
dependencies=[Depends(require_token)],
|
||||
)
|
||||
def list_classification_rules(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> list[ClassificationRuleOut]:
|
||||
"""Liste les regles de classification."""
|
||||
rules = (
|
||||
session.query(ClassificationRule)
|
||||
.order_by(ClassificationRule.sort_order, ClassificationRule.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
ClassificationRuleOut(
|
||||
id=rule.id,
|
||||
category=rule.category,
|
||||
type=rule.type,
|
||||
keywords=rule.keywords or [],
|
||||
sort_order=rule.sort_order,
|
||||
is_active=rule.is_active,
|
||||
)
|
||||
for rule in rules
|
||||
]
|
||||
|
||||
|
||||
@app.post(
|
||||
"/classification/rules",
|
||||
response_model=ClassificationRuleOut,
|
||||
dependencies=[Depends(require_token)],
|
||||
)
|
||||
def create_classification_rule(
|
||||
payload: ClassificationRuleCreate,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ClassificationRuleOut:
|
||||
"""Cree une regle de classification."""
|
||||
rule = ClassificationRule(
|
||||
category=payload.category,
|
||||
type=payload.type,
|
||||
keywords=payload.keywords,
|
||||
sort_order=payload.sort_order or 0,
|
||||
is_active=True if payload.is_active is None else payload.is_active,
|
||||
)
|
||||
session.add(rule)
|
||||
session.commit()
|
||||
session.refresh(rule)
|
||||
return ClassificationRuleOut(
|
||||
id=rule.id,
|
||||
category=rule.category,
|
||||
type=rule.type,
|
||||
keywords=rule.keywords or [],
|
||||
sort_order=rule.sort_order,
|
||||
is_active=rule.is_active,
|
||||
)
|
||||
|
||||
|
||||
@app.patch(
|
||||
"/classification/rules/{rule_id}",
|
||||
response_model=ClassificationRuleOut,
|
||||
dependencies=[Depends(require_token)],
|
||||
)
|
||||
def update_classification_rule(
|
||||
rule_id: int,
|
||||
payload: ClassificationRuleUpdate,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ClassificationRuleOut:
|
||||
"""Met a jour une regle de classification."""
|
||||
rule = session.query(ClassificationRule).filter(ClassificationRule.id == rule_id).one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Regle non trouvee")
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
for key, value in updates.items():
|
||||
setattr(rule, key, value)
|
||||
session.commit()
|
||||
session.refresh(rule)
|
||||
return ClassificationRuleOut(
|
||||
id=rule.id,
|
||||
category=rule.category,
|
||||
type=rule.type,
|
||||
keywords=rule.keywords or [],
|
||||
sort_order=rule.sort_order,
|
||||
is_active=rule.is_active,
|
||||
)
|
||||
|
||||
|
||||
@app.delete(
|
||||
"/classification/rules/{rule_id}",
|
||||
dependencies=[Depends(require_token)],
|
||||
)
|
||||
def delete_classification_rule(
|
||||
rule_id: int,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
"""Supprime une regle de classification."""
|
||||
rule = session.query(ClassificationRule).filter(ClassificationRule.id == rule_id).one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Regle non trouvee")
|
||||
session.delete(rule)
|
||||
session.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/classification/options",
|
||||
response_model=ClassificationOptionsOut,
|
||||
dependencies=[Depends(require_token)],
|
||||
)
|
||||
def get_classification_options(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ClassificationOptionsOut:
|
||||
"""Expose la liste des categories et types issus des regles actives."""
|
||||
rules = (
|
||||
session.query(ClassificationRule)
|
||||
.filter(ClassificationRule.is_active == True)
|
||||
.order_by(ClassificationRule.sort_order, ClassificationRule.id)
|
||||
.all()
|
||||
)
|
||||
categories = sorted({rule.category for rule in rules if rule.category})
|
||||
types = sorted({rule.type for rule in rules if rule.type})
|
||||
return ClassificationOptionsOut(categories=categories, types=types)
|
||||
|
||||
|
||||
@app.delete("/products/{product_id}", dependencies=[Depends(require_token)])
|
||||
def delete_product(
|
||||
product_id: int,
|
||||
@@ -703,6 +832,13 @@ def preview_scrape(payload: ScrapePreviewRequest) -> ScrapePreviewResponse:
|
||||
if snapshot is None:
|
||||
_add_backend_log("ERROR", f"Preview scraping KO: {payload.url}")
|
||||
return ScrapePreviewResponse(success=False, snapshot=None, error=result.get("error"))
|
||||
config = get_config()
|
||||
if config.enable_db:
|
||||
try:
|
||||
with get_session(config) as session:
|
||||
ProductRepository(session).apply_classification(snapshot)
|
||||
except Exception as exc:
|
||||
snapshot.add_note(f"Classification ignoree: {exc}")
|
||||
return ScrapePreviewResponse(
|
||||
success=bool(result.get("success")),
|
||||
snapshot=snapshot.model_dump(mode="json"),
|
||||
@@ -719,7 +855,9 @@ def commit_scrape(payload: ScrapeCommitRequest) -> ScrapeCommitResponse:
|
||||
_add_backend_log("ERROR", "Commit scraping KO: snapshot invalide")
|
||||
raise HTTPException(status_code=400, detail="Snapshot invalide") from exc
|
||||
|
||||
product_id = ScrapingPipeline(config=get_config()).process_snapshot(snapshot, save_to_db=True)
|
||||
product_id = ScrapingPipeline(config=get_config()).process_snapshot(
|
||||
snapshot, save_to_db=True, apply_classification=False
|
||||
)
|
||||
_add_backend_log("INFO", f"Commit scraping OK: product_id={product_id}")
|
||||
return ScrapeCommitResponse(success=True, product_id=product_id)
|
||||
|
||||
@@ -808,12 +946,9 @@ def _product_to_out(session: Session, product: Product) -> ProductOut:
|
||||
)
|
||||
images = [image.image_url for image in product.images]
|
||||
specs = {spec.spec_key: spec.spec_value for spec in product.specs}
|
||||
discount_amount = None
|
||||
discount_percent = None
|
||||
if latest and latest.price is not None and product.msrp:
|
||||
discount_amount = float(product.msrp) - float(latest.price)
|
||||
if product.msrp > 0:
|
||||
discount_percent = (discount_amount / float(product.msrp)) * 100
|
||||
main_image = images[0] if images else None
|
||||
gallery_images = images[1:] if len(images) > 1 else []
|
||||
asin = product.reference if product.source == "amazon" else None
|
||||
history_rows = (
|
||||
session.query(PriceHistory)
|
||||
.filter(PriceHistory.product_id == product.id, PriceHistory.price != None)
|
||||
@@ -830,12 +965,23 @@ def _product_to_out(session: Session, product: Product) -> ProductOut:
|
||||
id=product.id,
|
||||
source=product.source,
|
||||
reference=product.reference,
|
||||
asin=asin,
|
||||
url=product.url,
|
||||
title=product.title,
|
||||
category=product.category,
|
||||
type=product.type,
|
||||
description=product.description,
|
||||
currency=product.currency,
|
||||
msrp=float(product.msrp) if product.msrp is not None else None,
|
||||
rating_value=float(product.rating_value) if product.rating_value is not None else None,
|
||||
rating_count=product.rating_count,
|
||||
amazon_choice=product.amazon_choice,
|
||||
amazon_choice_label=product.amazon_choice_label,
|
||||
discount_text=product.discount_text,
|
||||
stock_text=product.stock_text,
|
||||
in_stock=product.in_stock,
|
||||
model_number=product.model_number,
|
||||
model_name=product.model_name,
|
||||
first_seen_at=product.first_seen_at,
|
||||
last_updated_at=product.last_updated_at,
|
||||
latest_price=float(latest.price) if latest and latest.price is not None else None,
|
||||
@@ -845,9 +991,11 @@ def _product_to_out(session: Session, product: Product) -> ProductOut:
|
||||
latest_stock_status=latest.stock_status if latest else None,
|
||||
latest_fetched_at=latest.fetched_at if latest else None,
|
||||
images=images,
|
||||
main_image=main_image,
|
||||
gallery_images=gallery_images,
|
||||
specs=specs,
|
||||
discount_amount=discount_amount,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=None,
|
||||
discount_percent=None,
|
||||
history=history_points,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,12 +22,23 @@ class ProductOut(BaseModel):
|
||||
id: int
|
||||
source: str
|
||||
reference: str
|
||||
asin: Optional[str] = None
|
||||
url: str
|
||||
title: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
msrp: Optional[float] = None
|
||||
rating_value: Optional[float] = None
|
||||
rating_count: Optional[int] = None
|
||||
amazon_choice: Optional[bool] = None
|
||||
amazon_choice_label: Optional[str] = None
|
||||
discount_text: Optional[str] = None
|
||||
stock_text: Optional[str] = None
|
||||
in_stock: Optional[bool] = None
|
||||
model_number: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
first_seen_at: datetime
|
||||
last_updated_at: datetime
|
||||
latest_price: Optional[float] = None
|
||||
@@ -35,6 +46,8 @@ class ProductOut(BaseModel):
|
||||
latest_stock_status: Optional[str] = None
|
||||
latest_fetched_at: Optional[datetime] = None
|
||||
images: list[str] = []
|
||||
main_image: Optional[str] = None
|
||||
gallery_images: list[str] = []
|
||||
specs: dict[str, str] = {}
|
||||
discount_amount: Optional[float] = None
|
||||
discount_percent: Optional[float] = None
|
||||
@@ -47,6 +60,7 @@ class ProductCreate(BaseModel):
|
||||
url: str
|
||||
title: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
msrp: Optional[float] = None
|
||||
@@ -56,6 +70,7 @@ class ProductUpdate(BaseModel):
|
||||
url: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
msrp: Optional[float] = None
|
||||
@@ -208,6 +223,36 @@ class VersionResponse(BaseModel):
|
||||
api_version: str
|
||||
|
||||
|
||||
class ClassificationRuleOut(BaseModel):
|
||||
id: int
|
||||
category: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ClassificationRuleCreate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
sort_order: Optional[int] = 0
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
|
||||
class ClassificationRuleUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
keywords: Optional[list[str]] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ClassificationOptionsOut(BaseModel):
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
types: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BackendLogEntry(BaseModel):
|
||||
time: datetime
|
||||
level: str
|
||||
|
||||
@@ -93,13 +93,52 @@ class ProductSnapshot(BaseModel):
|
||||
reference: Optional[str] = Field(
|
||||
default=None, description="Référence produit (ASIN, SKU, etc.)"
|
||||
)
|
||||
asin: Optional[str] = Field(
|
||||
default=None, description="ASIN Amazon si disponible"
|
||||
)
|
||||
category: Optional[str] = Field(default=None, description="Catégorie du produit")
|
||||
type: Optional[str] = Field(default=None, description="Type du produit")
|
||||
description: Optional[str] = Field(default=None, description="Description produit")
|
||||
|
||||
# Données Amazon explicites (si disponibles)
|
||||
rating_value: Optional[float] = Field(
|
||||
default=None, description="Note moyenne affichée"
|
||||
)
|
||||
rating_count: Optional[int] = Field(
|
||||
default=None, description="Nombre d'évaluations"
|
||||
)
|
||||
amazon_choice: Optional[bool] = Field(
|
||||
default=None, description="Badge Choix d'Amazon présent"
|
||||
)
|
||||
amazon_choice_label: Optional[str] = Field(
|
||||
default=None, description="Libellé du badge Choix d'Amazon"
|
||||
)
|
||||
discount_text: Optional[str] = Field(
|
||||
default=None, description="Texte de réduction affiché"
|
||||
)
|
||||
stock_text: Optional[str] = Field(
|
||||
default=None, description="Texte brut de stock"
|
||||
)
|
||||
in_stock: Optional[bool] = Field(
|
||||
default=None, description="Disponibilité dérivée"
|
||||
)
|
||||
model_number: Optional[str] = Field(
|
||||
default=None, description="Numéro du modèle de l'article"
|
||||
)
|
||||
model_name: Optional[str] = Field(
|
||||
default=None, description="Nom du modèle explicite"
|
||||
)
|
||||
|
||||
# Médias
|
||||
images: list[str] = Field(
|
||||
default_factory=list, description="Liste des URLs d'images du produit"
|
||||
)
|
||||
main_image: Optional[str] = Field(
|
||||
default=None, description="Image principale du produit"
|
||||
)
|
||||
gallery_images: list[str] = Field(
|
||||
default_factory=list, description="Images de galerie dédoublonnées"
|
||||
)
|
||||
|
||||
# Caractéristiques techniques
|
||||
specs: dict[str, str] = Field(
|
||||
@@ -134,6 +173,12 @@ class ProductSnapshot(BaseModel):
|
||||
"""Filtre les URLs d'images vides."""
|
||||
return [url.strip() for url in v if url and url.strip()]
|
||||
|
||||
@field_validator("gallery_images")
|
||||
@classmethod
|
||||
def validate_gallery_images(cls, v: list[str]) -> list[str]:
|
||||
"""Filtre les URLs de galerie vides."""
|
||||
return [url.strip() for url in v if url and url.strip()]
|
||||
|
||||
model_config = ConfigDict(
|
||||
use_enum_values=True,
|
||||
json_schema_extra={
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Ajout champs Amazon produit
|
||||
|
||||
Revision ID: 0014e51c4927
|
||||
Revises: 20260115_02_product_details
|
||||
Create Date: 2026-01-17 19:23:01.866891
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# Revision identifiers, used by Alembic.
|
||||
revision = '0014e51c4927'
|
||||
down_revision = '20260115_02_product_details'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('price_history', 'price',
|
||||
existing_type=sa.NUMERIC(precision=10, scale=2),
|
||||
comment='Product price',
|
||||
existing_nullable=True)
|
||||
op.alter_column('price_history', 'shipping_cost',
|
||||
existing_type=sa.NUMERIC(precision=10, scale=2),
|
||||
comment='Shipping cost',
|
||||
existing_nullable=True)
|
||||
op.alter_column('price_history', 'stock_status',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment='Stock status (in_stock, out_of_stock, unknown)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('price_history', 'fetch_method',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment='Fetch method (http, playwright)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('price_history', 'fetch_status',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment='Fetch status (success, partial, failed)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('price_history', 'fetched_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment='Scraping timestamp',
|
||||
existing_nullable=False)
|
||||
op.alter_column('product_images', 'image_url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Image URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('product_images', 'position',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='Image position (0=main)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('product_specs', 'spec_key',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
comment="Specification key (e.g., 'Brand', 'Color')",
|
||||
existing_nullable=False)
|
||||
op.alter_column('product_specs', 'spec_value',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Specification value',
|
||||
existing_nullable=False)
|
||||
op.add_column('products', sa.Column('rating_value', sa.Numeric(precision=3, scale=2), nullable=True, comment='Note moyenne'))
|
||||
op.add_column('products', sa.Column('rating_count', sa.Integer(), nullable=True, comment="Nombre d'evaluations"))
|
||||
op.add_column('products', sa.Column('amazon_choice', sa.Boolean(), nullable=True, comment="Badge Choix d'Amazon"))
|
||||
op.add_column('products', sa.Column('amazon_choice_label', sa.Text(), nullable=True, comment="Libelle Choix d'Amazon"))
|
||||
op.add_column('products', sa.Column('discount_text', sa.Text(), nullable=True, comment='Texte de reduction affiche'))
|
||||
op.add_column('products', sa.Column('stock_text', sa.Text(), nullable=True, comment='Texte brut du stock'))
|
||||
op.add_column('products', sa.Column('in_stock', sa.Boolean(), nullable=True, comment='Disponibilite derivee'))
|
||||
op.add_column('products', sa.Column('model_number', sa.Text(), nullable=True, comment='Numero du modele'))
|
||||
op.add_column('products', sa.Column('model_name', sa.Text(), nullable=True, comment='Nom du modele'))
|
||||
op.alter_column('products', 'source',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
comment='Store ID (amazon, cdiscount, etc.)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'reference',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
comment='Product reference (ASIN, SKU, etc.)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Canonical product URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'title',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Product title',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'category',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Product category (breadcrumb)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'description',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Product description',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'currency',
|
||||
existing_type=sa.VARCHAR(length=3),
|
||||
comment='Currency code (EUR, USD, GBP)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'msrp',
|
||||
existing_type=sa.NUMERIC(precision=10, scale=2),
|
||||
comment='Recommended price',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'first_seen_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment='First scraping timestamp',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'last_updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment='Last metadata update',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Scraped URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'source',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
comment='Store ID (amazon, cdiscount, etc.)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'reference',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
comment='Product reference (if extracted)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'fetch_method',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment='Fetch method (http, playwright)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'fetch_status',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment='Fetch status (success, partial, failed)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'fetched_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment='Scraping timestamp',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'duration_ms',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='Fetch duration in milliseconds',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'html_size_bytes',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='HTML response size in bytes',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'errors',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
comment='Error messages (list of strings)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'notes',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
comment='Debug notes (list of strings)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('webhooks', 'event',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
comment='Event name',
|
||||
existing_nullable=False)
|
||||
op.alter_column('webhooks', 'url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment='Webhook URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('webhooks', 'secret',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
comment='Secret optionnel',
|
||||
existing_nullable=True)
|
||||
op.alter_column('webhooks', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment='Creation timestamp',
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('webhooks', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment=None,
|
||||
existing_comment='Creation timestamp',
|
||||
existing_nullable=False)
|
||||
op.alter_column('webhooks', 'secret',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
comment=None,
|
||||
existing_comment='Secret optionnel',
|
||||
existing_nullable=True)
|
||||
op.alter_column('webhooks', 'url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Webhook URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('webhooks', 'event',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
comment=None,
|
||||
existing_comment='Event name',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'notes',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
comment=None,
|
||||
existing_comment='Debug notes (list of strings)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'errors',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
comment=None,
|
||||
existing_comment='Error messages (list of strings)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'html_size_bytes',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='HTML response size in bytes',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'duration_ms',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='Fetch duration in milliseconds',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'fetched_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment=None,
|
||||
existing_comment='Scraping timestamp',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'fetch_status',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment=None,
|
||||
existing_comment='Fetch status (success, partial, failed)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'fetch_method',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment=None,
|
||||
existing_comment='Fetch method (http, playwright)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'reference',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
comment=None,
|
||||
existing_comment='Product reference (if extracted)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('scraping_logs', 'source',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
comment=None,
|
||||
existing_comment='Store ID (amazon, cdiscount, etc.)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('scraping_logs', 'url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Scraped URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'last_updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment=None,
|
||||
existing_comment='Last metadata update',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'first_seen_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment=None,
|
||||
existing_comment='First scraping timestamp',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'msrp',
|
||||
existing_type=sa.NUMERIC(precision=10, scale=2),
|
||||
comment=None,
|
||||
existing_comment='Recommended price',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'currency',
|
||||
existing_type=sa.VARCHAR(length=3),
|
||||
comment=None,
|
||||
existing_comment='Currency code (EUR, USD, GBP)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'description',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Product description',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'category',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Product category (breadcrumb)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'title',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Product title',
|
||||
existing_nullable=True)
|
||||
op.alter_column('products', 'url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Canonical product URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'reference',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
comment=None,
|
||||
existing_comment='Product reference (ASIN, SKU, etc.)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('products', 'source',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
comment=None,
|
||||
existing_comment='Store ID (amazon, cdiscount, etc.)',
|
||||
existing_nullable=False)
|
||||
op.drop_column('products', 'model_name')
|
||||
op.drop_column('products', 'model_number')
|
||||
op.drop_column('products', 'in_stock')
|
||||
op.drop_column('products', 'stock_text')
|
||||
op.drop_column('products', 'discount_text')
|
||||
op.drop_column('products', 'amazon_choice_label')
|
||||
op.drop_column('products', 'amazon_choice')
|
||||
op.drop_column('products', 'rating_count')
|
||||
op.drop_column('products', 'rating_value')
|
||||
op.alter_column('product_specs', 'spec_value',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Specification value',
|
||||
existing_nullable=False)
|
||||
op.alter_column('product_specs', 'spec_key',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
comment=None,
|
||||
existing_comment="Specification key (e.g., 'Brand', 'Color')",
|
||||
existing_nullable=False)
|
||||
op.alter_column('product_images', 'position',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='Image position (0=main)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('product_images', 'image_url',
|
||||
existing_type=sa.TEXT(),
|
||||
comment=None,
|
||||
existing_comment='Image URL',
|
||||
existing_nullable=False)
|
||||
op.alter_column('price_history', 'fetched_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
comment=None,
|
||||
existing_comment='Scraping timestamp',
|
||||
existing_nullable=False)
|
||||
op.alter_column('price_history', 'fetch_status',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment=None,
|
||||
existing_comment='Fetch status (success, partial, failed)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('price_history', 'fetch_method',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment=None,
|
||||
existing_comment='Fetch method (http, playwright)',
|
||||
existing_nullable=False)
|
||||
op.alter_column('price_history', 'stock_status',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
comment=None,
|
||||
existing_comment='Stock status (in_stock, out_of_stock, unknown)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('price_history', 'shipping_cost',
|
||||
existing_type=sa.NUMERIC(precision=10, scale=2),
|
||||
comment=None,
|
||||
existing_comment='Shipping cost',
|
||||
existing_nullable=True)
|
||||
op.alter_column('price_history', 'price',
|
||||
existing_type=sa.NUMERIC(precision=10, scale=2),
|
||||
comment=None,
|
||||
existing_comment='Product price',
|
||||
existing_nullable=True)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Ajout champs Amazon produit
|
||||
|
||||
Revision ID: 1467e98fcbea
|
||||
Revises: 3e68b0f0c9e4
|
||||
Create Date: 2026-01-17 20:08:32.991650
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# Revision identifiers, used by Alembic.
|
||||
revision = '1467e98fcbea'
|
||||
down_revision = '3e68b0f0c9e4'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Ajout classification rules et type produit
|
||||
|
||||
Revision ID: 20260117_03_classification_rules
|
||||
Revises: 3e68b0f0c9e4
|
||||
Create Date: 2026-01-17 20:05:00.000000
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# Revision identifiers, used by Alembic.
|
||||
revision = "20260117_03_classification_rules"
|
||||
down_revision = "3e68b0f0c9e4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column("type", sa.Text(), nullable=True, comment="Product type"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"classification_rules",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("category", sa.String(length=80), nullable=True, comment="Categorie cible"),
|
||||
sa.Column("type", sa.String(length=80), nullable=True, comment="Type cible"),
|
||||
sa.Column(
|
||||
"keywords",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
comment="Mots-cles de matching",
|
||||
),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
comment="Creation timestamp",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_classification_rule_order", "classification_rules", ["sort_order"])
|
||||
op.create_index("ix_classification_rule_active", "classification_rules", ["is_active"])
|
||||
|
||||
rules_table = sa.table(
|
||||
"classification_rules",
|
||||
sa.column("category", sa.String),
|
||||
sa.column("type", sa.String),
|
||||
sa.column("keywords", postgresql.JSONB),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
sa.column("is_active", sa.Boolean),
|
||||
sa.column("created_at", sa.TIMESTAMP),
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
op.bulk_insert(
|
||||
rules_table,
|
||||
[
|
||||
{
|
||||
"category": "Informatique",
|
||||
"type": "Ecran",
|
||||
"keywords": ["ecran", "moniteur", "display"],
|
||||
"sort_order": 0,
|
||||
"is_active": True,
|
||||
"created_at": now,
|
||||
},
|
||||
{
|
||||
"category": "Informatique",
|
||||
"type": "PC portable",
|
||||
"keywords": ["pc portable", "ordinateur portable", "laptop", "notebook"],
|
||||
"sort_order": 1,
|
||||
"is_active": True,
|
||||
"created_at": now,
|
||||
},
|
||||
{
|
||||
"category": "Informatique",
|
||||
"type": "Unite centrale",
|
||||
"keywords": ["unite centrale", "tour", "desktop", "pc fixe"],
|
||||
"sort_order": 2,
|
||||
"is_active": True,
|
||||
"created_at": now,
|
||||
},
|
||||
{
|
||||
"category": "Informatique",
|
||||
"type": "Clavier",
|
||||
"keywords": ["clavier", "keyboard"],
|
||||
"sort_order": 3,
|
||||
"is_active": True,
|
||||
"created_at": now,
|
||||
},
|
||||
{
|
||||
"category": "Informatique",
|
||||
"type": "Souris",
|
||||
"keywords": ["souris", "mouse"],
|
||||
"sort_order": 4,
|
||||
"is_active": True,
|
||||
"created_at": now,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_classification_rule_active", table_name="classification_rules")
|
||||
op.drop_index("ix_classification_rule_order", table_name="classification_rules")
|
||||
op.drop_table("classification_rules")
|
||||
op.drop_column("products", "type")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Ajout champs Amazon produit
|
||||
|
||||
Revision ID: 3e68b0f0c9e4
|
||||
Revises: 0014e51c4927
|
||||
Create Date: 2026-01-17 19:45:03.730218
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# Revision identifiers, used by Alembic.
|
||||
revision = '3e68b0f0c9e4'
|
||||
down_revision = '0014e51c4927'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -84,6 +84,36 @@ class Product(Base):
|
||||
msrp: Mapped[Optional[Decimal]] = mapped_column(
|
||||
Numeric(10, 2), nullable=True, comment="Recommended price"
|
||||
)
|
||||
type: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Product type"
|
||||
)
|
||||
rating_value: Mapped[Optional[Decimal]] = mapped_column(
|
||||
Numeric(3, 2), nullable=True, comment="Note moyenne"
|
||||
)
|
||||
rating_count: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True, comment="Nombre d'evaluations"
|
||||
)
|
||||
amazon_choice: Mapped[Optional[bool]] = mapped_column(
|
||||
Boolean, nullable=True, comment="Badge Choix d'Amazon"
|
||||
)
|
||||
amazon_choice_label: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Libelle Choix d'Amazon"
|
||||
)
|
||||
discount_text: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Texte de reduction affiche"
|
||||
)
|
||||
stock_text: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Texte brut du stock"
|
||||
)
|
||||
in_stock: Mapped[Optional[bool]] = mapped_column(
|
||||
Boolean, nullable=True, comment="Disponibilite derivee"
|
||||
)
|
||||
model_number: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Numero du modele"
|
||||
)
|
||||
model_name: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Nom du modele"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
@@ -331,6 +361,45 @@ class ScrapingLog(Base):
|
||||
return f"<ScrapingLog(id={self.id}, url={self.url}, status={self.fetch_status}, fetched_at={self.fetched_at})>"
|
||||
|
||||
|
||||
class ClassificationRule(Base):
|
||||
"""
|
||||
Regles de classification categorie/type basees sur des mots-cles.
|
||||
"""
|
||||
|
||||
__tablename__ = "classification_rules"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
category: Mapped[Optional[str]] = mapped_column(
|
||||
String(80), nullable=True, comment="Categorie cible"
|
||||
)
|
||||
type: Mapped[Optional[str]] = mapped_column(
|
||||
String(80), nullable=True, comment="Type cible"
|
||||
)
|
||||
keywords: Mapped[list[str]] = mapped_column(
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=False,
|
||||
default=list,
|
||||
comment="Mots-cles de matching",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, comment="Ordre de priorite (0=haut)"
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, comment="Regle active"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP, nullable=False, default=utcnow, comment="Creation timestamp"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_classification_rule_order", "sort_order"),
|
||||
Index("ix_classification_rule_active", "is_active"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ClassificationRule(id={self.id}, category={self.category}, type={self.type})>"
|
||||
|
||||
|
||||
class Webhook(Base):
|
||||
"""
|
||||
Webhooks pour notifications externes.
|
||||
|
||||
@@ -13,7 +13,14 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from pricewatch.app.core.logging import get_logger
|
||||
from pricewatch.app.core.schema import ProductSnapshot
|
||||
from pricewatch.app.db.models import PriceHistory, Product, ProductImage, ProductSpec, ScrapingLog
|
||||
from pricewatch.app.db.models import (
|
||||
ClassificationRule,
|
||||
PriceHistory,
|
||||
Product,
|
||||
ProductImage,
|
||||
ProductSpec,
|
||||
ScrapingLog,
|
||||
)
|
||||
|
||||
logger = get_logger("db.repository")
|
||||
|
||||
@@ -49,12 +56,58 @@ class ProductRepository:
|
||||
product.title = snapshot.title
|
||||
if snapshot.category:
|
||||
product.category = snapshot.category
|
||||
if snapshot.type:
|
||||
product.type = snapshot.type
|
||||
if snapshot.description:
|
||||
product.description = snapshot.description
|
||||
if snapshot.currency:
|
||||
product.currency = snapshot.currency
|
||||
if snapshot.msrp is not None:
|
||||
product.msrp = snapshot.msrp
|
||||
if snapshot.rating_value is not None:
|
||||
product.rating_value = snapshot.rating_value
|
||||
if snapshot.rating_count is not None:
|
||||
product.rating_count = snapshot.rating_count
|
||||
if snapshot.amazon_choice is not None:
|
||||
product.amazon_choice = snapshot.amazon_choice
|
||||
if snapshot.amazon_choice_label:
|
||||
product.amazon_choice_label = snapshot.amazon_choice_label
|
||||
if snapshot.discount_text:
|
||||
product.discount_text = snapshot.discount_text
|
||||
if snapshot.stock_text:
|
||||
product.stock_text = snapshot.stock_text
|
||||
if snapshot.in_stock is not None:
|
||||
product.in_stock = snapshot.in_stock
|
||||
if snapshot.model_number:
|
||||
product.model_number = snapshot.model_number
|
||||
if snapshot.model_name:
|
||||
product.model_name = snapshot.model_name
|
||||
|
||||
def apply_classification(self, snapshot: ProductSnapshot) -> None:
|
||||
"""Applique les regles de classification au snapshot."""
|
||||
if not snapshot.title:
|
||||
return
|
||||
|
||||
rules = (
|
||||
self.session.query(ClassificationRule)
|
||||
.filter(ClassificationRule.is_active == True)
|
||||
.order_by(ClassificationRule.sort_order, ClassificationRule.id)
|
||||
.all()
|
||||
)
|
||||
if not rules:
|
||||
return
|
||||
|
||||
title = snapshot.title.lower()
|
||||
for rule in rules:
|
||||
keywords = rule.keywords or []
|
||||
if isinstance(keywords, str):
|
||||
keywords = [keywords]
|
||||
if any(keyword and keyword.lower() in title for keyword in keywords):
|
||||
if rule.category:
|
||||
snapshot.category = rule.category
|
||||
if rule.type:
|
||||
snapshot.type = rule.type
|
||||
return
|
||||
|
||||
def add_price_history(self, product: Product, snapshot: ProductSnapshot) -> Optional[PriceHistory]:
|
||||
"""Ajoute une entree d'historique de prix si inexistante."""
|
||||
|
||||
@@ -25,7 +25,12 @@ class ScrapingPipeline:
|
||||
def __init__(self, config: Optional[AppConfig] = None) -> None:
|
||||
self.config = config
|
||||
|
||||
def process_snapshot(self, snapshot: ProductSnapshot, save_to_db: bool = True) -> Optional[int]:
|
||||
def process_snapshot(
|
||||
self,
|
||||
snapshot: ProductSnapshot,
|
||||
save_to_db: bool = True,
|
||||
apply_classification: bool = True,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Persiste un snapshot en base si active.
|
||||
|
||||
@@ -39,6 +44,8 @@ class ScrapingPipeline:
|
||||
try:
|
||||
with get_session(app_config) as session:
|
||||
repo = ProductRepository(session)
|
||||
if apply_classification:
|
||||
repo.apply_classification(snapshot)
|
||||
product_id = repo.safe_save_snapshot(snapshot)
|
||||
session.commit()
|
||||
return product_id
|
||||
|
||||
@@ -15,6 +15,13 @@ price:
|
||||
- "#priceblock_dealprice"
|
||||
- ".a-price-range .a-price .a-offscreen"
|
||||
|
||||
# Texte de réduction explicite
|
||||
discount_text:
|
||||
- "#regularprice_savings"
|
||||
- "#dealprice_savings"
|
||||
- "#savingsPercentage"
|
||||
- "span.savingsPercentage"
|
||||
|
||||
# Devise (généralement dans le symbole)
|
||||
currency:
|
||||
- "span.a-price-symbol"
|
||||
@@ -32,6 +39,24 @@ stock_status:
|
||||
- "#availability"
|
||||
- ".a-declarative .a-size-medium"
|
||||
|
||||
# Note moyenne
|
||||
rating_value:
|
||||
- "#acrPopover"
|
||||
- "#averageCustomerReviews .a-icon-alt"
|
||||
- "#averageCustomerReviews span.a-icon-alt"
|
||||
|
||||
# Nombre d'évaluations
|
||||
rating_count:
|
||||
- "#acrCustomerReviewText"
|
||||
- "#acrCustomerReviewLink"
|
||||
|
||||
# Badge Choix d'Amazon
|
||||
amazon_choice:
|
||||
- "#acBadge_feature_div"
|
||||
- "#acBadge_feature_div .ac-badge"
|
||||
- "#acBadge_feature_div .ac-badge-rectangle"
|
||||
- "#acBadge_feature_div .ac-badge-rectangle-icon"
|
||||
|
||||
# Images produit
|
||||
images:
|
||||
- "#landingImage"
|
||||
@@ -44,6 +69,13 @@ category:
|
||||
- "#wayfinding-breadcrumbs_feature_div"
|
||||
- ".a-breadcrumb"
|
||||
|
||||
# Description (détails de l'article)
|
||||
description:
|
||||
- "#detailBullets_feature_div"
|
||||
- "#detailBulletsWrapper_feature_div"
|
||||
- "#productDetails_detailBullets_sections1"
|
||||
- "#feature-bullets"
|
||||
|
||||
# Caractéristiques techniques (table specs)
|
||||
specs_table:
|
||||
- "#productDetails_techSpec_section_1"
|
||||
|
||||
@@ -130,13 +130,19 @@ class AmazonStore(BaseStore):
|
||||
title = self._extract_title(soup, debug_info)
|
||||
price = self._extract_price(soup, debug_info)
|
||||
currency = self._extract_currency(soup, debug_info)
|
||||
stock_status = self._extract_stock(soup, debug_info)
|
||||
images = self._extract_images(soup, debug_info)
|
||||
stock_status, stock_text, in_stock = self._extract_stock_details(soup, debug_info)
|
||||
main_image, gallery_images, images = self._extract_images(soup, debug_info)
|
||||
category = self._extract_category(soup, debug_info)
|
||||
specs = self._extract_specs(soup, debug_info)
|
||||
description = self._extract_description(soup, debug_info)
|
||||
msrp = self._extract_msrp(soup, debug_info)
|
||||
reference = self.extract_reference(url) or self._extract_asin_from_html(soup)
|
||||
rating_value = self._extract_rating_value(soup, debug_info)
|
||||
rating_count = self._extract_rating_count(soup, debug_info)
|
||||
amazon_choice, amazon_choice_label = self._extract_amazon_choice(soup, debug_info)
|
||||
discount_text = self._extract_discount_text(soup, debug_info)
|
||||
model_number, model_name = self._extract_model_details(specs)
|
||||
asin = reference
|
||||
|
||||
# Déterminer le statut final (ne pas écraser FAILED)
|
||||
if debug_info.status != DebugStatus.FAILED:
|
||||
@@ -153,12 +159,24 @@ class AmazonStore(BaseStore):
|
||||
currency=currency or "EUR",
|
||||
shipping_cost=None, # Difficile à extraire
|
||||
stock_status=stock_status,
|
||||
stock_text=stock_text,
|
||||
in_stock=in_stock,
|
||||
reference=reference,
|
||||
asin=asin,
|
||||
category=category,
|
||||
description=description,
|
||||
images=images,
|
||||
main_image=main_image,
|
||||
gallery_images=gallery_images,
|
||||
specs=specs,
|
||||
msrp=msrp,
|
||||
rating_value=rating_value,
|
||||
rating_count=rating_count,
|
||||
amazon_choice=amazon_choice,
|
||||
amazon_choice_label=amazon_choice_label,
|
||||
discount_text=discount_text,
|
||||
model_number=model_number,
|
||||
model_name=model_name,
|
||||
debug=debug_info,
|
||||
)
|
||||
|
||||
@@ -203,14 +221,26 @@ class AmazonStore(BaseStore):
|
||||
return None
|
||||
|
||||
def _extract_description(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]:
|
||||
"""Extrait la description (meta tags)."""
|
||||
meta = soup.find("meta", property="og:description") or soup.find(
|
||||
"meta", attrs={"name": "description"}
|
||||
)
|
||||
if meta:
|
||||
description = meta.get("content", "").strip()
|
||||
if description:
|
||||
return description
|
||||
"""Extrait la description depuis les détails de l'article."""
|
||||
selectors = self.get_selector("description", [])
|
||||
if isinstance(selectors, str):
|
||||
selectors = [selectors]
|
||||
|
||||
for selector in selectors:
|
||||
element = soup.select_one(selector)
|
||||
if not element:
|
||||
continue
|
||||
items = [
|
||||
item.get_text(" ", strip=True)
|
||||
for item in element.select("li")
|
||||
if item.get_text(strip=True)
|
||||
]
|
||||
if items:
|
||||
return "\n".join(items)
|
||||
text = " ".join(element.stripped_strings)
|
||||
if text:
|
||||
return text
|
||||
|
||||
return None
|
||||
|
||||
def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
||||
@@ -271,8 +301,10 @@ class AmazonStore(BaseStore):
|
||||
# Défaut basé sur le domaine
|
||||
return "EUR"
|
||||
|
||||
def _extract_stock(self, soup: BeautifulSoup, debug: DebugInfo) -> StockStatus:
|
||||
"""Extrait le statut de stock."""
|
||||
def _extract_stock_details(
|
||||
self, soup: BeautifulSoup, debug: DebugInfo
|
||||
) -> tuple[StockStatus, Optional[str], Optional[bool]]:
|
||||
"""Extrait le statut de stock avec texte brut."""
|
||||
selectors = self.get_selector("stock_status", [])
|
||||
if isinstance(selectors, str):
|
||||
selectors = [selectors]
|
||||
@@ -280,22 +312,27 @@ class AmazonStore(BaseStore):
|
||||
for selector in selectors:
|
||||
element = soup.select_one(selector)
|
||||
if element:
|
||||
text = element.get_text(strip=True).lower()
|
||||
if "en stock" in text or "available" in text or "in stock" in text:
|
||||
return StockStatus.IN_STOCK
|
||||
text = element.get_text(strip=True)
|
||||
normalized = text.lower()
|
||||
if "en stock" in normalized or "available" in normalized or "in stock" in normalized:
|
||||
return StockStatus.IN_STOCK, text, True
|
||||
elif (
|
||||
"rupture" in text
|
||||
or "indisponible" in text
|
||||
or "out of stock" in text
|
||||
"rupture" in normalized
|
||||
or "indisponible" in normalized
|
||||
or "out of stock" in normalized
|
||||
):
|
||||
return StockStatus.OUT_OF_STOCK
|
||||
return StockStatus.OUT_OF_STOCK, text, False
|
||||
|
||||
return StockStatus.UNKNOWN
|
||||
return StockStatus.UNKNOWN, None, None
|
||||
|
||||
def _extract_images(self, soup: BeautifulSoup, debug: DebugInfo) -> list[str]:
|
||||
"""Extrait les URLs d'images."""
|
||||
images = []
|
||||
seen = set()
|
||||
def _extract_images(
|
||||
self, soup: BeautifulSoup, debug: DebugInfo
|
||||
) -> tuple[Optional[str], list[str], list[str]]:
|
||||
"""Extrait l'image principale et la galerie."""
|
||||
images: list[str] = []
|
||||
seen: set[str] = set()
|
||||
main_image: Optional[str] = None
|
||||
max_gallery = 15
|
||||
selectors = self.get_selector("images", [])
|
||||
if isinstance(selectors, str):
|
||||
selectors = [selectors]
|
||||
@@ -309,6 +346,8 @@ class AmazonStore(BaseStore):
|
||||
if self._is_product_image(url) and url not in seen:
|
||||
images.append(url)
|
||||
seen.add(url)
|
||||
if main_image is None:
|
||||
main_image = url
|
||||
dynamic = element.get("data-a-dynamic-image")
|
||||
if dynamic:
|
||||
urls = self._extract_dynamic_images(dynamic)
|
||||
@@ -316,6 +355,8 @@ class AmazonStore(BaseStore):
|
||||
if self._is_product_image(dyn_url) and dyn_url not in seen:
|
||||
images.append(dyn_url)
|
||||
seen.add(dyn_url)
|
||||
if main_image is None:
|
||||
main_image = dyn_url
|
||||
|
||||
# Fallback: chercher tous les img tags si aucune image trouvée
|
||||
if not images:
|
||||
@@ -326,8 +367,15 @@ class AmazonStore(BaseStore):
|
||||
if url not in seen:
|
||||
images.append(url)
|
||||
seen.add(url)
|
||||
if main_image is None:
|
||||
main_image = url
|
||||
|
||||
return images
|
||||
if main_image is None and images:
|
||||
main_image = images[0]
|
||||
gallery_images = [url for url in images if url != main_image]
|
||||
gallery_images = gallery_images[:max_gallery]
|
||||
final_images = [main_image] + gallery_images if main_image else gallery_images
|
||||
return main_image, gallery_images, final_images
|
||||
|
||||
def _extract_dynamic_images(self, raw: str) -> list[str]:
|
||||
"""Extrait les URLs du JSON data-a-dynamic-image."""
|
||||
@@ -393,8 +441,111 @@ class AmazonStore(BaseStore):
|
||||
if key and value:
|
||||
specs[key] = value
|
||||
|
||||
# Détails de l'article sous forme de liste
|
||||
detail_list = soup.select("#detailBullets_feature_div li")
|
||||
for item in detail_list:
|
||||
text = item.get_text(" ", strip=True)
|
||||
if ":" not in text:
|
||||
continue
|
||||
key, value = text.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value and key not in specs:
|
||||
specs[key] = value
|
||||
|
||||
return specs
|
||||
|
||||
def _extract_rating_value(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
||||
"""Extrait la note moyenne."""
|
||||
selectors = self.get_selector("rating_value", [])
|
||||
if isinstance(selectors, str):
|
||||
selectors = [selectors]
|
||||
|
||||
for selector in selectors:
|
||||
element = soup.select_one(selector)
|
||||
if not element:
|
||||
continue
|
||||
text = element.get_text(" ", strip=True) or element.get("title", "").strip()
|
||||
match = re.search(r"([\d.,]+)", text)
|
||||
if match:
|
||||
value = match.group(1).replace(",", ".")
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _extract_rating_count(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[int]:
|
||||
"""Extrait le nombre d'évaluations."""
|
||||
selectors = self.get_selector("rating_count", [])
|
||||
if isinstance(selectors, str):
|
||||
selectors = [selectors]
|
||||
|
||||
for selector in selectors:
|
||||
element = soup.select_one(selector)
|
||||
if not element:
|
||||
continue
|
||||
text = element.get_text(" ", strip=True)
|
||||
match = re.search(r"([\d\s\u202f\u00a0]+)", text)
|
||||
if match:
|
||||
numeric = re.sub(r"[^\d]", "", match.group(1))
|
||||
if numeric:
|
||||
return int(numeric)
|
||||
return None
|
||||
|
||||
def _extract_amazon_choice(
|
||||
self, soup: BeautifulSoup, debug: DebugInfo
|
||||
) -> tuple[Optional[bool], Optional[str]]:
|
||||
"""Extrait le badge Choix d'Amazon."""
|
||||
selectors = self.get_selector("amazon_choice", [])
|
||||
if isinstance(selectors, str):
|
||||
selectors = [selectors]
|
||||
|
||||
for selector in selectors:
|
||||
element = soup.select_one(selector)
|
||||
if element:
|
||||
label_candidates = [
|
||||
element.get_text(" ", strip=True),
|
||||
element.get("aria-label", "").strip(),
|
||||
element.get("title", "").strip(),
|
||||
element.get("data-a-badge-label", "").strip(),
|
||||
]
|
||||
label = next((item for item in label_candidates if item), "")
|
||||
normalized = label.lower()
|
||||
if "choix d'amazon" in normalized or "amazon's choice" in normalized:
|
||||
return True, label
|
||||
if label:
|
||||
return True, label
|
||||
return True, None
|
||||
return None, None
|
||||
|
||||
def _extract_discount_text(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]:
|
||||
"""Extrait le texte de réduction explicite."""
|
||||
selectors = self.get_selector("discount_text", [])
|
||||
if isinstance(selectors, str):
|
||||
selectors = [selectors]
|
||||
|
||||
for selector in selectors:
|
||||
element = soup.select_one(selector)
|
||||
if not element:
|
||||
continue
|
||||
text = element.get_text(" ", strip=True)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
def _extract_model_details(self, specs: dict[str, str]) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Extrait le numero et le nom du modele depuis les specs."""
|
||||
model_number = None
|
||||
model_name = None
|
||||
for key, value in specs.items():
|
||||
normalized = key.lower()
|
||||
if "numéro du modèle de l'article" in normalized or "numero du modele de l'article" in normalized:
|
||||
model_number = value
|
||||
if "nom du modèle" in normalized or "nom du modele" in normalized:
|
||||
model_name = value
|
||||
return model_number, model_name
|
||||
|
||||
def _extract_asin_from_html(self, soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extrait l'ASIN depuis le HTML (fallback)."""
|
||||
selectors = self.get_selector("asin", [])
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
import redis
|
||||
@@ -127,11 +128,13 @@ class ScrapingScheduler:
|
||||
interval_hours: int = 24,
|
||||
use_playwright: Optional[bool] = None,
|
||||
save_db: bool = True,
|
||||
job_id: Optional[str] = None,
|
||||
) -> ScheduledJobInfo:
|
||||
"""Planifie un scraping recurrent (intervalle en heures)."""
|
||||
interval_seconds = int(timedelta(hours=interval_hours).total_seconds())
|
||||
next_run = datetime.now(timezone.utc) + timedelta(seconds=interval_seconds)
|
||||
|
||||
resolved_job_id = job_id or self._job_id_for_url(url)
|
||||
job = self.scheduler.schedule(
|
||||
scheduled_time=next_run,
|
||||
func=scrape_product,
|
||||
@@ -139,6 +142,13 @@ class ScrapingScheduler:
|
||||
kwargs={"use_playwright": use_playwright, "save_db": save_db},
|
||||
interval=interval_seconds,
|
||||
repeat=None,
|
||||
id=resolved_job_id,
|
||||
)
|
||||
logger.info(f"Job planifie: {job.id}, prochaine execution: {next_run.isoformat()}")
|
||||
return ScheduledJobInfo(job_id=job.id, next_run=next_run)
|
||||
|
||||
@staticmethod
|
||||
def _job_id_for_url(url: str) -> str:
|
||||
"""Genere un job_id stable pour eviter les doublons."""
|
||||
fingerprint = hashlib.sha1(url.strip().lower().encode("utf-8")).hexdigest()
|
||||
return f"scrape_{fingerprint}"
|
||||
|
||||
@@ -157,6 +157,36 @@ def scrape_product(
|
||||
)
|
||||
success = False
|
||||
fetch_error = str(exc)
|
||||
# Si captcha detecte via HTTP, forcer une tentative Playwright.
|
||||
if (
|
||||
fetch_method == FetchMethod.HTTP
|
||||
and use_playwright
|
||||
and snapshot.debug.errors
|
||||
and any("captcha" in error.lower() for error in snapshot.debug.errors)
|
||||
):
|
||||
logger.info("[FETCH] Captcha detecte, tentative Playwright")
|
||||
pw_result = fetch_playwright(
|
||||
canonical_url,
|
||||
headless=not headful,
|
||||
timeout_ms=timeout_ms,
|
||||
save_screenshot=save_screenshot,
|
||||
)
|
||||
if pw_result.success and pw_result.html:
|
||||
try:
|
||||
snapshot = store.parse(pw_result.html, canonical_url)
|
||||
snapshot.debug.method = FetchMethod.PLAYWRIGHT
|
||||
snapshot.debug.duration_ms = pw_result.duration_ms
|
||||
snapshot.debug.html_size_bytes = len(pw_result.html.encode("utf-8"))
|
||||
snapshot.add_note("Captcha detecte via HTTP, fallback Playwright")
|
||||
success = snapshot.debug.status != DebugStatus.FAILED
|
||||
except Exception as exc:
|
||||
snapshot.add_note(f"Fallback Playwright echoue: {exc}")
|
||||
logger.error(f"[PARSE] Exception fallback Playwright: {exc}")
|
||||
fetch_error = str(exc)
|
||||
else:
|
||||
error = pw_result.error or "Erreur Playwright"
|
||||
snapshot.add_note(f"Fallback Playwright echoue: {error}")
|
||||
fetch_error = error
|
||||
else:
|
||||
snapshot = ProductSnapshot(
|
||||
source=store.store_id,
|
||||
|
||||
BIN
webui/dist/assets/fa-brands-400-D1LuMI3I.ttf
vendored
Normal file
BIN
webui/dist/assets/fa-brands-400-D_cYUPeE.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-regular-400-BjRzuEpd.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-regular-400-DZaxPHgR.ttf
vendored
Normal file
BIN
webui/dist/assets/fa-solid-900-CTAAxXor.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-solid-900-D0aA9rwL.ttf
vendored
Normal file
BIN
webui/dist/assets/fa-v4compatibility-C9RhG_FT.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-v4compatibility-CCth-dXg.ttf
vendored
Normal file
5
webui/dist/assets/index-BURbFjJa.css
vendored
Normal file
18
webui/dist/assets/index-ZvFbjZEA.js
vendored
Normal file
5
webui/dist/favicon.svg
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#3c3836" />
|
||||
<circle cx="32" cy="32" r="18" fill="#fe8019" />
|
||||
<path d="M18 34c6-6 22-6 28 0" fill="none" stroke="#282828" stroke-width="4" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
14
webui/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PriceWatch Web UI</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script type="module" crossorigin src="/assets/index-ZvFbjZEA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BURbFjJa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 181 KiB |
144
webui/src/components/CardActions.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
productId: number
|
||||
compareIds: number[]
|
||||
showSecondary?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'compare'): void
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'open'): void
|
||||
}>()
|
||||
|
||||
function handleRefresh(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
function handleCompare(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('compare')
|
||||
}
|
||||
|
||||
function handleEdit(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('edit')
|
||||
}
|
||||
|
||||
function handleDelete(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
function handleOpen(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('open')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="card-actions__btn card-actions__btn--primary"
|
||||
title="Rafraichir"
|
||||
aria-label="Rafraichir le produit"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card-actions__btn"
|
||||
title="Modifier"
|
||||
aria-label="Modifier le produit"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card-actions__btn"
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer le produit"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card-actions__btn"
|
||||
title="Ouvrir"
|
||||
aria-label="Ouvrir dans un nouvel onglet"
|
||||
@click="handleOpen"
|
||||
>
|
||||
<i class="fa-solid fa-up-right-from-square"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="props.showSecondary"
|
||||
class="card-actions__btn"
|
||||
:class="{ 'card-actions__btn--active': compareIds.includes(productId) }"
|
||||
title="Comparer"
|
||||
aria-label="Comparer le produit"
|
||||
@click="handleCompare"
|
||||
>
|
||||
<i class="fa-solid" :class="compareIds.includes(productId) ? 'fa-square-check' : 'fa-code-compare'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 0 2px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.card-actions__btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-actions__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-actions__btn--primary {
|
||||
background: rgba(254, 128, 25, 0.18);
|
||||
color: var(--accent);
|
||||
border-color: rgba(254, 128, 25, 0.4);
|
||||
}
|
||||
|
||||
.card-actions__btn--primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 14px rgba(254, 128, 25, 0.25);
|
||||
}
|
||||
|
||||
.card-actions__btn--active {
|
||||
background: rgba(184, 187, 38, 0.2);
|
||||
color: var(--success);
|
||||
border-color: rgba(184, 187, 38, 0.45);
|
||||
}
|
||||
|
||||
.card-actions__btn--active:hover {
|
||||
background: var(--success);
|
||||
}
|
||||
</style>
|
||||
@@ -78,11 +78,9 @@ const yBounds = computed(() => {
|
||||
const values = validPoints.value.map((item) => item.v);
|
||||
const rawMin = Math.min(...values);
|
||||
const rawMax = Math.max(...values);
|
||||
const delta = Math.max(rawMax - rawMin, 1);
|
||||
const pad = delta * 0.05;
|
||||
return {
|
||||
min: rawMin - pad,
|
||||
max: rawMax + pad,
|
||||
min: rawMin,
|
||||
max: rawMax,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -132,21 +130,14 @@ const chartPoints = computed(() => {
|
||||
const hasPoints = computed(() => chartPoints.value.length > 0);
|
||||
|
||||
const linePoints = computed(() => {
|
||||
if (!chartPoints.value.length) {
|
||||
if (chartPoints.value.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
if (chartPoints.value.length === 1) {
|
||||
const point = chartPoints.value[0];
|
||||
const endX = margins.left + chartDimensions.value.width;
|
||||
return [
|
||||
{ x: margins.left, y: point.y },
|
||||
{ x: endX, y: point.y },
|
||||
];
|
||||
}
|
||||
return chartPoints.value;
|
||||
});
|
||||
|
||||
const polylinePoints = computed(() => linePoints.value.map((point) => `${point.x},${point.y}`).join(" "));
|
||||
const showLine = computed(() => linePoints.value.length > 1);
|
||||
|
||||
const yTickValues = computed(() => {
|
||||
const count = Math.max(2, props.yTicks);
|
||||
@@ -256,6 +247,7 @@ const placeholderLabel = computed(() => "");
|
||||
</text>
|
||||
</g>
|
||||
<polyline
|
||||
v-if="showLine"
|
||||
:points="polylinePoints"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
|
||||
319
webui/src/components/PriceBlock.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
price: number | null
|
||||
currency: string
|
||||
msrp?: number | null
|
||||
discountAmount?: number | null
|
||||
discountPercent?: number | null
|
||||
discountText?: string | null
|
||||
deltaLabel?: string | null
|
||||
deltaLabelTitle?: string | null
|
||||
stockStatus: string
|
||||
stockText?: string | null
|
||||
inStock?: boolean | null
|
||||
reference?: string | null
|
||||
url?: string | null
|
||||
ratingValue?: number | null
|
||||
ratingCount?: number | null
|
||||
amazonChoice?: boolean | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const formatPrice = (value: number | null, currency: string): string => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
const formatShortPrice = (value: number | null, currency: string): string => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const rounded = Math.round(numeric * 100) / 100
|
||||
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
|
||||
const hasCents = centsValue !== 0
|
||||
if ((currency || 'EUR') === 'EUR') {
|
||||
const euros = Math.floor(rounded)
|
||||
const cents = String(Math.abs(centsValue)).padStart(2, '0')
|
||||
return hasCents ? `${euros}€${cents}` : `${euros}€`
|
||||
}
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR',
|
||||
minimumFractionDigits: hasCents ? 2 : 0,
|
||||
maximumFractionDigits: hasCents ? 2 : 0,
|
||||
}).format(rounded)
|
||||
}
|
||||
|
||||
type PriceParts = {
|
||||
euros: string
|
||||
cents: string | null
|
||||
symbol: string
|
||||
raw: string | null
|
||||
}
|
||||
|
||||
const formatPriceParts = (value: number | null, currency: string): PriceParts => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return { euros: '', cents: null, symbol: '', raw: '—' }
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const rounded = Math.round(numeric * 100) / 100
|
||||
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
|
||||
const hasCents = centsValue !== 0
|
||||
if ((currency || 'EUR') !== 'EUR') {
|
||||
return { euros: '', cents: null, symbol: '', raw: formatShortPrice(rounded, currency) }
|
||||
}
|
||||
return {
|
||||
euros: String(Math.floor(rounded)),
|
||||
cents: hasCents ? String(Math.abs(centsValue)).padStart(2, '0') : null,
|
||||
symbol: '€',
|
||||
raw: null,
|
||||
}
|
||||
}
|
||||
|
||||
const formattedPrice = computed(() => formatPriceParts(props.price, props.currency))
|
||||
const formattedMsrp = computed(() => (props.msrp ? formatPriceParts(props.msrp, props.currency) : null))
|
||||
|
||||
const discountDisplay = computed(() => {
|
||||
if (props.discountText?.trim()) {
|
||||
return props.discountText.trim()
|
||||
}
|
||||
if (props.discountAmount === null || props.discountAmount === undefined ||
|
||||
props.discountPercent === null || props.discountPercent === undefined) {
|
||||
return null
|
||||
}
|
||||
const amount = formatShortPrice(props.discountAmount, props.currency)
|
||||
const percent = Math.round(props.discountPercent)
|
||||
return `-${percent}% (${amount})`
|
||||
})
|
||||
|
||||
const stockLabel = computed(() => {
|
||||
if (props.stockText?.trim()) {
|
||||
return props.stockText.trim()
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
in_stock: 'En stock',
|
||||
out_of_stock: 'Rupture',
|
||||
unknown: 'Inconnu',
|
||||
error: 'Erreur',
|
||||
}
|
||||
return map[props.stockStatus] || props.stockStatus
|
||||
})
|
||||
|
||||
const stockClass = computed(() => {
|
||||
if (props.inStock === true) return 'text-[var(--success)]'
|
||||
if (props.inStock === false) return 'text-[var(--danger)]'
|
||||
if (props.stockStatus === 'in_stock') return 'text-[var(--success)]'
|
||||
if (props.stockStatus === 'out_of_stock') return 'text-[var(--danger)]'
|
||||
return 'text-[var(--muted)]'
|
||||
})
|
||||
|
||||
const deltaDisplay = computed(() => {
|
||||
if (props.deltaLabel && props.deltaLabel.trim()) {
|
||||
return props.deltaLabel
|
||||
}
|
||||
return '—'
|
||||
})
|
||||
|
||||
const deltaTitle = computed(() => {
|
||||
if (props.deltaLabelTitle && props.deltaLabelTitle.trim()) {
|
||||
return props.deltaLabelTitle
|
||||
}
|
||||
return 'Evol.'
|
||||
})
|
||||
|
||||
const ratingDisplay = computed(() => {
|
||||
if (props.ratingValue === null || props.ratingValue === undefined) {
|
||||
return '—'
|
||||
}
|
||||
const value = props.ratingValue.toFixed(1).replace('.', ',')
|
||||
if (props.ratingCount === null || props.ratingCount === undefined) {
|
||||
return value
|
||||
}
|
||||
const count = new Intl.NumberFormat('fr-FR').format(props.ratingCount)
|
||||
return `${value} (${count})`
|
||||
})
|
||||
|
||||
const amazonChoiceDisplay = computed(() => {
|
||||
if (props.amazonChoice === true) return 'Oui'
|
||||
if (props.amazonChoice === false) return '—'
|
||||
return '—'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="price-block" :class="{ 'price-block--compact': compact }">
|
||||
<div class="price-block__row price-block__row--main">
|
||||
<span class="price-block__label">Actuel</span>
|
||||
<span class="price-block__current">
|
||||
<template v-if="formattedPrice.raw">{{ formattedPrice.raw }}</template>
|
||||
<template v-else>
|
||||
<span class="price-block__euros">{{ formattedPrice.euros }}</span>
|
||||
<sup class="price-block__currency">{{ formattedPrice.symbol }}</sup>
|
||||
<span v-if="formattedPrice.cents" class="price-block__cents">{{ formattedPrice.cents }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">Prix conseillé</span>
|
||||
<span class="price-block__msrp">
|
||||
<template v-if="!formattedMsrp">—</template>
|
||||
<template v-else-if="formattedMsrp.raw">{{ formattedMsrp.raw }}</template>
|
||||
<template v-else>
|
||||
<span class="price-block__euros">{{ formattedMsrp.euros }}</span>
|
||||
<sup class="price-block__currency">{{ formattedMsrp.symbol }}</sup>
|
||||
<span v-if="formattedMsrp.cents" class="price-block__cents">{{ formattedMsrp.cents }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">{{ deltaTitle }}</span>
|
||||
<span>{{ deltaDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="discountDisplay" class="price-block__row price-block__discount">
|
||||
<span class="price-block__label">Réduction</span>
|
||||
<span>{{ discountDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row price-block__stock" :class="stockClass">
|
||||
<span class="price-block__label">Stock</span>
|
||||
<span>{{ stockLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">Note</span>
|
||||
<span>{{ ratingDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">Choix Amazon</span>
|
||||
<span>{{ amazonChoiceDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__meta">
|
||||
<span v-if="reference" class="price-block__ref">Ref: {{ reference }}</span>
|
||||
<a
|
||||
v-if="url"
|
||||
class="price-block__link"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
Lien produit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.price-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.price-block--compact {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.price-block__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.price-block__row--main {
|
||||
font-size: 0.8rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.price-block__label {
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
font-size: 0.6rem;
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.price-block__current {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.price-block__euros {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.price-block__currency {
|
||||
font-size: 0.65em;
|
||||
vertical-align: super;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.price-block__cents {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.price-block__msrp {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.price-block__discount {
|
||||
font-weight: 600;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.price-block__stock {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-block__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 6px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.price-block__ref {
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price-block__link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-block__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -119,7 +119,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const popupStyle = computed(() => ({
|
||||
position: "fixed",
|
||||
position: "fixed" as const,
|
||||
top: `${props.position.top}px`,
|
||||
left: `${props.position.left}px`,
|
||||
width: "280px",
|
||||
|
||||
600
webui/src/components/ProductCard.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import MiniLineChart from './MiniLineChart.vue'
|
||||
import PriceBlock from './PriceBlock.vue'
|
||||
import CardActions from './CardActions.vue'
|
||||
|
||||
interface HistoryPoint {
|
||||
t: number
|
||||
v: number
|
||||
}
|
||||
|
||||
interface HistorySnapshot {
|
||||
points: HistoryPoint[]
|
||||
min: number | null
|
||||
max: number | null
|
||||
delta: number | null
|
||||
trendIcon: string
|
||||
trendLabel: string
|
||||
trendDeltaLabel: string
|
||||
trendColor: string
|
||||
lastTimestamp: number | null
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: number
|
||||
storeId: string
|
||||
title: string
|
||||
url: string
|
||||
price: number | null
|
||||
currency: string
|
||||
msrp: number | null
|
||||
stockStatus: string
|
||||
stockText?: string | null
|
||||
inStock?: boolean | null
|
||||
updatedAt: string
|
||||
delta: number
|
||||
discountAmount: number | null
|
||||
discountPercent: number | null
|
||||
discountText?: string | null
|
||||
imageWebp?: string
|
||||
imageJpg?: string
|
||||
reference?: string
|
||||
category?: string
|
||||
type?: string
|
||||
notes?: string
|
||||
analysis?: string
|
||||
ratingValue?: number | null
|
||||
ratingCount?: number | null
|
||||
amazonChoice?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
product: Product
|
||||
historyData: HistorySnapshot | null
|
||||
compareIds: number[]
|
||||
storeLogo?: string
|
||||
storeLabel: string
|
||||
storeInitials: string
|
||||
chartPeriodLabel: string
|
||||
imageMode: string
|
||||
placeholderImage: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'compare'): void
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'open'): void
|
||||
(e: 'hover', event: MouseEvent | FocusEvent): void
|
||||
(e: 'leave'): void
|
||||
}>()
|
||||
|
||||
const formatShortPrice = (value: number | null, currency: string): string => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const rounded = Math.round(numeric * 100) / 100
|
||||
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
|
||||
const hasCents = centsValue !== 0
|
||||
if ((currency || 'EUR') === 'EUR') {
|
||||
const euros = Math.floor(rounded)
|
||||
const cents = String(Math.abs(centsValue)).padStart(2, '0')
|
||||
return hasCents ? `${euros}€${cents}` : `${euros}€`
|
||||
}
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR',
|
||||
minimumFractionDigits: hasCents ? 2 : 0,
|
||||
maximumFractionDigits: hasCents ? 2 : 0,
|
||||
}).format(rounded)
|
||||
}
|
||||
|
||||
const formatHistoryDateLabel = (value: number | string): string => {
|
||||
let timestamp: number | null = null
|
||||
if (typeof value === 'number') {
|
||||
timestamp = value
|
||||
} else if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
timestamp = parsed
|
||||
}
|
||||
}
|
||||
if (timestamp === null) {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
const diffMs = Math.max(0, Date.now() - timestamp)
|
||||
const hours = Math.max(0, Math.round(diffMs / 3_600_000))
|
||||
if (hours < 36) {
|
||||
return hours === 0 ? '0h' : `-${hours}h`
|
||||
}
|
||||
const days = Math.max(1, Math.round(hours / 24))
|
||||
return `-${days}j`
|
||||
}
|
||||
|
||||
const formatRelativeTimeAgo = (timestamp: number | null): string => {
|
||||
if (timestamp === null || !Number.isFinite(timestamp)) {
|
||||
return 'a l instant'
|
||||
}
|
||||
const diff = Date.now() - timestamp
|
||||
if (diff < 60_000) {
|
||||
return 'a l instant'
|
||||
}
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 60) {
|
||||
return `il y a ${minutes} min`
|
||||
}
|
||||
const hours = Math.floor(diff / 3_600_000)
|
||||
if (hours < 24) {
|
||||
return `il y a ${hours} h`
|
||||
}
|
||||
const days = Math.floor(diff / 86_400_000)
|
||||
return `il y a ${days} j`
|
||||
}
|
||||
|
||||
const cardClasses = computed(() => ({
|
||||
'product-card': true,
|
||||
'product-card--accent': props.product.delta < 0,
|
||||
}))
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
return props.product.imageJpg || props.product.imageWebp || props.placeholderImage
|
||||
})
|
||||
|
||||
const hasImage = computed(() => {
|
||||
return Boolean(props.product.imageWebp || props.product.imageJpg)
|
||||
})
|
||||
|
||||
const lastUpdateLabel = computed(() => {
|
||||
const timestamp = props.historyData?.lastTimestamp
|
||||
if (timestamp) {
|
||||
return formatRelativeTimeAgo(timestamp)
|
||||
}
|
||||
if (props.product.updatedAt) {
|
||||
const parsed = Date.parse(props.product.updatedAt)
|
||||
return Number.isNaN(parsed) ? props.product.updatedAt : formatRelativeTimeAgo(parsed)
|
||||
}
|
||||
return '—'
|
||||
})
|
||||
|
||||
const historyDeltaLabel = computed(() => {
|
||||
const value = props.historyData?.delta
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const sign = numeric >= 0 ? '+' : ''
|
||||
return `${sign}${numeric.toFixed(1)}%`
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
emit('click')
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
|
||||
function handleHover(event: MouseEvent | FocusEvent) {
|
||||
emit('hover', event)
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
emit('leave')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
:class="cardClasses"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
@mouseenter="handleHover"
|
||||
@mouseleave="handleLeave"
|
||||
@focusin="handleHover"
|
||||
@focusout="handleLeave"
|
||||
>
|
||||
<div class="product-card__top">
|
||||
<div class="product-card__identity">
|
||||
<div class="product-card__store-icon">
|
||||
<img v-if="storeLogo" :src="storeLogo" alt="" />
|
||||
<span v-else class="product-card__store-initials">{{ storeInitials }}</span>
|
||||
</div>
|
||||
<div class="product-card__identity-text">
|
||||
<h3 class="product-card__title" :title="product.title">
|
||||
{{ product.title }}
|
||||
</h3>
|
||||
<div class="product-card__store-name">{{ storeLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-card__reference">
|
||||
{{ product.reference || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card__layout">
|
||||
<!-- Colonne gauche -->
|
||||
<div class="product-card__left">
|
||||
<div class="product-card__thumbnail">
|
||||
<picture v-if="hasImage">
|
||||
<source v-if="product.imageWebp" :srcset="product.imageWebp" type="image/webp" />
|
||||
<source v-if="product.imageJpg" :srcset="product.imageJpg" type="image/jpeg" />
|
||||
<img
|
||||
:src="imageUrl"
|
||||
class="product-card__image product-card__image--contain"
|
||||
alt="Image produit"
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
<img
|
||||
v-else
|
||||
:src="placeholderImage"
|
||||
class="product-card__image product-card__image--contain"
|
||||
alt="Image indisponible"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone centrale -->
|
||||
<div class="product-card__main">
|
||||
<PriceBlock
|
||||
:price="product.price"
|
||||
:currency="product.currency"
|
||||
:msrp="product.msrp"
|
||||
:discount-amount="product.discountAmount"
|
||||
:discount-percent="product.discountPercent"
|
||||
:discount-text="product.discountText"
|
||||
:delta-label="historyDeltaLabel"
|
||||
:delta-label-title="`Evol. ${chartPeriodLabel}`"
|
||||
:stock-status="product.stockStatus"
|
||||
:stock-text="product.stockText"
|
||||
:in-stock="product.inStock"
|
||||
:reference="product.reference"
|
||||
:url="product.url"
|
||||
:rating-value="product.ratingValue"
|
||||
:rating-count="product.ratingCount"
|
||||
:amazon-choice="product.amazonChoice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zone basse -->
|
||||
<div class="product-card__history-zone">
|
||||
<div class="product-card__chart-container" :style="{ color: historyData?.trendColor || 'var(--muted)' }">
|
||||
<MiniLineChart
|
||||
v-if="historyData && historyData.points.length > 0"
|
||||
:points="historyData.points"
|
||||
:height="140"
|
||||
:formatY="(value: number) => formatShortPrice(value, product.currency)"
|
||||
:formatX="formatHistoryDateLabel"
|
||||
:yTicks="3"
|
||||
:xTicks="3"
|
||||
/>
|
||||
<div v-else class="product-card__no-history">
|
||||
Pas d'historique
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card__history-stats">
|
||||
<div class="product-card__stat">
|
||||
<span class="product-card__stat-label">Min</span>
|
||||
<span class="product-card__stat-value">
|
||||
{{ historyData && historyData.min !== null ? formatShortPrice(historyData.min, product.currency) : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="product-card__stat">
|
||||
<span class="product-card__stat-label">Max</span>
|
||||
<span class="product-card__stat-value">
|
||||
{{ historyData && historyData.max !== null ? formatShortPrice(historyData.max, product.currency) : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="product-card__stat">
|
||||
<span class="product-card__stat-label">Tendance</span>
|
||||
<span
|
||||
class="product-card__stat-value product-card__trend"
|
||||
:style="{ color: historyData?.trendColor || 'var(--muted)' }"
|
||||
>
|
||||
{{ historyData?.trendIcon || '→' }} {{ historyData?.trendLabel || '—' }}
|
||||
<span class="product-card__trend-delta">{{ historyData?.trendDeltaLabel || '—' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="product-card__stat product-card__stat--update">
|
||||
<span class="product-card__stat-label">Dernier scrap</span>
|
||||
<span class="product-card__stat-value">
|
||||
{{ lastUpdateLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card__footer">
|
||||
<div class="product-card__meta">
|
||||
<span>Categorie: {{ product.category || '—' }}</span>
|
||||
<span>Type: {{ product.type || '—' }}</span>
|
||||
</div>
|
||||
<CardActions
|
||||
:product-id="product.id"
|
||||
:compare-ids="compareIds"
|
||||
:show-secondary="false"
|
||||
@refresh="emit('refresh')"
|
||||
@compare="emit('compare')"
|
||||
@edit="emit('edit')"
|
||||
@delete="emit('delete')"
|
||||
@open="emit('open')"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 14px 26px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.product-card:hover,
|
||||
.product-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 32px var(--shadow);
|
||||
}
|
||||
|
||||
.product-card--accent {
|
||||
border-color: rgba(254, 128, 25, 0.5);
|
||||
box-shadow: 0 10px 30px rgba(254, 128, 25, 0.15);
|
||||
}
|
||||
|
||||
/* Header: Identity */
|
||||
.product-card__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-card__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.product-card__reference {
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-card__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-card__identity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-card__store-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__store-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.product-card__store-initials {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.product-card__identity-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-card__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-card__store-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Thumbnail */
|
||||
.product-card__thumbnail {
|
||||
width: 100%;
|
||||
height: var(--pw-card-media-height, 140px);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.product-card__image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.product-card__image--contain {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.product-card__image--cover {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Main Zone */
|
||||
.product-card__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: var(--pw-card-media-height, 140px);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.product-card__main :deep(.price-block) {
|
||||
max-width: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* History Zone */
|
||||
.product-card__history-zone {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.product-card__chart-container {
|
||||
height: 140px;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__no-history {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.product-card__history-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-card__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-card__stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.product-card__stat-value {
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.product-card__trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.product-card__trend-delta {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.product-card__stat--update {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.product-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.product-card__meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.product-card__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.product-card__top {
|
||||
flex-direction: column;
|
||||
}
|
||||
.product-card__reference {
|
||||
text-align: left;
|
||||
}
|
||||
.product-card__footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.product-card__history-zone {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
200
webui/src/components/ProductSummary.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
notes?: string | null
|
||||
analysis?: string | null
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:notes', value: string): void
|
||||
}>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editedNotes = ref(props.notes || '')
|
||||
|
||||
const hasContent = computed(() => {
|
||||
return Boolean(props.notes?.trim() || props.analysis?.trim())
|
||||
})
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (props.notes?.trim()) {
|
||||
return props.notes
|
||||
}
|
||||
if (props.analysis?.trim()) {
|
||||
return props.analysis
|
||||
}
|
||||
return 'Aucune note'
|
||||
})
|
||||
|
||||
function startEdit() {
|
||||
if (!props.editable) return
|
||||
editedNotes.value = props.notes || ''
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
emit('update:notes', editedNotes.value)
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editedNotes.value = props.notes || ''
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-summary" :class="{ 'product-summary--empty': !hasContent }">
|
||||
<div class="product-summary__header">
|
||||
<span class="product-summary__label">Résumé</span>
|
||||
<button
|
||||
v-if="editable && !isEditing"
|
||||
class="product-summary__edit-btn"
|
||||
title="Modifier"
|
||||
@click="startEdit"
|
||||
>
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="product-summary__edit">
|
||||
<textarea
|
||||
v-model="editedNotes"
|
||||
class="product-summary__textarea"
|
||||
placeholder="Ajouter des notes..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="product-summary__edit-actions">
|
||||
<button class="product-summary__action-btn" @click="saveEdit">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="product-summary__action-btn product-summary__action-btn--cancel" @click="cancelEdit">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="product-summary__content">
|
||||
{{ displayText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-summary {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.product-summary--empty {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.product-summary__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-summary__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.product-summary__edit-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.product-summary__edit-btn:hover {
|
||||
background: var(--accent);
|
||||
color: #1b1b1b;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.product-summary__content {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.product-summary__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-summary__textarea {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.product-summary__textarea:focus {
|
||||
outline: 2px solid rgba(254, 128, 25, 0.4);
|
||||
}
|
||||
|
||||
.product-summary__edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.product-summary__action-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #1b1b1b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.product-summary__action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.product-summary__action-btn--cancel {
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.product-summary__action-btn--cancel:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,8 @@
|
||||
--pw-store-icon: 40px;
|
||||
--pw-card-height-factor: 1;
|
||||
--pw-card-mobile-height-factor: 1;
|
||||
--pw-card-media-height: 160px;
|
||||
--pw-card-media-height: 140px;
|
||||
--pw-card-columns: 3;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
@@ -138,14 +139,12 @@
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 16px 32px var(--shadow);
|
||||
box-shadow: 0 12px 24px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: calc(470px * var(--pw-card-height-factor, 1));
|
||||
padding: 24px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.card-thumbnail {
|
||||
@@ -403,6 +402,25 @@
|
||||
box-shadow: 0 10px 30px rgba(254, 128, 25, 0.2);
|
||||
}
|
||||
|
||||
/* Stock status colors */
|
||||
.status-in_stock,
|
||||
.status-in-stock {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-out_of_stock,
|
||||
.status-out-of-stock {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.density-dense .card {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -616,6 +634,36 @@
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.add-product-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-product-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.add-product-modal__body {
|
||||
padding: 16px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-product-modal__footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 24px 18px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--surface);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.image-toggle {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
@@ -636,6 +684,44 @@
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.add-product-carousel {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(88px, 1fr);
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.add-product-thumb {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
scroll-snap-align: start;
|
||||
cursor: pointer;
|
||||
transition: border 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.add-product-thumb:hover {
|
||||
border-color: rgba(254, 128, 25, 0.6);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.add-product-thumb.selected {
|
||||
border-color: rgba(254, 128, 25, 0.9);
|
||||
box-shadow: 0 6px 16px rgba(254, 128, 25, 0.2);
|
||||
}
|
||||
|
||||
.add-product-thumb__image {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.log-status-panel {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -777,8 +863,8 @@
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 32px;
|
||||
grid-template-columns: repeat(var(--pw-card-columns, 3), minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -790,6 +876,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.product-grid {
|
||||
--pw-card-columns: min(var(--pw-card-columns, 3), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.product-grid {
|
||||
--pw-card-columns: min(var(--pw-card-columns, 3), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-header .toolbar-text {
|
||||
display: none;
|
||||
@@ -800,6 +898,7 @@
|
||||
}
|
||||
.product-grid {
|
||||
grid-template-columns: 1fr;
|
||||
--pw-card-columns: 1;
|
||||
}
|
||||
.card {
|
||||
min-height: calc(470px * var(--pw-card-mobile-height-factor, 1));
|
||||
|
||||
@@ -18,12 +18,23 @@ export interface Product {
|
||||
id: number
|
||||
source: string
|
||||
reference: string
|
||||
asin: string | null
|
||||
url: string
|
||||
title: string | null
|
||||
category: string | null
|
||||
type: string | null
|
||||
description: string | null
|
||||
currency: string | null
|
||||
msrp: number | null
|
||||
rating_value: number | null
|
||||
rating_count: number | null
|
||||
amazon_choice: boolean | null
|
||||
amazon_choice_label: string | null
|
||||
discount_text: string | null
|
||||
stock_text: string | null
|
||||
in_stock: boolean | null
|
||||
model_number: string | null
|
||||
model_name: string | null
|
||||
first_seen_at: string
|
||||
last_updated_at: string
|
||||
latest_price: number | null
|
||||
@@ -31,6 +42,8 @@ export interface Product {
|
||||
latest_stock_status: StockStatus | null
|
||||
latest_fetched_at: string | null
|
||||
images: string[]
|
||||
main_image: string | null
|
||||
gallery_images: string[]
|
||||
specs: Record<string, string>
|
||||
discount_amount: number | null
|
||||
discount_percent: number | null
|
||||
@@ -43,6 +56,7 @@ export interface ProductCreate {
|
||||
url: string
|
||||
title?: string | null
|
||||
category?: string | null
|
||||
type?: string | null
|
||||
description?: string | null
|
||||
currency?: string | null
|
||||
msrp?: number | null
|
||||
@@ -52,6 +66,7 @@ export interface ProductUpdate {
|
||||
url?: string | null
|
||||
title?: string | null
|
||||
category?: string | null
|
||||
type?: string | null
|
||||
description?: string | null
|
||||
currency?: string | null
|
||||
msrp?: number | null
|
||||
@@ -188,10 +203,23 @@ export interface ProductSnapshot {
|
||||
currency: string | null
|
||||
shipping_cost: number | null
|
||||
stock_status: StockStatus | null
|
||||
stock_text: string | null
|
||||
in_stock: boolean | null
|
||||
reference: string | null
|
||||
asin: string | null
|
||||
category: string | null
|
||||
type: string | null
|
||||
description: string | null
|
||||
rating_value: number | null
|
||||
rating_count: number | null
|
||||
amazon_choice: boolean | null
|
||||
amazon_choice_label: string | null
|
||||
discount_text: string | null
|
||||
model_number: string | null
|
||||
model_name: string | null
|
||||
images: string[]
|
||||
main_image: string | null
|
||||
gallery_images: string[]
|
||||
specs: Record<string, string>
|
||||
msrp: number | null
|
||||
debug: DebugInfo
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface FilterChip {
|
||||
// === Settings ===
|
||||
export interface AppSettings {
|
||||
cardRatio: number
|
||||
cardColumns: number
|
||||
imageHeight: number
|
||||
imageMode: ImageMode
|
||||
fontSize: number
|
||||
@@ -87,12 +88,12 @@ export interface ProductMeta {
|
||||
export type ProductMetaMap = Record<number, ProductMeta>
|
||||
|
||||
// === Scrape Log ===
|
||||
export type LogLevel = 'debug' | 'info' | 'success' | 'warning' | 'error'
|
||||
export type ScrapeLogLevel = 'debug' | 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface ScrapeLogEntry {
|
||||
id: number
|
||||
time: string
|
||||
level: LogLevel
|
||||
level: ScrapeLogLevel
|
||||
text: string
|
||||
}
|
||||
|
||||
@@ -152,7 +153,7 @@ export type LogTab = 'frontend' | 'backend' | 'uvicorn'
|
||||
export interface FrontendLog {
|
||||
id: number
|
||||
time: string
|
||||
level: LogLevel
|
||||
level: ScrapeLogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -164,6 +165,9 @@ export interface CardRatioPreset {
|
||||
|
||||
// === Constants (exportés pour réutilisation) ===
|
||||
export const DEFAULT_CARD_RATIO = 1
|
||||
export const DEFAULT_CARD_COLUMNS = 3
|
||||
export const MIN_CARD_COLUMNS = 1
|
||||
export const MAX_CARD_COLUMNS = 6
|
||||
export const DEFAULT_IMAGE_HEIGHT = 160
|
||||
export const CARD_HISTORY_LIMIT = 12
|
||||
export const DEFAULT_LOG_DURATION = 2500
|
||||
@@ -178,7 +182,7 @@ export const CARD_RATIO_PRESETS: CardRatioPreset[] = [
|
||||
|
||||
export const IMAGE_MODES: ImageMode[] = ['contain', 'cover']
|
||||
|
||||
export const LOG_ICONS: Record<LogLevel, string> = {
|
||||
export const LOG_ICONS: Record<ScrapeLogLevel, string> = {
|
||||
debug: '🔍',
|
||||
info: 'ℹ️',
|
||||
success: '✅',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
|
||||