before claude

This commit is contained in:
Gilles Soulier
2026-01-18 06:26:17 +01:00
parent dc19315e5d
commit 740c3d7516
60 changed files with 3815 additions and 354 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

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

View File

@@ -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={

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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")

View File

@@ -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 ###

View File

@@ -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.

View File

@@ -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."""

View File

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

View File

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

View File

@@ -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", [])

View File

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

View File

@@ -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,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

5
webui/dist/assets/index-BURbFjJa.css vendored Normal file

File diff suppressed because one or more lines are too long

18
webui/dist/assets/index-ZvFbjZEA.js vendored Normal file

File diff suppressed because one or more lines are too long

5
webui/dist/favicon.svg vendored Normal file
View 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
View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 181 KiB

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

View File

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

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

View File

@@ -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",

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

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

View File

@@ -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));

View File

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

View File

@@ -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: '✅',

View File

@@ -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,